deeptrade-quant 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. deeptrade/__init__.py +8 -0
  2. deeptrade/channels_builtin/__init__.py +0 -0
  3. deeptrade/channels_builtin/stdout/__init__.py +0 -0
  4. deeptrade/channels_builtin/stdout/deeptrade_plugin.yaml +25 -0
  5. deeptrade/channels_builtin/stdout/migrations/20260429_001_init.sql +13 -0
  6. deeptrade/channels_builtin/stdout/stdout_channel/__init__.py +0 -0
  7. deeptrade/channels_builtin/stdout/stdout_channel/channel.py +180 -0
  8. deeptrade/cli.py +214 -0
  9. deeptrade/cli_config.py +396 -0
  10. deeptrade/cli_data.py +33 -0
  11. deeptrade/cli_plugin.py +176 -0
  12. deeptrade/core/__init__.py +8 -0
  13. deeptrade/core/config.py +344 -0
  14. deeptrade/core/config_migrations.py +138 -0
  15. deeptrade/core/db.py +176 -0
  16. deeptrade/core/llm_client.py +591 -0
  17. deeptrade/core/llm_manager.py +174 -0
  18. deeptrade/core/logging_config.py +61 -0
  19. deeptrade/core/migrations/__init__.py +0 -0
  20. deeptrade/core/migrations/core/20260427_001_init.sql +121 -0
  21. deeptrade/core/migrations/core/20260501_002_drop_llm_calls_stage.sql +10 -0
  22. deeptrade/core/migrations/core/__init__.py +0 -0
  23. deeptrade/core/notifier.py +302 -0
  24. deeptrade/core/paths.py +49 -0
  25. deeptrade/core/plugin_manager.py +616 -0
  26. deeptrade/core/run_status.py +29 -0
  27. deeptrade/core/secrets.py +152 -0
  28. deeptrade/core/tushare_client.py +824 -0
  29. deeptrade/plugins_api/__init__.py +44 -0
  30. deeptrade/plugins_api/base.py +66 -0
  31. deeptrade/plugins_api/channel.py +42 -0
  32. deeptrade/plugins_api/events.py +61 -0
  33. deeptrade/plugins_api/llm.py +46 -0
  34. deeptrade/plugins_api/metadata.py +84 -0
  35. deeptrade/plugins_api/notify.py +67 -0
  36. deeptrade/strategies_builtin/__init__.py +0 -0
  37. deeptrade/strategies_builtin/limit_up_board/__init__.py +0 -0
  38. deeptrade/strategies_builtin/limit_up_board/deeptrade_plugin.yaml +101 -0
  39. deeptrade/strategies_builtin/limit_up_board/limit_up_board/__init__.py +0 -0
  40. deeptrade/strategies_builtin/limit_up_board/limit_up_board/calendar.py +65 -0
  41. deeptrade/strategies_builtin/limit_up_board/limit_up_board/cli.py +269 -0
  42. deeptrade/strategies_builtin/limit_up_board/limit_up_board/config.py +76 -0
  43. deeptrade/strategies_builtin/limit_up_board/limit_up_board/data.py +1191 -0
  44. deeptrade/strategies_builtin/limit_up_board/limit_up_board/pipeline.py +869 -0
  45. deeptrade/strategies_builtin/limit_up_board/limit_up_board/plugin.py +30 -0
  46. deeptrade/strategies_builtin/limit_up_board/limit_up_board/profiles.py +85 -0
  47. deeptrade/strategies_builtin/limit_up_board/limit_up_board/prompts.py +485 -0
  48. deeptrade/strategies_builtin/limit_up_board/limit_up_board/render.py +890 -0
  49. deeptrade/strategies_builtin/limit_up_board/limit_up_board/runner.py +1087 -0
  50. deeptrade/strategies_builtin/limit_up_board/limit_up_board/runtime.py +172 -0
  51. deeptrade/strategies_builtin/limit_up_board/limit_up_board/schemas.py +178 -0
  52. deeptrade/strategies_builtin/limit_up_board/migrations/20260430_001_init.sql +150 -0
  53. deeptrade/strategies_builtin/limit_up_board/migrations/20260501_002_lub_stage_results_llm_provider.sql +8 -0
  54. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_001_lub_lhb_tables.sql +36 -0
  55. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_002_lub_cyq_perf.sql +18 -0
  56. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_003_lub_lhb_pk_fix.sql +46 -0
  57. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_004_lub_lhb_drop_pk.sql +53 -0
  58. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_005_lub_config.sql +17 -0
  59. deeptrade/strategies_builtin/volume_anomaly/__init__.py +0 -0
  60. deeptrade/strategies_builtin/volume_anomaly/deeptrade_plugin.yaml +59 -0
  61. deeptrade/strategies_builtin/volume_anomaly/migrations/20260430_001_init.sql +94 -0
  62. deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_001_realized_returns.sql +44 -0
  63. deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_002_dimension_scores.sql +13 -0
  64. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/__init__.py +0 -0
  65. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/calendar.py +52 -0
  66. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/cli.py +247 -0
  67. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/data.py +2154 -0
  68. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/pipeline.py +327 -0
  69. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/plugin.py +22 -0
  70. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/profiles.py +49 -0
  71. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts.py +187 -0
  72. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts_examples.py +84 -0
  73. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/render.py +906 -0
  74. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runner.py +772 -0
  75. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runtime.py +90 -0
  76. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/schemas.py +97 -0
  77. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/stats.py +174 -0
  78. deeptrade/theme.py +48 -0
  79. deeptrade_quant-0.0.2.dist-info/METADATA +166 -0
  80. deeptrade_quant-0.0.2.dist-info/RECORD +83 -0
  81. deeptrade_quant-0.0.2.dist-info/WHEEL +4 -0
  82. deeptrade_quant-0.0.2.dist-info/entry_points.txt +2 -0
  83. deeptrade_quant-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,906 @@
