Skip to content

Commit 2942108

Browse files
authored
fix: 分析后无报告生成 (#2201) (#2211)
* fix(issue-2201): [bug]-分析后无报告生成 * fix(review-feedback-2211): Keep the TTM cutoff anchored to the as-of date and Propagate report * fix(review-feedback-2211): Move the empty-watchlist check before the trading-day return * fix(review-feedback-2211): preserve the failure status, but defer returning until after the and * fix(review-feedback-2211): preserve the failure result, but defer returning until after the * fix(review-feedback-2211): Handle failures in the market-review-only CLI branch
1 parent c7ca990 commit 2942108

10 files changed

Lines changed: 765 additions & 47 deletions

data_provider/yfinance_fundamental_adapter.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ def get_fundamental_bundle(self, stock_code: str) -> Dict[str, Any]:
268268

269269
# ---------------- dividend block ----------------
270270
events: List[Dict[str, Any]] = []
271+
as_of_date = datetime.now(timezone.utc).date()
271272
try:
272273
div_series = ticker.dividends
273274
except Exception as exc:
@@ -281,8 +282,7 @@ def get_fundamental_bundle(self, stock_code: str) -> Dict[str, Any]:
281282
if hasattr(div_series, "columns"):
282283
div_series = div_series.iloc[:, 0]
283284
try:
284-
# Index is timezone-aware (ex-dividend date)
285-
cutoff = pd.Timestamp.now(tz=div_series.index.tz) - pd.Timedelta(days=365)
285+
cutoff = pd.Timestamp(as_of_date).tz_localize(div_series.index.tz) - pd.Timedelta(days=365)
286286
for ts, value in div_series.items():
287287
per_share = _safe_float(value)
288288
if per_share is None or per_share <= 0:
@@ -325,7 +325,7 @@ def get_fundamental_bundle(self, stock_code: str) -> Dict[str, Any]:
325325
"ttm_cash_dividend_per_share": round(ttm_cash, 6) if ttm_cash is not None else None,
326326
"coverage": "cash_dividend_pre_tax",
327327
"currency": dividend_currency,
328-
"as_of": datetime.now(timezone.utc).date().isoformat(),
328+
"as_of": as_of_date.isoformat(),
329329
}
330330

331331
# Yield: prefer recomputing from TTM cash / latest price so the

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1212
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
1313
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
1414
- [改进] AIHubMix 注册与引流链接统一使用 inferera.com,改善中国大陆网络直连体验。
15+
- [修复] 单股推送模式在未配置通知渠道时仍会落盘本地个股报告;CLI 启动分析若因空股票列表、个股结果全失败或本地报告保存失败而未生成报告,会显式返回失败并记录原因。
16+
- [修复] 合并推送模式下即使个股汇总报告落盘失败,仍会先发送已有的合并通知;仅启用大盘复盘但最终未生成任何复盘内容时,分析任务会显式返回失败。
1517

1618
## [3.30.0] - 2026-08-09
1719

docs/FAQ.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,22 @@ PROXY_PORT=10809
166166
1. **自动分块**:最新版本已实现长消息自动切割
167167
2. **单股推送模式**:设置 `SINGLE_STOCK_NOTIFY=true`,每分析完一只股票立即推送
168168
3. **精简报告**:设置 `REPORT_TYPE=simple` 使用精简格式
169+
4. **仅落盘本地报告**:即使未配置任何通知渠道,`SINGLE_STOCK_NOTIFY=true` 仍会把单股报告保存到 `reports/report_YYYYMMDD_<股票代码>.md`
169170

170171
---
171172

173+
### Q8.1: 分析结束了,但 `reports/` 里没有生成报告文件?
174+
175+
**常见原因**
176+
1. `STOCK_LIST` 为空,且本轮未启用大盘复盘
177+
2. 股票列表非空,但个股分析全部失败,最终没有成功结果
178+
3. 个股结果已生成,但写入 `reports/` 时失败(如目录权限或挂载问题)
179+
180+
**现在的行为**
181+
1. CLI 启动分析会对上述场景显式记录失败原因
182+
2.`--serve` 的独立运行模式会返回非零退出码,避免工作流把“未生成报告”误判为成功
183+
3. 若是单股推送模式且通知未配置,仍会继续保存本地 Markdown 报告用于排查
184+
172185
### Q9: Telegram 推送收不到消息?
173186

174187
**解决方案**

docs/FAQ_EN.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,22 @@ First confirm whether `LITELLM_CONFIG` or `LLM_CHANNELS` is active, because eith
164164
1. **Auto-chunking**: Latest version implements automatic long message splitting
165165
2. **Single stock push mode**: Set `SINGLE_STOCK_NOTIFY=true`, push immediately after each stock analysis
166166
3. **Brief report**: Set `REPORT_TYPE=simple` for simplified format
167+
4. **Local file fallback**: Even with no notification channel configured, `SINGLE_STOCK_NOTIFY=true` still saves each stock report to `reports/report_YYYYMMDD_<stock_code>.md`
167168

168169
---
169170

