mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 12:33:53 +08:00
fix: clarify runtime logging context (#1643)
* fix: clarify market review logging context * docs: update changelog for logging context
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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] 大盘复盘完成并已推送")
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 扫描误判为缺失。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -131,4 +131,5 @@ def perform_market_review(
|
||||
analyzer=pipeline.analyzer,
|
||||
search_service=pipeline.search_service,
|
||||
config=config,
|
||||
trigger_source="service",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user