1
+ """Render & export the volume-anomaly final reports.
2
+
3
+ Three report flavours, one per mode:
4
+ screen → screen_summary.md + screen_hits.json + screen_stats.json
5
+ analyze → analyze_summary.md + analyze_predictions.json + data_snapshot.json
6
+ prune → prune_summary.md + pruned_codes.json
7
+
8
+ All include the『已追踪时长』column when relevant.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import math
16
+ from dataclasses import asdict, dataclass, field, is_dataclass
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from deeptrade.core import paths
21
+ from deeptrade.core.run_status import RunStatus
22
+
23
+ from .data import AnalyzeBundle, ScreenDiagnostics, ScreenResult, ScreenRules
24
+ from .schemas import VATrendCandidate
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Banner
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def render_banners(
35
+ *,
36
+ status: RunStatus,
37
+ is_intraday: bool,
38
+ failed_batch_ids: list[str] | None = None,
39
+ ) -> str:
40
+ parts: list[str] = []
41
+ if status in {RunStatus.PARTIAL_FAILED, RunStatus.FAILED, RunStatus.CANCELLED}:
42
+ marker = {
43
+ RunStatus.PARTIAL_FAILED: "🚨 **PARTIAL — 本次结果不完整,不可作为有效筛选结果**",
44
+ RunStatus.FAILED: "🚨 **FAILED — 运行失败**",
45
+ RunStatus.CANCELLED: "⏹ **CANCELLED — 用户中断**",
46
+ }[status]
47
+ parts.append(f"> {marker}")
48
+ if status == RunStatus.PARTIAL_FAILED and failed_batch_ids:
49
+ parts.append(f"> 失败批次:`{', '.join(failed_batch_ids)}`(详见 `llm_calls.jsonl`)")
50
+ if is_intraday:
51
+ parts.append("> ⚠ **INTRADAY MODE** — 数据可能不完整,仅供盘中观察,不可与日终结果混用")
52
+ return "\n".join(parts) + ("\n\n" if parts else "")
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # SCREEN report
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def write_screen_report(
61
+ run_id: str,
62
+ *,
63
+ status: RunStatus,
64
+ is_intraday: bool,
65
+ result: ScreenResult,
66
+ n_new: int,
67
+ n_updated: int,
68
+ watchlist_total: int,
69
+ reports_root: Path | None = None,
70
+ ) -> Path:
71
+ root = (reports_root or paths.reports_dir()) / str(run_id)
72
+ root.mkdir(parents=True, exist_ok=True)
73
+
74
+ rules = result.rules
75
+ md = [render_banners(status=status, is_intraday=is_intraday)]
76
+ md.append("# 成交量异动策略 — 异动筛选\n")
77
+ md.append(
78
+ f"- mode: **screen**\n"
79
+ f"- trade_date: **{result.trade_date}**\n"
80
+ f"- status: `{status.value}`\n"
81
+ f"- intraday: `{is_intraday}`\n"
82
+ )
83
+ md.append(_render_rules_md(rules))
84
+ upper_shadow_label = (
85
+ f"上影线≤{rules.upper_shadow_ratio_max:.2f}"
86
+ if rules.upper_shadow_ratio_max is not None
87
+ else "上影线过滤(关闭)"
88
+ )
89
+ if rules.turnover_buckets is not None:
90
+ turnover_label = "换手率(按流通市值分桶)"
91
+ else:
92
+ turnover_label = f"换手率{rules.turnover_min}-{rules.turnover_max}%"
93
+ md.append(
94
+ "\n## 筛选漏斗\n"
95
+ f"- 主板池: **{result.n_main_board}**\n"
96
+ f"- 排除 ST/停牌后: **{result.n_after_st_susp}**\n"
97
+ f"- 满足『阳线 + 实体≥{rules.body_ratio_min} + 涨幅"
98
+ f"{rules.pct_chg_min}-{rules.pct_chg_max}%』: **{result.n_after_t_day_rules}**\n"
99
+ f"- 满足『{upper_shadow_label}』: **{result.n_after_upper_shadow}**\n"
100
+ f"- 满足『{turnover_label}』: "
101
+ f"**{result.n_after_turnover}**\n"
102
+ f"- 满足『({rules.vol_max_short_window}日最大量 OR "
103
+ f"{rules.lookback_trade_days}日top{rules.vol_top_n_long}) + "
104
+ f"{int(rules.vol_ratio_5d_min)}日量比≥{rules.vol_ratio_5d_min}』"
105
+ f"(最终命中): **{result.n_after_vol_rules}**\n"
106
+ )
107
+ md.append(
108
+ "\n## 待追踪标的池写入\n"
109
+ f"- 新增: **{n_new}**\n"
110
+ f"- 已存在更新: **{n_updated}**\n"
111
+ f"- 当前池总数: **{watchlist_total}**\n"
112
+ )
113
+
114
+ md.append(_render_diagnostics_md(result.diagnostics, rules))
115
+
116
+ if result.data_unavailable:
117
+ md.append("\n## 数据缺失/降级警示 (data_unavailable)\n")
118
+ for entry in result.data_unavailable:
119
+ md.append(f"- {entry}\n")
120
+
121
+ md.append(f"\n## 本次命中明细 ({len(result.hits)} 只)\n")
122
+ if result.hits:
123
+ md.append(
124
+ "| Code | Name | Industry | Pct% | Body | Turn% | VolRatio5d | "
125
+ f"VolRank/{rules.lookback_trade_days}d | ShortMax | LongMax |\n"
126
+ )
127
+ md.append(
128
+ "|------|------|----------|------|------|-------|-----------|"
129
+ "------------|----------|---------|\n"
130
+ )
131
+ for h in result.hits:
132
+ md.append(
133
+ f"| `{h['ts_code']}` | {h.get('name') or '—'} | {h.get('industry') or '—'} | "
134
+ f"{h.get('pct_chg')} | {h.get('body_ratio')} | {h.get('turnover_rate')} | "
135
+ f"{h.get('vol_ratio_5d')} | {h.get('vol_rank_in_long_window', '—')} | "
136
+ f"{h.get('max_vol_short_window', '—')} | {h.get('max_vol_long_window', '—')} |\n"
137
+ )
138
+ else:
139
+ md.append("_(本次无命中)_\n")
140
+
141
+ # P0 H2 — surface candidates that were excluded from vol rule due to insufficient history
142
+ insuff = result.diagnostics.insufficient_history
143
+ if insuff:
144
+ md.append(
145
+ f"\n## 因历史不足被排除的候选 ({len(insuff)} 只)\n"
146
+ f"_这些标的通过了换手率筛选,但历史交易日数不足 "
147
+ f"`min_history_coverage = {rules.min_history_coverage:.0%}`× "
148
+ f"`lookback = {rules.lookback_trade_days}` = "
149
+ f"{result.diagnostics.history_min_required_days} 天,无法可靠评估 vol 规则。_\n\n"
150
+ )
151
+ md.append("| Code | Name | Available | Required | Reason |\n")
152
+ md.append("|------|------|-----------|----------|--------|\n")
153
+ for r in insuff:
154
+ md.append(
155
+ f"| `{r['ts_code']}` | {r.get('name') or '—'} | "
156
+ f"{r.get('available_days', '?')} | {r.get('required_days', '?')} | "
157
+ f"{r.get('reason', '<lookback × min_coverage')} |\n"
158
+ )
159
+
160
+ md.append("\n---\n*免责声明:本报告仅用于策略研究,不构成投资建议。*\n")
161
+ (root / "summary.md").write_text("".join(md), encoding="utf-8")
162
+
163
+ (root / "screen_hits.json").write_text(
164
+ json.dumps(result.hits, ensure_ascii=False, indent=2), encoding="utf-8"
165
+ )
166
+ (root / "screen_stats.json").write_text(
167
+ json.dumps(
168
+ {
169
+ "trade_date": result.trade_date,
170
+ "rules": result.rules.as_dict(),
171
+ "diagnostics": _diagnostics_to_dict(result.diagnostics),
172
+ "n_main_board": result.n_main_board,
173
+ "n_after_st_susp": result.n_after_st_susp,
174
+ "n_after_t_day_rules": result.n_after_t_day_rules,
175
+ "n_after_turnover": result.n_after_turnover,
176
+ "n_after_vol_rules": result.n_after_vol_rules,
177
+ "n_new": n_new,
178
+ "n_updated": n_updated,
179
+ "watchlist_total": watchlist_total,
180
+ "data_unavailable": result.data_unavailable,
181
+ },
182
+ ensure_ascii=False,
183
+ indent=2,
184
+ ),
185
+ encoding="utf-8",
186
+ )
187
+ (root / "llm_calls.jsonl").touch(exist_ok=True)
188
+ return root
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # ANALYZE report
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def write_analyze_report(
197
+ run_id: str,
198
+ *,
199
+ status: RunStatus,
200
+ is_intraday: bool,
201
+ bundle: AnalyzeBundle,
202
+ predictions: list[VATrendCandidate],
203
+ market_context_summary: str | None,
204
+ risk_disclaimer: str | None,
205
+ failed_batch_ids: list[str] | None = None,
206
+ reports_root: Path | None = None,
207
+ ) -> Path:
208
+ root = (reports_root or paths.reports_dir()) / str(run_id)
209
+ root.mkdir(parents=True, exist_ok=True)
210
+
211
+ # tracked_days lookup from candidates (keyed by candidate_id)
212
+ tracked_days_lookup: dict[str, int] = {
213
+ c["candidate_id"]: int(c.get("tracked_days") or 0)
214
+ for c in bundle.candidates
215
+ if isinstance(c, dict)
216
+ }
217
+
218
+ md = [render_banners(status=status, is_intraday=is_intraday, failed_batch_ids=failed_batch_ids)]
219
+ md.append("# 成交量异动策略 — 走势分析\n")
220
+ md.append(
221
+ f"- mode: **analyze**\n"
222
+ f"- trade_date: **{bundle.trade_date}**\n"
223
+ f"- next_trade_date: **{bundle.next_trade_date}**\n"
224
+ f"- status: `{status.value}`\n"
225
+ f"- intraday: `{is_intraday}`\n"
226
+ f"- 待追踪池规模: **{len(bundle.candidates)}**\n"
227
+ f"- LLM 输出预测数: **{len(predictions)}**\n"
228
+ )
229
+ md.append(
230
+ f"\n*sector_strength_source*: `{bundle.sector_strength_source}` "
231
+ f"_(可信度:limit_cpt_list > industry_fallback)_\n"
232
+ )
233
+ if bundle.data_unavailable:
234
+ md.append(f"\n*data_unavailable*: `{bundle.data_unavailable}`\n")
235
+ if market_context_summary:
236
+ md.append(f"\n**市场背景**: {market_context_summary}\n")
237
+
238
+ by_pred: dict[str, list[VATrendCandidate]] = {
239
+ "imminent_launch": [],
240
+ "watching": [],
241
+ "not_yet": [],
242
+ }
243
+ for p in predictions:
244
+ by_pred.setdefault(p.prediction, []).append(p)
245
+ for k in by_pred:
246
+ by_pred[k].sort(key=lambda c: c.rank)
247
+
248
+ md.append(f"\n## 即将启动 · imminent_launch ({len(by_pred['imminent_launch'])} 只)\n")
249
+ if by_pred["imminent_launch"]:
250
+ md.append("| # | Code | Name | 已追踪 | Score | W/P/C/S/H/R | Pattern | 洗盘 | Conf | Rationale |\n")
251
+ md.append("|---|------|------|-------|-------|-------------|---------|------|------|-----------|\n")
252
+ for c in by_pred["imminent_launch"]:
253
+ td = tracked_days_lookup.get(c.candidate_id, 0)
254
+ md.append(
255
+ f"| {c.rank} | `{c.ts_code}` | {c.name} | {td}日 | "
256
+ f"{c.launch_score:.1f} | {_dim_compact(c)} | {c.pattern} | {c.washout_quality} | "
257
+ f"{c.confidence} | {c.rationale} |\n"
258
+ )
259
+ else:
260
+ md.append("_(本轮无即将启动标的)_\n")
261
+
262
+ md.append(f"\n## 持续观察 · watching ({len(by_pred['watching'])} 只)\n")
263
+ if by_pred["watching"]:
264
+ md.append("| # | Code | Name | 已追踪 | Score | W/P/C/S/H/R | Pattern | 洗盘 | Conf |\n")
265
+ md.append("|---|------|------|-------|-------|-------------|---------|------|------|\n")
266
+ for c in by_pred["watching"]:
267
+ td = tracked_days_lookup.get(c.candidate_id, 0)
268
+ md.append(
269
+ f"| {c.rank} | `{c.ts_code}` | {c.name} | {td}日 | "
270
+ f"{c.launch_score:.1f} | {_dim_compact(c)} | {c.pattern} | {c.washout_quality} | "
271
+ f"{c.confidence} |\n"
272
+ )
273
+ else:
274
+ md.append("_(本轮无持续观察标的)_\n")
275
+
276
+ md.append(f"\n## 时机未到 · not_yet ({len(by_pred['not_yet'])} 只)\n")
277
+ if by_pred["not_yet"]:
278
+ md.append("| Code | Name | 已追踪 | Reason |\n")
279
+ md.append("|------|------|-------|--------|\n")
280
+ for c in by_pred["not_yet"]:
281
+ td = tracked_days_lookup.get(c.candidate_id, 0)
282
+ md.append(
283
+ f"| `{c.ts_code}` | {c.name} | {td}日 | {c.rationale[:80]} |\n"
284
+ )
285
+ else:
286
+ md.append("_(无)_\n")
287
+
288
+ if risk_disclaimer:
289
+ md.append(f"\n**风险提示**: {risk_disclaimer}\n")
290
+ md.append("\n---\n*免责声明:本报告仅用于策略研究,不构成投资建议。*\n")
291
+ (root / "summary.md").write_text("".join(md), encoding="utf-8")
292
+
293
+ # JSON outputs
294
+ pred_json = []
295
+ for p in predictions:
296
+ rec = p.model_dump(mode="json")
297
+ rec["tracked_days"] = tracked_days_lookup.get(p.candidate_id, 0)
298
+ pred_json.append(rec)
299
+ (root / "analyze_predictions.json").write_text(
300
+ json.dumps(pred_json, ensure_ascii=False, indent=2), encoding="utf-8"
301
+ )
302
+
303
+ snapshot: dict[str, Any] = {
304
+ "trade_date": bundle.trade_date,
305
+ "next_trade_date": bundle.next_trade_date,
306
+ "status": status.value,
307
+ "is_intraday": is_intraday,
308
+ "candidates": bundle.candidates,
309
+ "market_summary": bundle.market_summary,
310
+ "sector_strength": {
311
+ "source": bundle.sector_strength_source,
312
+ "data": bundle.sector_strength_data,
313
+ },
314
+ "data_unavailable": bundle.data_unavailable,
315
+ }
316
+ (root / "data_snapshot.json").write_text(
317
+ json.dumps(snapshot, ensure_ascii=False, indent=2, default=_json_default),
318
+ encoding="utf-8",
319
+ )
320
+ (root / "llm_calls.jsonl").touch(exist_ok=True)
321
+ return root
322
+
323
+
324
+ def _json_default(o: Any) -> Any:
325
+ if is_dataclass(o) and not isinstance(o, type):
326
+ return asdict(o)
327
+ return str(o)
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # PRUNE report
332
+ # ---------------------------------------------------------------------------
333
+
334
+
335
+ def write_prune_report(
336
+ run_id: str,
337
+ *,
338
+ status: RunStatus,
339
+ today: str,
340
+ min_tracked_days: int,
341
+ pruned: list[dict[str, Any]],
342
+ watchlist_remaining: int,
343
+ reports_root: Path | None = None,
344
+ ) -> Path:
345
+ root = (reports_root or paths.reports_dir()) / str(run_id)
346
+ root.mkdir(parents=True, exist_ok=True)
347
+
348
+ md = [render_banners(status=status, is_intraday=False)]
349
+ md.append("# 成交量异动策略 — 剔除已追踪 N 日标的\n")
350
+ md.append(
351
+ f"- mode: **prune**\n"
352
+ f"- today: **{today}**\n"
353
+ f"- 阈值: 已追踪 ≥ **{min_tracked_days}** 日历日\n"
354
+ f"- status: `{status.value}`\n"
355
+ f"- 剔除数量: **{len(pruned)}**\n"
356
+ f"- 剔除后池剩余: **{watchlist_remaining}**\n"
357
+ )
358
+ md.append(f"\n## 被剔除标的 ({len(pruned)} 只)\n")
359
+ if pruned:
360
+ md.append("| Code | Name | Industry | Tracked Since | 已追踪 | Last Screened |\n")
361
+ md.append("|------|------|----------|---------------|-------|---------------|\n")
362
+ for r in pruned:
363
+ md.append(
364
+ f"| `{r['ts_code']}` | {r.get('name') or '—'} | {r.get('industry') or '—'} | "
365
+ f"{r['tracked_since']} | {r.get('tracked_days', '?')}日 | "
366
+ f"{r.get('last_screened') or '—'} |\n"
367
+ )
368
+ else:
369
+ md.append("_(无满足阈值的标的)_\n")
370
+ md.append("\n---\n*免责声明:本报告仅用于策略研究,不构成投资建议。*\n")
371
+ (root / "summary.md").write_text("".join(md), encoding="utf-8")
372
+
373
+ (root / "pruned_codes.json").write_text(
374
+ json.dumps(pruned, ensure_ascii=False, indent=2), encoding="utf-8"
375
+ )
376
+ (root / "llm_calls.jsonl").touch(exist_ok=True)
377
+ return root
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # EVALUATE report (v0.4.0 P1-3)
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ @dataclass
386
+ class EvaluateOutcome:
387
+ """Summary of one evaluate run — fed to ``write_evaluate_report``."""
388
+ today: str
389
+ n_targets: int
390
+ n_skipped_complete: int
391
+ n_complete: int
392
+ n_partial: int
393
+ n_pending: int
394
+ lookback_days: int
395
+ backfill_all: bool = False
396
+
397
+
398
+ def write_evaluate_report(
399
+ run_id: str,
400
+ *,
401
+ outcome: EvaluateOutcome,
402
+ reports_root: Path | None = None,
403
+ ) -> Path:
404
+ root = (reports_root or paths.reports_dir()) / str(run_id)
405
+ root.mkdir(parents=True, exist_ok=True)
406
+ md = []
407
+ md.append("# 成交量异动策略 — T+N 实际收益评估\n")
408
+ md.append(
409
+ f"- mode: **evaluate**\n"
410
+ f"- today: **{outcome.today}**\n"
411
+ f"- lookback: **{outcome.lookback_days}** 天 "
412
+ f"{'(`--backfill-all`)' if outcome.backfill_all else ''}\n"
413
+ f"- 评估目标: **{outcome.n_targets}** 条 hit "
414
+ f"(跳过已 complete: **{outcome.n_skipped_complete}**)\n"
415
+ )
416
+ md.append(
417
+ "\n## 状态分布\n"
418
+ f"- complete: **{outcome.n_complete}**\n"
419
+ f"- partial: **{outcome.n_partial}**\n"
420
+ f"- pending: **{outcome.n_pending}**\n"
421
+ )
422
+ md.append(
423
+ "\n_说明:`complete` = max horizon 已到 today 且全部 horizon 拉到收盘;"
424
+ "`partial` = max horizon 未到 today 或部分 horizon 因停牌缺数据;"
425
+ "`pending` = 连 T+1 都还在未来。下次 evaluate 会重算 partial / pending。_\n"
426
+ )
427
+ md.append("\n---\n*免责声明:本报告仅用于策略研究,不构成投资建议。*\n")
428
+ (root / "summary.md").write_text("".join(md), encoding="utf-8")
429
+ (root / "evaluate_summary.json").write_text(
430
+ json.dumps(asdict(outcome), ensure_ascii=False, indent=2), encoding="utf-8"
431
+ )
432
+ (root / "llm_calls.jsonl").touch(exist_ok=True)
433
+ return root
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Stats query renderer (v0.4.0 P1-3 — read-only aggregation)
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def render_stats_table(
442
+ rows: list[dict[str, Any]],
443
+ *,
444
+ by: str,
445
+ title: str,
446
+ console: Any | None = None,
447
+ ) -> None:
448
+ """Print a stats-by-X aggregation table to stdout.
449
+
450
+ ``rows`` is a list of dicts with keys: bucket, n_samples, t3_mean,
451
+ t3_winrate, t5_max_ret_mean. Format aligns with ``stats`` CLI subcommand.
452
+ """
453
+ from rich.console import Console
454
+ from rich.table import Table
455
+
456
+ from deeptrade.theme import EVA_THEME
457
+
458
+ if console is None:
459
+ console = Console(theme=EVA_THEME)
460
+
461
+ if not rows:
462
+ console.print(f"[k.label]{title}[/k.label] _(no samples in range)_")
463
+ return
464
+
465
+ t = Table(title=title, title_style="title", border_style="panel.border.ok",
466
+ header_style="k.label")
467
+ t.add_column(by, style="k.value", no_wrap=True)
468
+ t.add_column("样本数", justify="right")
469
+ t.add_column("T+3 均收益", justify="right")
470
+ t.add_column("T+3 胜率", justify="right")
471
+ t.add_column("T+5 最大涨幅均", justify="right")
472
+ for r in rows:
473
+ t.add_row(
474
+ str(r.get("bucket", "?")),
475
+ str(r.get("n_samples", 0)),
476
+ f"{r['t3_mean']:+.2f}%" if r.get("t3_mean") is not None else "—",
477
+ f"{r['t3_winrate']:.1f}%" if r.get("t3_winrate") is not None else "—",
478
+ f"{r['t5_max_ret_mean']:+.2f}%" if r.get("t5_max_ret_mean") is not None else "—",
479
+ )
480
+ console.print(t)
481
+
482
+
483
+ # ---------------------------------------------------------------------------
484
+ # Terminal summary (concise post-run print)
485
+ # ---------------------------------------------------------------------------
486
+
487
+
488
+ def render_terminal_summary(
489
+ run_id: str,
490
+ *,
491
+ reports_root: Path | None = None,
492
+ console: Any = None,
493
+ ) -> None:
494
+ """Print a compact summary after the run finishes.
495
+
496
+ Auto-detects the mode by which JSON files exist in the report dir.
497
+ """
498
+ from rich.console import Console
499
+ from rich.panel import Panel
500
+ from rich.table import Table
501
+
502
+ from deeptrade.theme import EVA_THEME
503
+
504
+ root = (reports_root or paths.reports_dir()) / str(run_id)
505
+ if not root.is_dir():
506
+ return
507
+ if console is None:
508
+ console = Console(theme=EVA_THEME)
509
+
510
+ if (root / "analyze_predictions.json").is_file():
511
+ _render_analyze_terminal(root, console, Table, Panel)
512
+ elif (root / "screen_hits.json").is_file():
513
+ _render_screen_terminal(root, console, Table, Panel)
514
+ elif (root / "pruned_codes.json").is_file():
515
+ _render_prune_terminal(root, console, Table, Panel)
516
+ elif (root / "evaluate_summary.json").is_file():
517
+ _render_evaluate_terminal(root, console)
518
+
519
+ console.print(f"\n[k.label]报告目录:[/k.label] [k.value]{root}[/k.value]")
520
+ console.print(
521
+ f"[k.label]完整报告:[/k.label] [k.value]deeptrade strategy report {run_id}[/k.value]"
522
+ )
523
+
524
+
525
+ def _render_screen_terminal(root: Path, console: Any, Table: Any, _Panel: Any) -> None:
526
+ stats = _safe_load_json(root / "screen_stats.json", default={})
527
+ hits = _safe_load_json(root / "screen_hits.json", default=[])
528
+ console.print(
529
+ f"[title]异动筛选[/title] "
530
+ f"[k.label]T=[/k.label][k.value]{stats.get('trade_date', '?')}[/k.value] "
531
+ f"[k.label]命中=[/k.label][k.value]{len(hits)}[/k.value] "
532
+ f"[k.label]新增=[/k.label][k.value]{stats.get('n_new', 0)}[/k.value] "
533
+ f"[k.label]池规模=[/k.label][k.value]{stats.get('watchlist_total', 0)}[/k.value]"
534
+ )
535
+ if not hits:
536
+ return
537
+ t = Table(title="本次命中", title_style="title", border_style="panel.border.ok",
538
+ header_style="k.label")
539
+ t.add_column("代码", style="k.value", no_wrap=True, width=11)
540
+ t.add_column("名称", no_wrap=True, max_width=10)
541
+ t.add_column("行业", no_wrap=True, max_width=12)
542
+ t.add_column("涨%", justify="right", width=5)
543
+ t.add_column("换手%", justify="right", width=6)
544
+ t.add_column("量比5d", justify="right", width=6)
545
+ for h in hits:
546
+ t.add_row(
547
+ h.get("ts_code", "?"),
548
+ h.get("name", "?"),
549
+ h.get("industry", "—"),
550
+ f"{h.get('pct_chg', 0):.2f}",
551
+ f"{h.get('turnover_rate', 0):.2f}",
552
+ f"{h.get('vol_ratio_5d', 0):.2f}",
553
+ )
554
+ console.print(t)
555
+
556
+
557
+ def _render_analyze_terminal(root: Path, console: Any, Table: Any, _Panel: Any) -> None:
558
+ snap = _safe_load_json(root / "data_snapshot.json", default={})
559
+ preds = _safe_load_json(root / "analyze_predictions.json", default=[])
560
+ n_imminent = sum(1 for p in preds if p.get("prediction") == "imminent_launch")
561
+ n_watch = sum(1 for p in preds if p.get("prediction") == "watching")
562
+ console.print(
563
+ f"[title]走势分析[/title] "
564
+ f"[k.label]T=[/k.label][k.value]{snap.get('trade_date', '?')}[/k.value] "
565
+ f"[k.label]T+1=[/k.label][k.value]{snap.get('next_trade_date', '?')}[/k.value] "
566
+ f"[k.label]即将启动=[/k.label][k.value]{n_imminent}[/k.value] "
567
+ f"[k.label]观察=[/k.label][k.value]{n_watch}[/k.value]"
568
+ )
569
+
570
+ imminent = sorted(
571
+ (p for p in preds if p.get("prediction") == "imminent_launch"),
572
+ key=lambda p: p.get("rank", 0),
573
+ )
574
+ if imminent:
575
+ t = Table(
576
+ title=f"即将启动 · {len(imminent)} 只",
577
+ title_style="title",
578
+ border_style="panel.border.ok",
579
+ header_style="k.label",
580
+ expand=True,
581
+ )
582
+ t.add_column("#", justify="right", width=3)
583
+ t.add_column("代码", style="k.value", no_wrap=True, width=11)
584
+ t.add_column("名称", no_wrap=True, max_width=10)
585
+ t.add_column("追踪", justify="right", width=5)
586
+ t.add_column("分", justify="right", width=4)
587
+ t.add_column("形态", width=10)
588
+ t.add_column("洗盘", width=8)
589
+ t.add_column("信", width=4)
590
+ t.add_column("理由", overflow="fold")
591
+ for p in imminent:
592
+ t.add_row(
593
+ str(p.get("rank", "?")),
594
+ p.get("ts_code", "?"),
595
+ p.get("name", "?"),
596
+ f"{p.get('tracked_days', 0)}日",
597
+ f"{p.get('launch_score', 0):.0f}",
598
+ p.get("pattern", "?"),
599
+ p.get("washout_quality", "?"),
600
+ _conf_short(p.get("confidence", "")),
601
+ p.get("rationale", ""),
602
+ )
603
+ console.print(t)
604
+
605
+ watching = sorted(
606
+ (p for p in preds if p.get("prediction") == "watching"),
607
+ key=lambda p: p.get("rank", 0),
608
+ )
609
+ if watching:
610
+ t = Table(
611
+ title=f"观察 · {len(watching)} 只",
612
+ title_style="subtitle",
613
+ border_style="panel.border.primary",
614
+ header_style="k.label",
615
+ )
616
+ t.add_column("#", justify="right", width=3)
617
+ t.add_column("代码", style="k.value", no_wrap=True, width=11)
618
+ t.add_column("名称", no_wrap=True, max_width=10)
619
+ t.add_column("追踪", justify="right", width=5)
620
+ t.add_column("分", justify="right", width=4)
621
+ t.add_column("形态", width=10)
622
+ t.add_column("洗盘", width=8)
623
+ t.add_column("信", width=4)
624
+ for p in watching:
625
+ t.add_row(
626
+ str(p.get("rank", "?")),
627
+ p.get("ts_code", "?"),
628
+ p.get("name", "?"),
629
+ f"{p.get('tracked_days', 0)}日",
630
+ f"{p.get('launch_score', 0):.0f}",
631
+ p.get("pattern", "?"),
632
+ p.get("washout_quality", "?"),
633
+ _conf_short(p.get("confidence", "")),
634
+ )
635
+ console.print(t)
636
+
637
+
638
+ def _render_evaluate_terminal(root: Path, console: Any) -> None:
639
+ summary = _safe_load_json(root / "evaluate_summary.json", default={})
640
+ console.print(
641
+ f"[title]T+N 评估[/title] "
642
+ f"[k.label]today=[/k.label][k.value]{summary.get('today', '?')}[/k.value] "
643
+ f"[k.label]targets=[/k.label][k.value]{summary.get('n_targets', 0)}[/k.value] "
644
+ f"[k.label]complete=[/k.label][k.value]{summary.get('n_complete', 0)}[/k.value] "
645
+ f"[k.label]partial=[/k.label][k.value]{summary.get('n_partial', 0)}[/k.value] "
646
+ f"[k.label]pending=[/k.label][k.value]{summary.get('n_pending', 0)}[/k.value]"
647
+ )
648
+
649
+
650
+ def _render_prune_terminal(root: Path, console: Any, Table: Any, _Panel: Any) -> None:
651
+ pruned = _safe_load_json(root / "pruned_codes.json", default=[])
652
+ console.print(
653
+ f"[title]剔除追踪标的[/title] [k.label]剔除=[/k.label][k.value]{len(pruned)}[/k.value]"
654
+ )
655
+ if not pruned:
656
+ return
657
+ t = Table(border_style="panel.border.warn", header_style="k.label")
658
+ t.add_column("代码", style="k.value", no_wrap=True, width=11)
659
+ t.add_column("名称", no_wrap=True, max_width=10)
660
+ t.add_column("入池日", no_wrap=True, width=10)
661
+ t.add_column("已追踪", justify="right", width=7)
662
+ for r in pruned:
663
+ t.add_row(
664
+ r.get("ts_code", "?"),
665
+ r.get("name", "?"),
666
+ r.get("tracked_since", "?"),
667
+ f"{r.get('tracked_days', '?')}日",
668
+ )
669
+ console.print(t)
670
+
671
+
672
+ def _render_diagnostics_md(diag: ScreenDiagnostics, rules: ScreenRules) -> str:
673
+ """P0 — observable data-completeness section.
674
+
675
+ Lets the user 自证 that every screening step had complete data, or pinpoint
676
+ where degradation occurred.
677
+ """
678
+
679
+ def _pct(num: int, denom: int) -> str:
680
+ return f"{num / denom * 100:.1f}%" if denom > 0 else "n/a"
681
+
682
+ daily_t_cov = _pct(diag.daily_t_main_board_rows, diag.main_board_rows)
683
+ db_t_cov = _pct(diag.daily_basic_t_main_board_rows, diag.main_board_rows)
684
+ history_status = (
685
+ "完整"
686
+ if diag.history_window_actual_days == diag.history_window_planned_days
687
+ else (
688
+ f"⚠ 缺失 {len(diag.history_window_missing_dates)} 天 "
689
+ f"({diag.history_window_actual_days}/{diag.history_window_planned_days})"
690
+ )
691
+ )
692
+ st_status_marker = "" if diag.stock_st_status == "ok" else " 🚨"
693
+ susp_status_marker = "" if diag.suspend_d_status == "ok" else " ⚠"
694
+ db_status_marker = "" if diag.daily_basic_status == "ok" else " 🚨"
695
+
696
+ adj_marker = ""
697
+ if diag.vol_adjust_enabled:
698
+ if diag.vol_adjust_status == "ok":
699
+ adj_marker = ""
700
+ elif diag.vol_adjust_status.startswith("degraded"):
701
+ adj_marker = " ⚠"
702
+ else:
703
+ adj_marker = " 🚨"
704
+
705
+ out = [
706
+ "\n## 数据完整性诊断\n",
707
+ f"- stock_basic 行数: **{diag.stock_basic_rows}** "
708
+ f"(主板可用: **{diag.main_board_rows}**)\n",
709
+ f"- stock_st(T) ST 标的数: **{diag.stock_st_count}** "
710
+ f"`{diag.stock_st_status}`{st_status_marker}\n",
711
+ f"- suspend_d(T) 停牌标的数: **{diag.suspend_d_count}** "
712
+ f"`{diag.suspend_d_status}`{susp_status_marker}\n",
713
+ f"- daily(T) 全市场行数: **{diag.daily_t_total_rows}** "
714
+ f"(主板覆盖 **{diag.daily_t_main_board_rows}/{diag.main_board_rows} = {daily_t_cov}**)\n",
715
+ f"- daily_basic(T) 全市场行数: **{diag.daily_basic_t_total_rows}** "
716
+ f"(主板覆盖 **{diag.daily_basic_t_main_board_rows}/{diag.main_board_rows} = "
717
+ f"{db_t_cov}**) `{diag.daily_basic_status}`{db_status_marker}\n",
718
+ f" - 候选缺 turnover_rate 被静默剔除: **{diag.n_turnover_missing}** 只\n",
719
+ f"- 历史窗口: 计划 **{diag.history_window_planned_days}** 天 / "
720
+ f"实拉 **{diag.history_window_actual_days}** 天 → {history_status}\n",
721
+ f"- 个股历史覆盖率门槛: ≥ **{diag.history_min_required_days}** 天 "
722
+ f"({rules.min_history_coverage:.0%}× lookback)\n",
723
+ f" - 因历史不足被排除: **{len(diag.insufficient_history)}** 只\n",
724
+ f"- vol 复权调整 (vol_adjust): "
725
+ f"`{diag.vol_adjust_status}`{adj_marker}\n",
726
+ ]
727
+ if diag.vol_adjust_enabled:
728
+ out.append(
729
+ f" - adj_factor 窗口: 计划 **{diag.adj_factor_planned_days}** 天 / "
730
+ f"实拉 **{diag.adj_factor_actual_days}** 天 / "
731
+ f"缺失 **{len(diag.adj_factor_missing_dates)}** 天\n"
732
+ )
733
+ out.append(
734
+ f" - 候选 T 日 adj_factor 缺失数: **{len(diag.adj_factor_missing_codes)}** 只 "
735
+ f"_(这些标的退化为原始 vol)_\n"
736
+ )
737
+ if diag.history_window_missing_dates:
738
+ sample = diag.history_window_missing_dates[:10]
739
+ ellipsis = "..." if len(diag.history_window_missing_dates) > 10 else ""
740
+ out.append(f" - daily 缺失日期样本: `{sample}{ellipsis}`\n")
741
+ if diag.turnover_missing_codes:
742
+ sample = diag.turnover_missing_codes[:10]
743
+ ellipsis = "..." if len(diag.turnover_missing_codes) > 10 else ""
744
+ out.append(f" - turnover 缺失样本: `{sample}{ellipsis}`\n")
745
+ if diag.adj_factor_missing_dates:
746
+ sample = diag.adj_factor_missing_dates[:10]
747
+ ellipsis = "..." if len(diag.adj_factor_missing_dates) > 10 else ""
748
+ out.append(f" - adj_factor 缺失日期样本: `{sample}{ellipsis}`\n")
749
+ if diag.adj_factor_missing_codes:
750
+ sample = diag.adj_factor_missing_codes[:10]
751
+ ellipsis = "..." if len(diag.adj_factor_missing_codes) > 10 else ""
752
+ out.append(f" - adj_factor(T) 缺失代码样本: `{sample}{ellipsis}`\n")
753
+
754
+ # v0.3.0 P0-1 — upper-shadow filter status.
755
+ if diag.upper_shadow_filter_enabled:
756
+ out.append(
757
+ f"- 上影线过滤: 阈值 ≤ **{diag.upper_shadow_filter_threshold:.2f}** "
758
+ f"(T-day 规则后剩 → 过滤后 **{diag.n_after_upper_shadow}** 只)\n"
759
+ )
760
+ else:
761
+ out.append("- 上影线过滤: `disabled`\n")
762
+
763
+ # v0.3.0 P0-2 — circ_mv-bucketed turnover distribution.
764
+ if diag.turnover_buckets_enabled:
765
+ out.append(f"- 流通市值分桶换手率命中分布 (n_missing_circ_mv={diag.n_missing_circ_mv}):\n")
766
+ if diag.turnover_bucket_hits:
767
+ for label, n in diag.turnover_bucket_hits.items():
768
+ out.append(f" - {label}: **{n}**\n")
769
+ else:
770
+ out.append(" _(本次无候选进入分桶判定)_\n")
771
+ if diag.circ_mv_missing_codes:
772
+ sample = diag.circ_mv_missing_codes[:10]
773
+ ellipsis = "..." if len(diag.circ_mv_missing_codes) > 10 else ""
774
+ out.append(f" - circ_mv 缺失代码样本: `{sample}{ellipsis}`\n")
775
+ else:
776
+ out.append("- 流通市值分桶换手率: `disabled` (使用全局 turnover_min/max)\n")
777
+ return "".join(out)
778
+
779
+
780
+ def _diagnostics_to_dict(diag: ScreenDiagnostics) -> dict[str, Any]:
781
+ """Serialize ScreenDiagnostics for screen_stats.json."""
782
+ return {
783
+ "stock_basic_rows": diag.stock_basic_rows,
784
+ "main_board_rows": diag.main_board_rows,
785
+ "stock_st_count": diag.stock_st_count,
786
+ "stock_st_status": diag.stock_st_status,
787
+ "suspend_d_count": diag.suspend_d_count,
788
+ "suspend_d_status": diag.suspend_d_status,
789
+ "daily_t_total_rows": diag.daily_t_total_rows,
790
+ "daily_t_main_board_rows": diag.daily_t_main_board_rows,
791
+ "daily_basic_t_total_rows": diag.daily_basic_t_total_rows,
792
+ "daily_basic_t_main_board_rows": diag.daily_basic_t_main_board_rows,
793
+ "daily_basic_status": diag.daily_basic_status,
794
+ "n_turnover_missing": diag.n_turnover_missing,
795
+ "turnover_missing_codes": diag.turnover_missing_codes,
796
+ "history_window_planned_days": diag.history_window_planned_days,
797
+ "history_window_actual_days": diag.history_window_actual_days,
798
+ "history_window_missing_dates": diag.history_window_missing_dates,
799
+ "history_min_required_days": diag.history_min_required_days,
800
+ "insufficient_history": diag.insufficient_history,
801
+ "vol_adjust_enabled": diag.vol_adjust_enabled,
802
+ "vol_adjust_status": diag.vol_adjust_status,
803
+ "adj_factor_planned_days": diag.adj_factor_planned_days,
804
+ "adj_factor_actual_days": diag.adj_factor_actual_days,
805
+ "adj_factor_missing_dates": diag.adj_factor_missing_dates,
806
+ "adj_factor_missing_codes": diag.adj_factor_missing_codes,
807
+ # v0.3.0 P0-1 / P0-2
808
+ "upper_shadow_filter_enabled": diag.upper_shadow_filter_enabled,
809
+ "upper_shadow_filter_threshold": diag.upper_shadow_filter_threshold,
810
+ "n_after_upper_shadow": diag.n_after_upper_shadow,
811
+ "turnover_buckets_enabled": diag.turnover_buckets_enabled,
812
+ "turnover_bucket_hits": diag.turnover_bucket_hits,
813
+ "n_missing_circ_mv": diag.n_missing_circ_mv,
814
+ "circ_mv_missing_codes": diag.circ_mv_missing_codes,
815
+ }
816
+
817
+
818
+ def _render_rules_md(rules: ScreenRules) -> str:
819
+ """Render the本次使用的筛选阈值 section (transparent + auditable)."""
820
+ if rules.upper_shadow_ratio_max is None:
821
+ upper_shadow_line = "- 上影线过滤: **关闭** (`upper_shadow_ratio_max=null`)\n"
822
+ else:
823
+ upper_shadow_line = f"- 上影线占振幅 ≤: **{rules.upper_shadow_ratio_max}**\n"
824
+ if rules.turnover_buckets is None:
825
+ turnover_line = (
826
+ f"- 换手率区间: **[{rules.turnover_min}%, {rules.turnover_max}%]** (全局)\n"
827
+ )
828
+ else:
829
+ rows = []
830
+ prev_max = 0.0
831
+ for b_max, t_min, t_max in rules.turnover_buckets:
832
+ label = (
833
+ f">{int(prev_max)}亿"
834
+ if math.isinf(b_max)
835
+ else (f"≤{int(b_max)}亿" if prev_max <= 0 else f"{int(prev_max)}-{int(b_max)}亿")
836
+ )
837
+ rows.append(f" - {label}: [{t_min}%, {t_max}%]")
838
+ prev_max = b_max
839
+ turnover_line = "- 换手率(按流通市值分桶):\n" + "\n".join(rows) + "\n"
840
+ return (
841
+ "\n## 筛选阈值(本次使用)\n"
842
+ f"- 涨幅区间: **[{rules.pct_chg_min}%, {rules.pct_chg_max}%]**\n"
843
+ f"- K线实体占比 ≥: **{rules.body_ratio_min}**\n"
844
+ + upper_shadow_line
845
+ + turnover_line
846
+ + f"- 5日量比 ≥: **{rules.vol_ratio_5d_min}** "
847
+ f"_(严格使用 T 之前最近 5 个连续交易日)_\n"
848
+ f"- 量价规则: **{rules.vol_max_short_window}日最大量** OR "
849
+ f"**{rules.lookback_trade_days}日 vol 排名前 {rules.vol_top_n_long}**\n"
850
+ f"- 长窗口(vol 历史比较): **{rules.lookback_trade_days}** 交易日\n"
851
+ f"- 历史覆盖率门槛: ≥ **{rules.min_history_coverage:.0%}** × lookback\n"
852
+ f"- vol 复权调整 (vol_adjust): **{'启用' if rules.vol_adjust else '关闭'}**\n"
853
+ )
854
+
855
+
856
+ def _safe_load_json(path: Path, *, default: Any) -> Any:
857
+ if not path.is_file():
858
+ return default
859
+ try:
860
+ return json.loads(path.read_text(encoding="utf-8"))
861
+ except (OSError, json.JSONDecodeError):
862
+ return default
863
+
864
+
865
+ def _conf_short(c: str) -> str:
866
+ return {"high": "高", "medium": "中", "low": "低"}.get(c, c[:1].upper() if c else "?")
867
+
868
+
869
+ def _dim_compact(c: VATrendCandidate) -> str:
870
+ """Render dimension_scores as W/P/C/S/H/R (washout / pattern / capital /
871
+ sector / historical / risk). v0.6.0 P1-2."""
872
+ d = c.dimension_scores
873
+ return f"{d.washout}/{d.pattern}/{d.capital}/{d.sector}/{d.historical}/{d.risk}"
874
+
875
+
876
+ def export_llm_calls(run_id: str, db: Any, *, reports_root: Path | None = None) -> int:
877
+ """Pull this run's llm_calls rows into reports/<run_id>/llm_calls.jsonl."""
878
+ root = (reports_root or paths.reports_dir()) / str(run_id)
879
+ root.mkdir(parents=True, exist_ok=True)
880
+ rows = db.fetchall(
881
+ "SELECT call_id, model, prompt_hash, input_tokens, output_tokens, "
882
+ "latency_ms, validation_status, error, created_at "
883
+ "FROM llm_calls WHERE run_id = ? ORDER BY created_at",
884
+ (run_id,),
885
+ )
886
+ out_path = root / "llm_calls.jsonl"
887
+ with out_path.open("w", encoding="utf-8") as fh:
888
+ for row in rows:
889
+ fh.write(
890
+ json.dumps(
891
+ {
892
+ "call_id": str(row[0]),
893
+ "model": row[1],
894
+ "prompt_hash": row[2],
895
+ "input_tokens": row[3],
896
+ "output_tokens": row[4],
897
+ "latency_ms": row[5],
898
+ "validation_status": row[6],
899
+ "error": row[7],
900
+ "created_at": str(row[8]),
901
+ },
902
+ ensure_ascii=False,
903
+ )
904
+ + "\n"
905
+ )
906
+ return len(rows)