171+
### Q8.1: Analysis finished, but no report file was created under `reports/`?
172+
173+
**Common causes**:
174+
1. `STOCK_LIST` is empty and market review was not enabled for this run
175+
2. The stock list is non-empty, but every stock analysis failed so no successful result was produced
176+
3. Stock results were produced, but writing to `reports/` failed (for example due to permissions or mount issues)
177+
178+
**Current behavior**:
179+
1. CLI-triggered analysis now logs the exact failure reason for these cases
180+
2. Standalone non-`--serve` runs now return a non-zero exit code so workflows do not treat “no report generated” as success
181+
3. In single-stock push mode, local Markdown report saving still happens even when notifications are not configured
182+
170183
### Q9: Not receiving Telegram push messages?
171184

172185
**Solution**:

main.py

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
logger = logging.getLogger(__name__)
8282
_RUNTIME_ENV_FILE_KEYS = set()
8383
_PUBLIC_BIND_HOSTS = frozenset({"0.0.0.0", "::", "[::]", "*"})
84+
_LAST_ANALYSIS_FAILURE_REASON: Optional[str] = None
8485

8586

8687
def _get_active_env_path() -> Path:
@@ -750,6 +751,13 @@ def run_full_analysis(
750751
from src.core.market_review import run_market_review
751752
from src.core.pipeline import StockAnalysisPipeline
752753

754+
global _LAST_ANALYSIS_FAILURE_REASON
755+
_LAST_ANALYSIS_FAILURE_REASON = None
756+
757+
def _return_with_auto_backtest(result: bool) -> bool:
758+
_run_auto_backtest(config)
759+
return result
760+
753761
try:
754762
_refresh_stock_index_cache_for_analysis(config)
755763
if portfolio_stock_codes is not None:
@@ -759,8 +767,23 @@ def run_full_analysis(
759767
if stock_codes is None and portfolio_stock_codes is None:
760768
config.refresh_stock_list()
761769

762-
# Issue #373: Trading day filter (per-stock, per-market)
770+
using_config_stock_list = stock_codes is None and portfolio_stock_codes is None
763771
effective_codes = stock_codes if stock_codes is not None else config.stock_list
772+
# Fail fast on an empty persisted watchlist before trading-day filtering.
773+
# Otherwise should_skip=True would mask the configuration error as success.
774+
if (
775+
not getattr(args, "dry_run", False)
776+
and using_config_stock_list
777+
and not effective_codes
778+
and not market_review_requested
779+
):
780+
_LAST_ANALYSIS_FAILURE_REASON = "empty_stock_list"
781+
logger.error(
782+
"本轮分析未生成报告:STOCK_LIST 为空,且未启用大盘复盘。"
783+
)
784+
return _return_with_auto_backtest(False)
785+
786+
# Issue #373: Trading day filter (per-stock, per-market)
764787
filtered_codes, effective_region, should_skip = _compute_trading_day_filter(
765788
config, args, effective_codes
766789
)
@@ -811,6 +834,17 @@ def run_full_analysis(
811834
and not args.no_market_review
812835
and (market_review_region or '') != ''
813836
)
837+
if (
838+
not getattr(args, "dry_run", False)
839+
and not stock_codes
840+
and using_config_stock_list
841+
and not should_run_market_review
842+
):
843+
_LAST_ANALYSIS_FAILURE_REASON = "empty_stock_list"
844+
logger.error(
845+
"本轮分析未生成报告:STOCK_LIST 为空,且未启用大盘复盘。"
846+
)
847+
return _return_with_auto_backtest(False)
814848
should_use_daily_market_context = (
815849
should_run_market_review
816850
and getattr(config, 'daily_market_context_enabled', True)
@@ -990,6 +1024,33 @@ def run_full_analysis(
9901024
elif can_reuse_market_context:
9911025
market_report = market_context_full_report or market_context_summary
9921026

1027+
expected_stock_report = (
1028+
not getattr(args, "dry_run", False)
1029+
and bool(stock_codes)
1030+
and not skip_futu_stock_analysis
1031+
)
1032+
deferred_failure_result = None
1033+
if expected_stock_report and results and not getattr(
1034+
pipeline, "_last_local_report_path", None
1035+
):
1036+
_LAST_ANALYSIS_FAILURE_REASON = "report_save_failed"
1037+
save_error = getattr(pipeline, "_last_local_report_error", None) or "unknown error"
1038+
logger.error(
1039+
"本轮分析已生成个股结果,但汇总报告保存失败,未生成本地报告文件: %s",
1040+
save_error,
1041+
)
1042+
deferred_failure_result = False
1043+
expected_market_report = (
1044+
not getattr(args, "dry_run", False)
1045+
and should_run_market_review
1046+
)
1047+
if (expected_stock_report or expected_market_report) and not results and not market_report:
1048+
_LAST_ANALYSIS_FAILURE_REASON = "no_report"
1049+
logger.error(
1050+
"本轮分析未生成任何报告:预期的个股分析或大盘复盘均未产出结果。"
1051+
)
1052+
return _return_with_auto_backtest(False)
1053+
9931054
# Issue #190: 合并推送(个股+大盘复盘)
9941055
if merge_notification and (results or market_report) and not args.no_notify:
9951056
parts = []
@@ -1063,12 +1124,15 @@ def run_full_analysis(
10631124
except Exception as e:
10641125
logger.error(f"飞书文档生成失败: {e}")
10651126

1066-
# === Auto backtest ===
1067-
_run_auto_backtest(config)
1068-
1069-
return True
1127+
return _return_with_auto_backtest(
1128+
deferred_failure_result
1129+
if deferred_failure_result is not None
1130+
else True
1131+
)
10701132

10711133
except Exception as e:
1134+
if _LAST_ANALYSIS_FAILURE_REASON is None:
1135+
_LAST_ANALYSIS_FAILURE_REASON = "runtime_error"
10721136
logger.exception(f"分析流程执行失败: {e}")
10731137
if raise_errors:
10741138
raise
@@ -1088,19 +1152,31 @@ def _run_analysis_with_runtime_scheduler_lock(
10881152
config: Config,
10891153
args: argparse.Namespace,
10901154
stock_codes: Optional[List[str]] = None,
1091-
) -> None:
1155+
) -> bool:
10921156
from src.services.runtime_scheduler import run_with_global_analysis_lock
10931157

1158+
task_result: Dict[str, bool] = {"ok": True}
1159+
1160+
def _locked_task_runner(
1161+
locked_config: Config,
1162+
locked_args: argparse.Namespace,
1163+
locked_stock_codes: Optional[List[str]] = None,
1164+
) -> bool:
1165+
result = run_full_analysis(locked_config, locked_args, locked_stock_codes)
1166+
task_result["ok"] = bool(result)
1167+
return task_result["ok"]
1168+
10941169
# Keep startup/triggered analysis in sync with API runtime scheduler and
10951170
# run-now entrypoint. Blocking is expected here because startup paths should
10961171
# wait for an in-flight job before returning a response.
1097-
run_with_global_analysis_lock(
1098-
task_runner=run_full_analysis,
1172+
lock_acquired = run_with_global_analysis_lock(
1173+
task_runner=_locked_task_runner,
10991174
config=config,
11001175
args=args,
11011176
stock_codes=stock_codes,
11021177
blocking=True,
11031178
)
1179+
return bool(lock_acquired and task_result["ok"])
11041180

11051181

11061182
def start_api_server(host: str, port: int, config: Config) -> None:
@@ -1509,7 +1585,7 @@ def main() -> int:
15091585
logger.info("模式: 仅大盘复盘")
15101586
notifier, analyzer, search_service = build_market_review_runtime(config)
15111587

1512-
_run_market_review_with_shared_lock(
1588+
market_review_result = _run_market_review_with_shared_lock(
15131589
config,
15141590
run_market_review,
15151591
notifier=notifier,
@@ -1519,7 +1595,7 @@ def main() -> int:
15191595
override_region=effective_region,
15201596
trigger_source="cli",
15211597
)
1522-
return 0
1598+
return 0 if market_review_result else 1
15231599

15241600
# 模式2: 定时任务模式
15251601
if args.schedule or config.schedule_enabled:
@@ -1554,7 +1630,12 @@ def main() -> int:
15541630

15551631
def scheduled_task():
15561632
runtime_config = _reload_runtime_config()
1557-
run_full_analysis(runtime_config, args, scheduled_stock_codes)
1633+
result = run_full_analysis(runtime_config, args, scheduled_stock_codes)
1634+
if result is False:
1635+
reason = _LAST_ANALYSIS_FAILURE_REASON or "unknown"
1636+
raise RuntimeError(
1637+
f"scheduled analysis reported failure: {reason}"
1638+
)
15581639

15591640
background_tasks = []
15601641
if getattr(config, 'agent_event_monitor_enabled', False):
@@ -1592,14 +1673,24 @@ def event_monitor_task():
15921673
# 模式3: 正常单次运行
15931674
if config.run_immediately:
15941675
try:
1595-
_run_analysis_with_runtime_scheduler_lock(config, args, stock_codes)
1676+
analysis_ok = _run_analysis_with_runtime_scheduler_lock(config, args, stock_codes)
15961677
except FutuPortfolioError as exc:
15971678
if not start_serve:
15981679
raise
15991680
logger.exception(
16001681
"Futu 持仓导入失败,Web/API 服务继续运行: %s",
16011682
exc,
16021683
)
1684+
else:
1685+
if analysis_ok is False:
1686+
if start_serve:
1687+
logger.error("启动时分析执行失败,Web/API 服务继续运行。")
1688+
elif _LAST_ANALYSIS_FAILURE_REASON in {
1689+
"no_report",
1690+
"empty_stock_list",
1691+
"report_save_failed",
1692+
}:
1693+
return 1
16031694
else:
16041695
logger.info("配置为不立即运行分析 (RUN_IMMEDIATELY=false)")
16051696

0 commit comments

Comments
 (0)