fix: clarify runtime logging context (#1643)

* fix: clarify market review logging context

* docs: update changelog for logging context
This commit is contained in:
LouisHong
2026-06-09 01:08:55 +08:00
committed by GitHub
parent ff6494b5d7
commit 4f536026f8
18 changed files with 404 additions and 60 deletions
+14 -1
View File
@@ -144,10 +144,16 @@ def _run_market_review_background(
"send_notification": send_notification,
"override_region": override_region,
"return_structured": True,
"config": runtime_config,
"trigger_source": "api",
}
if query_id:
review_kwargs["query_id"] = query_id
logger.info(
"[MarketReview] component=market_review action=background_start "
"trigger_source=api task_id=%s region=%s",
query_id or "-",
override_region or getattr(runtime_config, "market_review_region", "cn") or "cn",
)
report = run_market_review(**review_kwargs)
if not report:
raise RuntimeError("大盘复盘未返回可持久化报告")
@@ -495,6 +501,13 @@ def trigger_market_review(
try:
task_id = uuid.uuid4().hex
logger.info(
"[MarketReview] component=market_review action=submit trigger_source=api "
"task_id=%s region=%s send_notification=%s",
task_id,
getattr(runtime_config, "market_review_region", "cn") or "cn",
request.send_notification,
)
task = get_task_queue().submit_background_task(
lambda: _run_market_review_background(
request.send_notification,
+1
View File
@@ -146,6 +146,7 @@ class MarketCommand(BotCommand):
search_service=search_service,
send_notification=True,
override_region=override_region,
trigger_source="bot",
)
if review_report:
logger.info("[MarketCommand] 大盘复盘完成并已推送")
+40 -4
View File
@@ -1764,24 +1764,60 @@ class AkshareFetcher(BaseFetcher):
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info("[API调用] ak.stock_zh_a_spot_em() 获取市场统计...")
started_at = time.monotonic()
logger.info(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot_em action=request_start"
)
df = ak.stock_zh_a_spot_em()
elapsed = time.monotonic() - started_at
logger.info(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot_em action=request_complete elapsed=%.2fs",
elapsed,
)
if df is not None and not df.empty:
return self._calc_market_stats(df)
logger.warning(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot_em action=parse status=empty"
)
except Exception as e:
logger.warning(f"[Akshare] 东财接口获取市场统计失败: {e},尝试新浪接口")
logger.warning(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot_em action=failed error=%s fallback=ak.stock_zh_a_spot",
e,
)
# 东财失败后,尝试新浪接口
try:
self._set_random_user_agent()
self._enforce_rate_limit()
logger.info("[API调用] ak.stock_zh_a_spot() 获取市场统计(新浪)...")
started_at = time.monotonic()
logger.info(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot action=request_start"
)
df = ak.stock_zh_a_spot()
elapsed = time.monotonic() - started_at
logger.info(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot action=request_complete elapsed=%.2fs",
elapsed,
)
if df is not None and not df.empty:
return self._calc_market_stats(df)
logger.warning(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot action=parse status=empty"
)
except Exception as e:
logger.error(f"[Akshare] 新浪接口获取市场统计也失败: {e}")
logger.error(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot action=failed error=%s",
e,
)
return None
+88 -13
View File
@@ -1375,12 +1375,12 @@ class DataFetcherManager:
# Issue #455: PREFETCH_REALTIME_QUOTES=false 可禁用预取,避免全市场拉取
if not getattr(config, "prefetch_realtime_quotes", True):
logger.debug("[预取] PREFETCH_REALTIME_QUOTES=false,跳过批量预取")
logger.debug("[预取] component=realtime_prefetch action=skip reason=disabled")
return 0
# 如果实时行情被禁用,跳过预取
if not config.enable_realtime_quote:
logger.debug("[预取] 实时行情功能已禁用,跳过预取")
logger.debug("[预取] component=realtime_prefetch action=skip reason=realtime_quote_disabled")
return 0
# 检查优先级中是否包含全量拉取数据源
@@ -1400,15 +1400,29 @@ class DataFetcherManager:
# 如果没有全量数据源,或者全量数据源排在第 3 位之后,跳过预取
if first_bulk_source_index is None or first_bulk_source_index >= 2:
logger.info(f"[预取] 当前优先级使用轻量级数据源(sina/tencent),无需预取")
logger.info(
"[预取] component=realtime_prefetch action=skip reason=no_early_bulk_source priority=%s",
priority,
)
return 0
# 如果股票数量少于 5 个,不进行批量预取(逐个查询更高效)
if len(stock_codes) < 5:
logger.info(f"[预取] 股票数量 {len(stock_codes)} < 5,跳过批量预取")
logger.info(
"[预取] component=realtime_prefetch action=skip reason=small_batch "
"stock_count=%d threshold=5 bulk_source=%s",
len(stock_codes),
priority_list[first_bulk_source_index],
)
return 0
logger.info(f"[预取] 开始批量预取实时行情,共 {len(stock_codes)} 只股票...")
bulk_source = priority_list[first_bulk_source_index]
logger.info(
"[预取] component=realtime_prefetch action=start stock_count=%d bulk_source=%s first_code=%s",
len(stock_codes),
bulk_source,
stock_codes[0],
)
# 尝试通过 efinance 或 akshare 预取
# 只需要调用一次 get_realtime_quote,缓存机制会自动拉取全市场数据
@@ -1418,14 +1432,30 @@ class DataFetcherManager:
quote = self.get_realtime_quote(first_code)
if quote:
logger.info(f"[预取] 批量预取完成,缓存已填充")
logger.info(
"[预取] component=realtime_prefetch action=complete status=success "
"stock_count=%d bulk_source=%s",
len(stock_codes),
bulk_source,
)
return len(stock_codes)
else:
logger.warning(f"[预取] 批量预取失败,将使用逐个查询模式")
logger.warning(
"[预取] component=realtime_prefetch action=complete status=failed "
"stock_count=%d bulk_source=%s fallback=per_stock",
len(stock_codes),
bulk_source,
)
return 0
except Exception as e:
logger.error(f"[预取] 批量预取异常: {e}")
logger.error(
"[预取] component=realtime_prefetch action=complete status=error "
"stock_count=%d bulk_source=%s error=%s",
len(stock_codes),
bulk_source,
e,
)
return 0
@staticmethod
@@ -2116,27 +2146,72 @@ class DataFetcherManager:
continue
return []
def get_market_stats(self) -> Dict[str, Any]:
def get_market_stats(self, *, purpose: str = "unspecified") -> Dict[str, Any]:
"""获取市场涨跌统计(自动切换数据源)"""
logger.info("[MarketStats] component=market_stats action=start purpose=%s", purpose)
tickflow_fetcher = self._get_tickflow_fetcher()
if tickflow_fetcher is not None:
started_at = time.monotonic()
try:
data = tickflow_fetcher.get_market_stats()
elapsed = time.monotonic() - started_at
if data:
logger.info("[TickFlowFetcher] 获取市场统计成功")
logger.info(
"[MarketStats] component=market_stats action=provider_success "
"purpose=%s provider=TickFlowFetcher elapsed=%.2fs",
purpose,
elapsed,
)
return data
logger.info(
"[MarketStats] component=market_stats action=provider_empty "
"purpose=%s provider=TickFlowFetcher elapsed=%.2fs",
purpose,
elapsed,
)
except Exception as e:
logger.warning(f"[TickFlowFetcher] 获取市场统计失败: {e}")
elapsed = time.monotonic() - started_at
logger.warning(
"[MarketStats] component=market_stats action=provider_failed "
"purpose=%s provider=TickFlowFetcher elapsed=%.2fs error=%s",
purpose,
elapsed,
e,
)
for fetcher in self._fetchers:
started_at = time.monotonic()
try:
data = fetcher.get_market_stats()
elapsed = time.monotonic() - started_at
if data:
logger.info(f"[{fetcher.name}] 获取市场统计成功")
logger.info(
"[MarketStats] component=market_stats action=provider_success "
"purpose=%s provider=%s elapsed=%.2fs",
purpose,
fetcher.name,
elapsed,
)
return data
logger.info(
"[MarketStats] component=market_stats action=provider_empty "
"purpose=%s provider=%s elapsed=%.2fs",
purpose,
fetcher.name,
elapsed,
)
except Exception as e:
logger.warning(f"[{fetcher.name}] 获取市场统计失败: {e}")
elapsed = time.monotonic() - started_at
logger.warning(
"[MarketStats] component=market_stats action=provider_failed "
"purpose=%s provider=%s elapsed=%.2fs error=%s",
purpose,
fetcher.name,
elapsed,
e,
)
continue
logger.warning("[MarketStats] component=market_stats action=complete status=empty purpose=%s", purpose)
return {}
def _run_with_timeout(
+25 -3
View File
@@ -934,19 +934,41 @@ class EfinanceFetcher(BaseFetcher):
current_time - _realtime_cache['timestamp'] < _realtime_cache['ttl']
):
df = _realtime_cache['data']
logger.info(
"[MarketStats] component=market_stats provider=EfinanceFetcher "
"api=ef.stock.get_realtime_quotes action=cache_hit cache_age=%.0fs",
current_time - _realtime_cache['timestamp'],
)
else:
logger.info("[API调用] ef.stock.get_realtime_quotes() 获取市场统计...")
started_at = time.monotonic()
logger.info(
"[MarketStats] component=market_stats provider=EfinanceFetcher "
"api=ef.stock.get_realtime_quotes action=request_start"
)
df = _ef_call_with_timeout(ef.stock.get_realtime_quotes)
elapsed = time.monotonic() - started_at
logger.info(
"[MarketStats] component=market_stats provider=EfinanceFetcher "
"api=ef.stock.get_realtime_quotes action=request_complete elapsed=%.2fs",
elapsed,
)
_realtime_cache['data'] = df
_realtime_cache['timestamp'] = current_time
if df is None or df.empty:
logger.warning("[API返回] 市场统计数据为空")
logger.warning(
"[MarketStats] component=market_stats provider=EfinanceFetcher "
"api=ef.stock.get_realtime_quotes action=parse status=empty"
)
return None
return self._calc_market_stats(df)
except Exception as e:
logger.error(f"[efinance] 获取市场统计失败: {e}")
logger.error(
"[MarketStats] component=market_stats provider=EfinanceFetcher "
"api=ef.stock.get_realtime_quotes action=failed error=%s",
e,
)
return None
def _calc_market_stats(
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [改进] #1390 P0 为个股分析与历史/回测展示新增可选八态 `action` / `action_label` 建议动作字段,保留 `operation_advice` 自由文本和 `decision_type=buy|hold|sell` 统计口径,不新增迁移或配置项。
- [修复] #1390 收紧建议动作 legacy fallback:英文 `not to ...` 与 `avoid selling/reducing/trimming ...` 等否定/回避表达不再误判为买卖动作,Web 旧记录不再把中文金融上下文、`buy or sell`、多 guard 歧义文本或 `buyback` / `buy-back` / `buy back` / `selloff` / `sell-off` / `sell off` 等英文复合词渲染成 action badge,并在有结构化 `action` 时让回测/历史趋势等入口按界面语言显示 action 标签。
- [改进] 完善运行时日志上下文,补充 logger name、触发来源、市场统计与实时行情预取链路状态,便于排查调度、API、Bot 和数据源降级路径。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [修复] 桌面发布打包改用冻结可执行文件运行时探针校验 `alphasift.dsa_adapter`,避免 macOS PyInstaller 将模块内嵌进可执行文件时被文件系统/zip 扫描误判为缺失。
+7
View File
@@ -587,6 +587,11 @@ def run_full_analysis(
and not args.no_market_review
and effective_region != ''
):
schedule_mode = bool(
getattr(args, 'schedule', False)
or getattr(config, 'schedule_enabled', False)
)
review_trigger_source = "schedule" if schedule_mode else "cli"
review_result = _run_market_review_with_shared_lock(
config,
run_market_review,
@@ -596,6 +601,7 @@ def run_full_analysis(
send_notification=not args.no_notify,
merge_notification=merge_notification,
override_region=effective_region,
trigger_source=review_trigger_source,
)
# 如果有结果,赋值给 market_report 用于后续飞书文档生成
if review_result:
@@ -965,6 +971,7 @@ def main() -> int:
search_service=search_service,
send_notification=not args.no_notify,
override_region=effective_region,
trigger_source="cli",
)
return 0
+72 -9
View File
@@ -94,6 +94,7 @@ def run_market_review(
override_region: Optional[str] = None,
query_id: Optional[str] = None,
return_structured: bool = False,
trigger_source: str = "cli",
) -> Optional[str] | Optional[MarketReviewRunResult]:
"""
执行大盘复盘分析
@@ -107,11 +108,11 @@ def run_market_review(
merge_notification: 是否合并推送(跳过本次推送,由 main 层合并个股+大盘后统一发送,Issue #190)
override_region: 覆盖 config 的 market_review_region(Issue #373 交易日过滤后有效子集)
query_id: 历史记录关联 ID;API 后台任务会传入 task_id,CLI/Bot 为空时自动生成
trigger_source: 触发来源,用于日志排障(cli/schedule/api/bot/service 等)
Returns:
复盘报告文本
"""
logger.info("开始执行大盘复盘分析...")
runtime_config = config or get_config()
review_text = _get_market_review_text(getattr(runtime_config, "report_language", "zh"))
raw_region = (
@@ -121,6 +122,12 @@ def run_market_review(
)
run_markets = _resolve_market_review_regions(raw_region)
persist_region = ','.join(run_markets) if len(run_markets) > 1 else run_markets[0]
logger.info(
"[MarketReview] component=market_review action=start trigger_source=%s query_id=%s region=%s",
trigger_source,
query_id or "-",
persist_region,
)
try:
if len(run_markets) > 1:
@@ -131,7 +138,14 @@ def run_market_review(
for mkt, title_key, label in _MARKET_REVIEW_MARKETS:
if mkt not in run_markets:
continue
logger.info("生成 %s 大盘复盘报告...", label)
logger.info(
"[MarketReview] component=market_review action=build_report "
"trigger_source=%s query_id=%s region=%s label=%s",
trigger_source,
query_id or "-",
mkt,
label,
)
mkt_analyzer = MarketAnalyzer(
search_service=search_service,
analyzer=analyzer,
@@ -154,6 +168,18 @@ def run_market_review(
review_report = None
else:
run_region = run_markets[0]
label = next(
(market_label for mkt, _, market_label in _MARKET_REVIEW_MARKETS if mkt == run_region),
run_region,
)
logger.info(
"[MarketReview] component=market_review action=build_report "
"trigger_source=%s query_id=%s region=%s label=%s",
trigger_source,
query_id or "-",
run_region,
label,
)
market_analyzer = MarketAnalyzer(
search_service=search_service,
analyzer=analyzer,
@@ -190,7 +216,14 @@ def run_market_review(
markdown_report,
report_filename
)
logger.info(f"大盘复盘报告已保存: {filepath}")
logger.info(
"[MarketReview] component=market_review action=save_report "
"trigger_source=%s query_id=%s region=%s path=%s",
trigger_source,
query_id or "-",
persist_region,
filepath,
)
_persist_market_review_history(
review_report=review_report,
@@ -204,7 +237,13 @@ def run_market_review(
# 推送通知(合并模式下跳过,由 main 层统一发送)
if merge_notification and send_notification:
logger.info("合并推送模式:跳过大盘复盘单独推送,将在个股+大盘复盘后统一发送")
logger.info(
"[MarketReview] component=market_review action=skip_standalone_notification "
"trigger_source=%s query_id=%s region=%s",
trigger_source,
query_id or "-",
persist_region,
)
elif send_notification and notifier.is_available():
# 添加标题
report_content = _render_market_review_payload_markdown(
@@ -214,11 +253,29 @@ def run_market_review(
success = notifier.send(report_content, email_send_to_all=True, route_type="report")
if success:
logger.info("大盘复盘推送成功")
logger.info(
"[MarketReview] component=market_review action=send_notification "
"status=success trigger_source=%s query_id=%s region=%s",
trigger_source,
query_id or "-",
persist_region,
)
else:
logger.warning("大盘复盘推送失败")
logger.warning(
"[MarketReview] component=market_review action=send_notification "
"status=failed trigger_source=%s query_id=%s region=%s",
trigger_source,
query_id or "-",
persist_region,
)
elif not send_notification:
logger.info("已跳过推送通知 (--no-notify)")
logger.info(
"[MarketReview] component=market_review action=skip_notification "
"reason=no_notify trigger_source=%s query_id=%s region=%s",
trigger_source,
query_id or "-",
persist_region,
)
if return_structured:
return MarketReviewRunResult(
@@ -227,8 +284,14 @@ def run_market_review(
)
return review_report
except Exception as e:
logger.error(f"大盘复盘分析失败: {e}")
except Exception:
logger.exception(
"[MarketReview] component=market_review action=failed "
"trigger_source=%s query_id=%s region=%s",
trigger_source,
query_id or "-",
persist_region,
)
return None
+1 -1
View File
@@ -19,7 +19,7 @@ from pathlib import Path
from typing import List, Optional, Tuple
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(pathname)s:%(lineno)d | %(message)s"
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(pathname)s:%(lineno)d | %(message)s"
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_ALLOWED_LOG_LEVELS = {
'DEBUG': logging.DEBUG,
+67 -23
View File
@@ -141,6 +141,9 @@ class MarketAnalyzer:
self.profile: MarketProfile = get_profile(self.region)
self.strategy = get_market_strategy_blueprint(self.region)
def _log_context(self) -> str:
return f"component=market_review region={self.region}"
def _get_review_language(self) -> str:
return normalize_report_language(
getattr(getattr(self, "config", None), "report_language", "zh")
@@ -352,7 +355,7 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
indices = []
try:
logger.info("[大盘] 获取主要指数实时行情...")
logger.info("[大盘] %s action=get_main_indices status=start", self._log_context())
# 使用 DataFetcherManager 获取指数行情(按 region 切换)
data_list = self.data_manager.get_main_indices(region=self.region)
@@ -376,21 +379,25 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
indices.append(index)
if not indices:
logger.warning("[大盘] 所有行情数据源失败,将依赖新闻搜索进行分析")
logger.warning("[大盘] %s action=get_main_indices status=empty", self._log_context())
else:
logger.info(f"[大盘] 获取到 {len(indices)} 个指数行情")
logger.info(
"[大盘] %s action=get_main_indices status=success count=%d",
self._log_context(),
len(indices),
)
except Exception as e:
logger.error(f"[大盘] 获取指数行情失败: {e}")
logger.error("[大盘] %s action=get_main_indices status=failed error=%s", self._log_context(), e)
return indices
def _get_market_statistics(self, overview: MarketOverview):
"""获取市场涨跌统计"""
try:
logger.info("[大盘] 获取市场涨跌统计...")
logger.info("[大盘] %s action=get_market_stats status=start", self._log_context())
stats = self.data_manager.get_market_stats()
stats = self.data_manager.get_market_stats(purpose=f"market_review:{self.region}")
if stats:
overview.up_count = stats.get('up_count', 0)
@@ -400,17 +407,27 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
overview.limit_down_count = stats.get('limit_down_count', 0)
overview.total_amount = stats.get('total_amount', 0.0)
logger.info(f"[大盘] 涨:{overview.up_count} 跌:{overview.down_count} 平:{overview.flat_count} "
f"涨停:{overview.limit_up_count} 跌停:{overview.limit_down_count} "
f"成交额:{overview.total_amount:.0f}亿")
logger.info(
"[大盘] %s action=get_market_stats status=success up=%s down=%s flat=%s "
"limit_up=%s limit_down=%s amount=%.0f亿",
self._log_context(),
overview.up_count,
overview.down_count,
overview.flat_count,
overview.limit_up_count,
overview.limit_down_count,
overview.total_amount,
)
else:
logger.warning("[大盘] %s action=get_market_stats status=empty", self._log_context())
except Exception as e:
logger.error(f"[大盘] 获取涨跌统计失败: {e}")
logger.error("[大盘] %s action=get_market_stats status=failed error=%s", self._log_context(), e)
def _get_sector_rankings(self, overview: MarketOverview):
"""获取板块涨跌榜"""
try:
logger.info("[大盘] 获取板块涨跌榜...")
logger.info("[大盘] %s action=get_sector_rankings status=start", self._log_context())
top_sectors, bottom_sectors = self.data_manager.get_sector_rankings(5)
@@ -418,11 +435,17 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
overview.top_sectors = top_sectors
overview.bottom_sectors = bottom_sectors
logger.info(f"[大盘] 领涨板块: {[s['name'] for s in overview.top_sectors]}")
logger.info(f"[大盘] 领跌板块: {[s['name'] for s in overview.bottom_sectors]}")
logger.info(
"[大盘] %s action=get_sector_rankings status=success top=%s bottom=%s",
self._log_context(),
[s['name'] for s in overview.top_sectors],
[s['name'] for s in overview.bottom_sectors],
)
else:
logger.warning("[大盘] %s action=get_sector_rankings status=empty", self._log_context())
except Exception as e:
logger.error(f"[大盘] 获取板块涨跌榜失败: {e}")
logger.error("[大盘] %s action=get_sector_rankings status=failed error=%s", self._log_context(), e)
# def _get_north_flow(self, overview: MarketOverview):
# """获取北向资金流入"""
@@ -453,7 +476,10 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
新闻列表
"""
if not self.search_service:
logger.warning("[大盘] 搜索服务未配置,跳过新闻搜索")
logger.warning(
"[大盘] %s action=search_market_news status=skipped reason=no_search_service",
self._log_context(),
)
return []
all_news = []
@@ -468,7 +494,7 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
}
try:
logger.info("[大盘] 开始搜索市场新闻...")
logger.info("[大盘] %s action=search_market_news status=start", self._log_context())
# 根据 region 设置搜索上下文名称,避免美股搜索被解读为 A 股语境
market_name = market_names.get(self.region, "大盘")
@@ -481,12 +507,20 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
)
if response and response.results:
all_news.extend(response.results)
logger.info(f"[大盘] 搜索 '{query}' 获取 {len(response.results)} 条结果")
logger.info(
"[大盘] %s action=search_market_news status=query_success count=%d",
self._log_context(),
len(response.results),
)
logger.info(f"[大盘] 共获取 {len(all_news)} 条市场新闻")
logger.info(
"[大盘] %s action=search_market_news status=success count=%d",
self._log_context(),
len(all_news),
)
except Exception as e:
logger.error(f"[大盘] 搜索市场新闻失败: {e}")
logger.error("[大盘] %s action=search_market_news status=failed error=%s", self._log_context(), e)
return all_news
@@ -502,22 +536,32 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
大盘复盘报告文本
"""
if not self.analyzer or not self.analyzer.is_available():
logger.warning("[大盘] AI分析器未配置或不可用,使用模板生成报告")
logger.warning(
"[大盘] %s action=generate_review status=fallback_template reason=no_analyzer",
self._log_context(),
)
return self._generate_template_review(overview, news)
# 构建 Prompt
prompt = self._build_review_prompt(overview, news)
logger.info("[大盘] 调用大模型生成复盘报告...")
logger.info("[大盘] %s action=generate_review status=start", self._log_context())
# Use the public generate_text() entry point - never access private analyzer attributes.
review = self.analyzer.generate_text(prompt, max_tokens=8192, temperature=0.7)
if review:
logger.info("[大盘] 复盘报告生成成功,长度: %d 字符", len(review))
logger.info(
"[大盘] %s action=generate_review status=success length=%d",
self._log_context(),
len(review),
)
# Inject structured data tables into LLM prose sections
return self._inject_data_into_review(review, overview, news)
logger.warning("[大盘] 大模型返回为空,使用模板报告")
logger.warning(
"[大盘] %s action=generate_review status=fallback_template reason=empty_llm_response",
self._log_context(),
)
return self._generate_template_review(overview, news)
def build_market_review_payload(
+1
View File
@@ -131,4 +131,5 @@ def perform_market_review(
analyzer=pipeline.analyzer,
search_service=pipeline.search_service,
config=config,
trigger_source="service",
)
+4
View File
@@ -194,6 +194,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
call_kwargs = run_market_review.call_args.kwargs
self.assertEqual(call_kwargs["send_notification"], True)
self.assertIsNone(call_kwargs["override_region"])
self.assertEqual(call_kwargs["trigger_source"], "api")
runtime_config = call_kwargs.get("config")
self.assertIsNotNone(runtime_config)
self.assertEqual(getattr(runtime_config, "report_language", None), "en")
@@ -246,6 +247,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
call_kwargs = run_market_review.call_args.kwargs
runtime_config = call_kwargs.get("config")
self.assertEqual(getattr(runtime_config, "report_language", None), "en")
self.assertEqual(call_kwargs["trigger_source"], "api")
def test_trigger_market_review_rejects_duplicate_submission(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
@@ -376,6 +378,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
send_notification=False,
override_region="cn,us",
return_structured=True,
trigger_source="api",
)
def test_market_review_runtime_initializes_analyzer_for_litellm_provider(self) -> None:
@@ -430,6 +433,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
send_notification=False,
override_region="cn",
return_structured=True,
trigger_source="api",
)
def test_run_market_review_uses_request_scoped_config_language(self) -> None:
+3
View File
@@ -122,6 +122,7 @@ class MarketCommandRegionFilterTestCase(unittest.TestCase):
search_service=runtime_search,
send_notification=True,
override_region="cn,us",
trigger_source="bot",
)
kwargs = market_review_module.run_market_review.call_args.kwargs
self.assertEqual(kwargs.get("override_region"), "cn,us")
@@ -147,6 +148,7 @@ class MarketCommandRegionFilterTestCase(unittest.TestCase):
search_service=runtime_search,
send_notification=True,
override_region="cn,hk",
trigger_source="bot",
)
market_review_module.run_market_review.assert_called_once()
kwargs = market_review_module.run_market_review.call_args.kwargs
@@ -192,6 +194,7 @@ class MarketCommandRegionFilterTestCase(unittest.TestCase):
search_service=runtime_search,
send_notification=True,
override_region=None,
trigger_source="bot",
)
market_review_module.run_market_review.assert_called_once()
kwargs = market_review_module.run_market_review.call_args.kwargs
+12
View File
@@ -39,6 +39,18 @@ def _read_debug_log(log_dir) -> str:
return debug_log.read_text(encoding="utf-8")
def test_log_format_includes_logger_name(tmp_path, monkeypatch):
monkeypatch.delenv("LITELLM_LOG_LEVEL", raising=False)
setup_logging(log_prefix="stock_analysis", log_dir=str(tmp_path), debug=False)
logging.getLogger("src.sample").info("logger context smoke")
debug_log_text = _read_debug_log(tmp_path)
assert " | src.sample | " in debug_log_text
assert "logger context smoke" in debug_log_text
@pytest.mark.parametrize("env_value", [None, "", " "])
def test_litellm_debug_is_quiet_by_default_and_empty_env(tmp_path, monkeypatch, env_value):
if env_value is None:
+29
View File
@@ -522,6 +522,34 @@ class MainScheduleModeTestCase(unittest.TestCase):
pipeline.run.assert_called_once()
run_market_review.assert_not_called()
def test_config_enabled_schedule_marks_market_review_source_as_schedule(self) -> None:
args = self._make_args(schedule=False)
config = self._make_config(
schedule_enabled=True,
trading_day_check_enabled=False,
market_review_enabled=True,
no_market_review=False,
single_stock_notify=False,
merge_email_notification=False,
analysis_delay=0,
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
)
pipeline = MagicMock()
pipeline.run.return_value = []
with patch.object(main, "_refresh_stock_index_cache_for_analysis"), \
patch.object(main, "_compute_trading_day_filter", return_value=(["600519"], "cn", False)), \
patch("src.core.pipeline.StockAnalysisPipeline", return_value=pipeline), \
patch("main._run_market_review_with_shared_lock", return_value="market report") as run_with_lock, \
patch("src.core.market_review.run_market_review") as run_market_review:
main.run_full_analysis(config, args, ["600519"])
pipeline.run.assert_called_once()
run_with_lock.assert_called_once()
call_args = run_with_lock.call_args
self.assertIs(call_args.args[1], run_market_review)
self.assertEqual(call_args.kwargs["trigger_source"], "schedule")
def test_market_review_mode_uses_shared_runtime_assembly(self) -> None:
args = self._make_args(market_review=True)
config = self._make_config(
@@ -563,6 +591,7 @@ class MainScheduleModeTestCase(unittest.TestCase):
self.assertTrue(call_args.kwargs["send_notification"])
self.assertNotIn("merge_notification", call_args.kwargs)
self.assertEqual(call_args.kwargs["override_region"], "cn,us")
self.assertEqual(call_args.kwargs["trigger_source"], "cli")
def test_bootstrap_logging_persists_when_config_load_fails(self) -> None:
"""Config load failure must be logged to stderr and return exit code 1.
+22 -1
View File
@@ -3,7 +3,7 @@
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from src.core.market_strategy import get_market_strategy_blueprint
from src.market_analyzer import MarketAnalyzer, MarketOverview
@@ -71,6 +71,27 @@ class TestMarketAnalyzerStrategyPrompt(unittest.TestCase):
self.assertNotIn("### 一、市场总结", prompt)
self.assertNotIn("A股市场三段式复盘策略", prompt)
def test_market_stats_passes_market_review_purpose(self):
analyzer = MarketAnalyzer.__new__(MarketAnalyzer)
analyzer.region = "hk"
analyzer.data_manager = MagicMock()
analyzer.data_manager.get_market_stats.return_value = {
"up_count": 3,
"down_count": 2,
"flat_count": 1,
"limit_up_count": 0,
"limit_down_count": 0,
"total_amount": 12.0,
}
overview = MarketOverview(date="2026-02-24")
analyzer._get_market_statistics(overview)
analyzer.data_manager.get_market_stats.assert_called_once_with(
purpose="market_review:hk"
)
self.assertEqual(overview.up_count, 3)
if __name__ == "__main__":
unittest.main()
+16 -4
View File
@@ -190,7 +190,10 @@ class PortfolioApiTestCase(unittest.TestCase):
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([accepted_task], [])
with patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
with patch(
"src.services.portfolio_service.PortfolioService._fetch_realtime_position_price",
return_value=(None, None),
), patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
resp = self.client.post(
"/api/v1/portfolio/positions/600519/analysis",
json={"account_id": account_id, "analysis_phase": "intraday", "force": True},
@@ -223,7 +226,10 @@ class PortfolioApiTestCase(unittest.TestCase):
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([accepted_task], [])
with patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
with patch(
"src.services.portfolio_service.PortfolioService._fetch_realtime_position_price",
return_value=(None, None),
), patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
resp = self.client.post(
"/api/v1/portfolio/positions/600519.SH/analysis",
json={"account_id": account_id},
@@ -250,7 +256,10 @@ class PortfolioApiTestCase(unittest.TestCase):
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([accepted_task], [])
with patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
with patch(
"src.services.portfolio_service.PortfolioService._fetch_realtime_position_price",
return_value=(None, None),
), patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
resp = self.client.post(
"/api/v1/portfolio/positions/1810.HK/analysis",
json={"account_id": account_id},
@@ -284,7 +293,10 @@ class PortfolioApiTestCase(unittest.TestCase):
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [duplicate])
with patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
with patch(
"src.services.portfolio_service.PortfolioService._fetch_realtime_position_price",
return_value=(None, None),
), patch("api.v1.endpoints.portfolio.get_task_queue", return_value=queue):
resp = self.client.post(
"/api/v1/portfolio/positions/600519/analysis",
json={"account_id": account_id, "force": True},
@@ -115,7 +115,7 @@ class TestTickFlowMarketReviewFallback(unittest.TestCase):
error=RuntimeError("tickflow down")
)
data = DataFetcherManager.get_market_stats(manager)
data = DataFetcherManager.get_market_stats(manager, purpose="market_review:cn")
self.assertEqual(data["up_count"], 1)
self.assertEqual(fallback.stats_calls, 1)