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,890 @@
1
+ """Render & export the limit-up-board final report.
2
+
3
+ DESIGN §12.8.3 + the v0.3.1 banner / S5 rules:
4
+ * partial_failed / failed / cancelled → red banner at top of summary.md
5
+ * is_intraday=True → yellow `INTRADAY MODE` banner
6
+ * Both stack
7
+ * round2_predictions.json contains ALL R2 predictions (with batch_local_rank)
8
+ * round2_final_ranking.json only emitted when R2 was multi-batch
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from dataclasses import asdict
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from deeptrade.core import paths
20
+ from deeptrade.core.run_status import RunStatus
21
+
22
+ from .data import Round1Bundle
23
+ from .schemas import (
24
+ ContinuationCandidate,
25
+ FinalRankingResponse,
26
+ StrongCandidate,
27
+ )
28
+
29
+ if TYPE_CHECKING: # pragma: no cover
30
+ from .runner import ProviderDebateResult
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Banner
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def render_banners(
41
+ *,
42
+ status: RunStatus,
43
+ is_intraday: bool,
44
+ failed_batch_ids: list[str] | None = None,
45
+ ) -> str:
46
+ """Top-of-report banner stack — markdown blockquote style.
47
+
48
+ F-L3: when ``status == PARTIAL_FAILED`` and ``failed_batch_ids`` is
49
+ non-empty, the banner enumerates which batches failed so users don't have
50
+ to grep ``llm_calls.jsonl`` to find them.
51
+ """
52
+ parts: list[str] = []
53
+ if status in {RunStatus.PARTIAL_FAILED, RunStatus.FAILED, RunStatus.CANCELLED}:
54
+ marker = {
55
+ RunStatus.PARTIAL_FAILED: "🚨 **PARTIAL — 本次结果不完整,不可作为有效筛选结果**",
56
+ RunStatus.FAILED: "🚨 **FAILED — 运行失败**",
57
+ RunStatus.CANCELLED: "⏹ **CANCELLED — 用户中断**",
58
+ }[status]
59
+ parts.append(f"> {marker}")
60
+ if status == RunStatus.PARTIAL_FAILED and failed_batch_ids:
61
+ parts.append(f"> 失败批次:`{', '.join(failed_batch_ids)}`(详见 `llm_calls.jsonl`)")
62
+ if is_intraday:
63
+ parts.append("> ⚠ **INTRADAY MODE** — 数据可能不完整,仅供盘中观察,不可与日终结果混用")
64
+ return "\n".join(parts) + ("\n\n" if parts else "")
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Markdown body
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def render_summary_md(
73
+ *,
74
+ status: RunStatus,
75
+ is_intraday: bool,
76
+ bundle: Round1Bundle,
77
+ selected: list[StrongCandidate],
78
+ predictions: list[ContinuationCandidate],
79
+ final_ranking: FinalRankingResponse | None,
80
+ failed_batch_ids: list[str] | None = None,
81
+ ) -> str:
82
+ """Build the full summary.md content."""
83
+ out = [
84
+ render_banners(status=status, is_intraday=is_intraday, failed_batch_ids=failed_batch_ids)
85
+ ]
86
+ out.append("# 打板策略报告\n")
87
+ out.append(
88
+ f"- trade_date: **{bundle.trade_date}**\n"
89
+ f"- next_trade_date: **{bundle.next_trade_date}**\n"
90
+ f"- status: `{status.value}`\n"
91
+ f"- intraday: `{is_intraday}`\n"
92
+ )
93
+
94
+ # Sector strength source label is meaningful — surface it.
95
+ out.append(
96
+ f"\n*sector_strength_source*: `{bundle.sector_strength.source}` "
97
+ f"_(可信度:limit_cpt_list > lu_desc_aggregation > industry_fallback)_\n"
98
+ )
99
+
100
+ if bundle.data_unavailable:
101
+ out.append(f"\n*data_unavailable*: `{bundle.data_unavailable}`\n")
102
+
103
+ # ----- R1 -----
104
+ out.append(f"\n## R1 强势标的({len(selected)}/{len(bundle.candidates)} selected)\n")
105
+ if selected:
106
+ out.append("| Rank | Code | Name | T收盘 (元) | Score | Level | Theme/Industry | Rationale |\n")
107
+ out.append("|------|------|------|-----------|-------|-------|----------------|-----------|\n")
108
+ for i, c in enumerate(selected, 1):
109
+ theme = _industry_for(c.candidate_id, bundle.candidates)
110
+ out.append(
111
+ f"| {i} | `{c.ts_code}` | {c.name} | {_close_for(c.candidate_id, bundle.candidates)} | "
112
+ f"{c.score:.1f} | {c.strength_level} | {theme} | {c.rationale} |\n"
113
+ )
114
+ else:
115
+ out.append("_(本轮无强势标的)_\n")
116
+
117
+ # ----- R2 / Final -----
118
+ if predictions:
119
+ if final_ranking is not None:
120
+ out.append("\n## 次日连板预测(按 final_rank 排序)\n")
121
+ out.append("| # | Code | Name | T收盘 (元) | Final Pred | Conf. | Δ vs batch | Reason |\n")
122
+ out.append("|---|------|------|-----------|-----------|-------|-----------|--------|\n")
123
+ for fi in sorted(final_ranking.finalists, key=lambda f: f.final_rank):
124
+ out.append(
125
+ f"| {fi.final_rank} | `{fi.ts_code}` | "
126
+ f"{_name_for(fi.candidate_id, predictions)} | "
127
+ f"{_close_for(fi.candidate_id, bundle.candidates)} | "
128
+ f"{fi.final_prediction} | {fi.final_confidence} | "
129
+ f"{fi.delta_vs_batch} | {fi.reason_vs_peers} |\n"
130
+ )
131
+ else:
132
+ out.append("\n## 次日连板预测(单批)\n")
133
+ out.append("| Rank | Code | Name | T收盘 (元) | Score | Conf. | Pred | Rationale |\n")
134
+ out.append("|------|------|------|-----------|-------|-------|------|-----------|\n")
135
+ for p in sorted(predictions, key=lambda x: x.rank):
136
+ out.append(
137
+ f"| {p.rank} | `{p.ts_code}` | {p.name} | "
138
+ f"{_close_for(p.candidate_id, bundle.candidates)} | "
139
+ f"{p.continuation_score:.1f} | {p.confidence} | "
140
+ f"{p.prediction} | {p.rationale} |\n"
141
+ )
142
+ else:
143
+ out.append("\n## 次日连板预测\n_(本轮无候选标的)_\n")
144
+
145
+ out.append("\n---\n*免责声明:本报告仅用于策略研究,不构成投资建议。*\n")
146
+ return "".join(out)
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Report directory writer
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def write_report(
155
+ run_id: str,
156
+ *,
157
+ status: RunStatus,
158
+ is_intraday: bool,
159
+ bundle: Round1Bundle,
160
+ selected: list[StrongCandidate],
161
+ predictions: list[ContinuationCandidate],
162
+ final_ranking: FinalRankingResponse | None,
163
+ extra_files: dict[str, str] | None = None,
164
+ reports_root: Path | None = None,
165
+ failed_batch_ids: list[str] | None = None,
166
+ debate_results: list[ProviderDebateResult] | None = None,
167
+ ) -> Path:
168
+ """Write the report directory and return its path.
169
+
170
+ In single-LLM mode the existing 6-file layout is preserved. In debate
171
+ mode, ``selected`` / ``predictions`` / ``final_ranking`` are typically
172
+ empty and the per-provider results are persisted under
173
+ ``debate/<provider>/`` plus a 《多 LLM 辩论结果》 section in summary.md.
174
+ """
175
+ root = (reports_root or paths.reports_dir()) / str(run_id)
176
+ root.mkdir(parents=True, exist_ok=True)
177
+
178
+ # 1. summary.md
179
+ if debate_results:
180
+ md = render_debate_summary_md(
181
+ status=status,
182
+ is_intraday=is_intraday,
183
+ bundle=bundle,
184
+ results=debate_results,
185
+ failed_batch_ids=failed_batch_ids,
186
+ )
187
+ else:
188
+ md = render_summary_md(
189
+ status=status,
190
+ is_intraday=is_intraday,
191
+ bundle=bundle,
192
+ selected=selected,
193
+ predictions=predictions,
194
+ final_ranking=final_ranking,
195
+ failed_batch_ids=failed_batch_ids,
196
+ )
197
+ (root / "summary.md").write_text(md, encoding="utf-8")
198
+
199
+ # 2. round1_strong_targets.json
200
+ (root / "round1_strong_targets.json").write_text(
201
+ json.dumps([s.model_dump(mode="json") for s in selected], ensure_ascii=False, indent=2),
202
+ encoding="utf-8",
203
+ )
204
+
205
+ # 3. round2_predictions.json (ALL predictions, with batch_local_rank)
206
+ r2_out = []
207
+ for p in predictions:
208
+ rec = p.model_dump(mode="json")
209
+ rec["batch_local_rank"] = p.rank # explicit alias for downstream tools
210
+ if final_ranking is not None:
211
+ match = next(
212
+ (f for f in final_ranking.finalists if f.candidate_id == p.candidate_id),
213
+ None,
214
+ )
215
+ if match is not None:
216
+ rec["final_rank"] = match.final_rank
217
+ rec["delta_vs_batch"] = match.delta_vs_batch
218
+ r2_out.append(rec)
219
+ (root / "round2_predictions.json").write_text(
220
+ json.dumps(r2_out, ensure_ascii=False, indent=2), encoding="utf-8"
221
+ )
222
+
223
+ # 4. round2_final_ranking.json (only when multi-batch / final_ranking ran)
224
+ if final_ranking is not None:
225
+ (root / "round2_final_ranking.json").write_text(
226
+ json.dumps(final_ranking.model_dump(mode="json"), ensure_ascii=False, indent=2),
227
+ encoding="utf-8",
228
+ )
229
+
230
+ # 5. data_snapshot.json
231
+ snapshot: dict[str, Any] = {
232
+ "trade_date": bundle.trade_date,
233
+ "next_trade_date": bundle.next_trade_date,
234
+ "status": status.value,
235
+ "is_intraday": is_intraday,
236
+ "candidates": bundle.candidates,
237
+ "market_summary": bundle.market_summary,
238
+ "sector_strength": asdict(bundle.sector_strength),
239
+ "data_unavailable": bundle.data_unavailable,
240
+ "debate_mode": bool(debate_results),
241
+ "debate_providers": (
242
+ [r.provider for r in debate_results] if debate_results else []
243
+ ),
244
+ }
245
+ (root / "data_snapshot.json").write_text(
246
+ json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8"
247
+ )
248
+
249
+ # 6. llm_calls.jsonl is written incrementally by the runner; we touch it
250
+ # here so the file always exists in the report dir.
251
+ (root / "llm_calls.jsonl").touch(exist_ok=True)
252
+
253
+ # 7. debate/ — per-provider details
254
+ if debate_results:
255
+ debate_dir = root / "debate"
256
+ debate_dir.mkdir(parents=True, exist_ok=True)
257
+ for r in debate_results:
258
+ pdir = debate_dir / r.provider
259
+ pdir.mkdir(parents=True, exist_ok=True)
260
+ if r.r1_result and r.r1_result.selected:
261
+ (pdir / "round1_strong_targets.json").write_text(
262
+ json.dumps(
263
+ [s.model_dump(mode="json") for s in r.r1_result.selected],
264
+ ensure_ascii=False,
265
+ indent=2,
266
+ ),
267
+ encoding="utf-8",
268
+ )
269
+ if r.r2_result and r.r2_result.predictions:
270
+ (pdir / "round2_initial.json").write_text(
271
+ json.dumps(
272
+ [p.model_dump(mode="json") for p in r.r2_result.predictions],
273
+ ensure_ascii=False,
274
+ indent=2,
275
+ ),
276
+ encoding="utf-8",
277
+ )
278
+ if r.final_initial is not None:
279
+ (pdir / "round2_final_ranking.json").write_text(
280
+ json.dumps(
281
+ r.final_initial.model_dump(mode="json"),
282
+ ensure_ascii=False,
283
+ indent=2,
284
+ ),
285
+ encoding="utf-8",
286
+ )
287
+ if r.revision and r.revision.revised:
288
+ (pdir / "round2_revised.json").write_text(
289
+ json.dumps(
290
+ {
291
+ "revision_summary": r.revision.revision_summary,
292
+ "candidates": [
293
+ c.model_dump(mode="json") for c in r.revision.revised
294
+ ],
295
+ },
296
+ ensure_ascii=False,
297
+ indent=2,
298
+ ),
299
+ encoding="utf-8",
300
+ )
301
+ if r.error:
302
+ (pdir / "error.txt").write_text(r.error, encoding="utf-8")
303
+
304
+ # Caller-supplied extras (e.g. R1/R2 raw responses captured during run)
305
+ if extra_files:
306
+ for name, content in extra_files.items():
307
+ (root / name).write_text(content, encoding="utf-8")
308
+
309
+ return root
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Debate-mode markdown
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ _PRED_GLYPH = {"top_candidate": "▲", "watchlist": "○", "avoid": "▽"}
318
+
319
+
320
+ def _glyph(pred: str | None) -> str:
321
+ return _PRED_GLYPH.get(pred or "", "—")
322
+
323
+
324
+ def render_debate_summary_md(
325
+ *,
326
+ status: RunStatus,
327
+ is_intraday: bool,
328
+ bundle: Round1Bundle,
329
+ results: list[ProviderDebateResult],
330
+ failed_batch_ids: list[str] | None = None,
331
+ ) -> str:
332
+ """Build summary.md content for debate-mode runs.
333
+
334
+ Layout:
335
+ Banners
336
+ Header
337
+ §1 参与 LLM 与状态
338
+ §2 每个 LLM 的 initial → revised 对照表
339
+ §3 跨 LLM 分歧矩阵 — Initial
340
+ §4 跨 LLM 分歧矩阵 — Revised
341
+ §5 各 LLM 的 revision_summary
342
+ """
343
+ out: list[str] = [
344
+ render_banners(status=status, is_intraday=is_intraday, failed_batch_ids=failed_batch_ids)
345
+ ]
346
+ out.append("# 打板策略报告(多 LLM 辩论)\n")
347
+ out.append(
348
+ f"- trade_date: **{bundle.trade_date}**\n"
349
+ f"- next_trade_date: **{bundle.next_trade_date}**\n"
350
+ f"- status: `{status.value}`\n"
351
+ f"- intraday: `{is_intraday}`\n"
352
+ f"- providers: {', '.join(f'`{r.provider}`' for r in results)}\n"
353
+ )
354
+ out.append(
355
+ f"\n*sector_strength_source*: `{bundle.sector_strength.source}` "
356
+ f"_(可信度:limit_cpt_list > lu_desc_aggregation > industry_fallback)_\n"
357
+ )
358
+ if bundle.data_unavailable:
359
+ out.append(f"\n*data_unavailable*: `{bundle.data_unavailable}`\n")
360
+
361
+ # ----- §1 参与 LLM 状态 ------------------------------------------------
362
+ out.append("\n## 1. 参与 LLM 状态\n\n")
363
+ out.append("| Provider | R1 入选 | R2 预测 | R2 失败批 | final_ranking | R3 修订 | Error |\n")
364
+ out.append("|----------|--------:|--------:|---------:|:-------------:|:------:|-------|\n")
365
+ for r in results:
366
+ n_r1 = len(r.r1_result.selected) if r.r1_result else 0
367
+ n_r2 = len(r.r2_result.predictions) if r.r2_result else 0
368
+ r2_fails = (
369
+ r.r2_result.failed_batches if r.r2_result and r.r2_result.failed_batches else 0
370
+ )
371
+ if not r.final_attempted:
372
+ fr = "—"
373
+ elif r.final_initial is not None:
374
+ fr = "✔"
375
+ else:
376
+ fr = "✘"
377
+ if r.revision is None:
378
+ rv = "—"
379
+ elif r.revision.success:
380
+ rv = "✔"
381
+ else:
382
+ rv = "✘"
383
+ err = (r.error or "").replace("|", "/")[:60]
384
+ out.append(
385
+ f"| `{r.provider}` | {n_r1} | {n_r2} | {r2_fails} | {fr} | {rv} | {err} |\n"
386
+ )
387
+
388
+ # ----- §2 每个 LLM 的 initial → revised 对照 ----------------------------
389
+ for r in results:
390
+ out.append(f"\n## 2.{results.index(r) + 1} `{r.provider}` 修订对照\n")
391
+ if r.error:
392
+ out.append(f"\n_(provider {r.provider} 整体失败: {r.error})_\n")
393
+ continue
394
+ initial = r.initial_predictions
395
+ revised = {c.candidate_id: c for c in r.revised_predictions}
396
+ if not initial:
397
+ out.append("\n_(无连板预测候选)_\n")
398
+ continue
399
+ if r.revision and r.revision.revision_summary:
400
+ out.append(f"\n**revision_summary**: {r.revision.revision_summary}\n")
401
+ elif r.revision and not r.revision.success:
402
+ out.append(
403
+ f"\n_(R3 修订失败: {r.revision.error or '未知'};下表 Revised 列回退为初始判断)_\n"
404
+ )
405
+ out.append(
406
+ "\n| # | Code | Name | T收盘 (元) | Initial Pred | Init Score | Revised Pred | "
407
+ "Rev Score | Δ | Revision Note |\n"
408
+ )
409
+ out.append(
410
+ "|---|------|------|-----------|-------------|-----------:|-------------|"
411
+ "----------:|---|---------------|\n"
412
+ )
413
+ # Sort by revised rank if available; else by initial rank.
414
+ def _sort_by_revised_rank(
415
+ p: ContinuationCandidate, _rev: dict = revised
416
+ ) -> int:
417
+ rev = _rev.get(p.candidate_id)
418
+ return rev.rank if rev else p.rank
419
+ for p in sorted(initial, key=_sort_by_revised_rank):
420
+ rev = revised.get(p.candidate_id)
421
+ init_pred = p.prediction
422
+ init_score = p.continuation_score
423
+ rev_pred = rev.prediction if rev else init_pred
424
+ rev_score = rev.continuation_score if rev else init_score
425
+ delta = _delta_label(init_pred, rev_pred, init_score, rev_score) if rev else "—"
426
+ note = (rev.revision_note if rev else "—").replace("|", "/")
427
+ out.append(
428
+ f"| {rev.rank if rev else p.rank} | `{p.ts_code}` | {p.name} | "
429
+ f"{_close_for(p.candidate_id, bundle.candidates)} | "
430
+ f"{init_pred} | {init_score:.0f} | {rev_pred} | {rev_score:.0f} | "
431
+ f"{delta} | {note} |\n"
432
+ )
433
+
434
+ # ----- §3 / §4 分歧矩阵 -------------------------------------------------
435
+ out.append("\n## 3. 跨 LLM 分歧矩阵 — Initial\n\n")
436
+ out.append(_render_disagreement_matrix(results, bundle, mode="initial"))
437
+ out.append("\n## 4. 跨 LLM 分歧矩阵 — Revised\n\n")
438
+ out.append(_render_disagreement_matrix(results, bundle, mode="revised"))
439
+ out.append(
440
+ "\n_说明: ▲ = top_candidate, ○ = watchlist, ▽ = avoid, — = 该 LLM 未将此股纳入预测_\n"
441
+ )
442
+
443
+ out.append("\n---\n*免责声明:本报告仅用于策略研究,不构成投资建议。*\n")
444
+ return "".join(out)
445
+
446
+
447
+ def _render_disagreement_matrix(
448
+ results: list[ProviderDebateResult],
449
+ bundle: Round1Bundle,
450
+ *,
451
+ mode: str,
452
+ ) -> str:
453
+ """Build the cross-LLM disagreement matrix in markdown."""
454
+ union_ids: dict[str, tuple[str, str]] = {}
455
+ pred_by_provider: dict[str, dict[str, Any]] = {}
456
+ for r in results:
457
+ preds: list[Any]
458
+ if mode == "initial":
459
+ preds = list(r.initial_predictions)
460
+ else:
461
+ preds = list(r.revised_predictions) or list(r.initial_predictions)
462
+ prov_map: dict[str, Any] = {}
463
+ for p in preds:
464
+ prov_map[p.candidate_id] = p
465
+ union_ids.setdefault(p.candidate_id, (p.ts_code, p.name))
466
+ pred_by_provider[r.provider] = prov_map
467
+
468
+ if not union_ids:
469
+ return "_(无候选)_\n"
470
+
471
+ # Sort candidates by appearance count (most provider coverage first), then ts_code.
472
+ def coverage(cid: str) -> int:
473
+ return sum(1 for m in pred_by_provider.values() if cid in m)
474
+
475
+ sorted_ids = sorted(union_ids.keys(), key=lambda c: (-coverage(c), c))
476
+
477
+ headers = ["Code", "Name", "T收盘 (元)"] + [f"`{r.provider}`" for r in results]
478
+ sep = ["------"] * len(headers)
479
+ md = ["| " + " | ".join(headers) + " |\n", "| " + " | ".join(sep) + " |\n"]
480
+ for cid in sorted_ids:
481
+ ts_code, name = union_ids[cid]
482
+ cells = [
483
+ f"`{ts_code}`",
484
+ name,
485
+ _close_for(cid, bundle.candidates),
486
+ ]
487
+ for r in results:
488
+ p = pred_by_provider.get(r.provider, {}).get(cid)
489
+ if p is None:
490
+ cells.append("—")
491
+ else:
492
+ cells.append(f"{_glyph(p.prediction)} {p.continuation_score:.0f}")
493
+ md.append("| " + " | ".join(cells) + " |\n")
494
+ return "".join(md)
495
+
496
+
497
+ def _delta_label(
498
+ init_pred: str, rev_pred: str, init_score: float, rev_score: float
499
+ ) -> str:
500
+ """Encode the change from initial to revised prediction in one cell."""
501
+ rank = {"avoid": 0, "watchlist": 1, "top_candidate": 2}
502
+ init_r = rank.get(init_pred, 1)
503
+ rev_r = rank.get(rev_pred, 1)
504
+ if rev_r > init_r:
505
+ return f"⬆ +{rev_score - init_score:.0f}"
506
+ if rev_r < init_r:
507
+ return f"⬇ {rev_score - init_score:.0f}"
508
+ diff = rev_score - init_score
509
+ if abs(diff) < 0.5:
510
+ return "= 0"
511
+ return f"= {diff:+.0f}"
512
+
513
+
514
+ def render_terminal_summary(
515
+ run_id: str,
516
+ *,
517
+ reports_root: Path | None = None,
518
+ console: Any = None,
519
+ ) -> None:
520
+ """Print a concise, friendly summary of a finished run to the terminal.
521
+
522
+ Reads from ``reports/<run_id>/`` so it works for both:
523
+ - just-finished runs (called from ``cmd_run`` after the dashboard exits)
524
+ - historical runs (called from ``cmd_report <run_id>``)
525
+
526
+ Output sections (only the ones with data are shown):
527
+ - Header line: trade_date / next_trade_date / status / counts
528
+ - "次日重点关注" — R2 top_candidate picks (full table with rationale)
529
+ - "观察仓" — R2 watchlist (compact, no rationale)
530
+ - "回避" — R2 avoid (compact)
531
+ - Footer: report directory + how to re-display
532
+ """
533
+ from rich.console import Console
534
+ from rich.panel import Panel
535
+ from rich.table import Table
536
+
537
+ from deeptrade.theme import EVA_THEME
538
+
539
+ root = (reports_root or paths.reports_dir()) / str(run_id)
540
+ if not root.is_dir():
541
+ return
542
+
543
+ if console is None:
544
+ console = Console(theme=EVA_THEME)
545
+
546
+ snap = _safe_load_json(root / "data_snapshot.json", default={})
547
+ r1 = _safe_load_json(root / "round1_strong_targets.json", default=[])
548
+ r2 = _safe_load_json(root / "round2_predictions.json", default=[])
549
+ has_final = (root / "round2_final_ranking.json").is_file()
550
+ debate_mode = bool(snap.get("debate_mode", False))
551
+
552
+ trade_date = snap.get("trade_date", "?")
553
+ next_trade_date = snap.get("next_trade_date", "?")
554
+ status = snap.get("status", "unknown")
555
+ is_intraday = bool(snap.get("is_intraday", False))
556
+ candidates = snap.get("candidates", [])
557
+ close_lookup = {c.get("ts_code"): c.get("close_yuan") for c in candidates}
558
+ n_total = len(candidates)
559
+ n_selected = sum(1 for c in r1 if c.get("selected"))
560
+
561
+ # ----- Banner / status -------------------------------------------------
562
+ if status in ("partial_failed", "failed", "cancelled"):
563
+ banner_style = "headline.alert" if status == "partial_failed" else "headline.fatal"
564
+ banner_label = {
565
+ "partial_failed": "PARTIAL — 结果不完整",
566
+ "failed": "FAILED — 运行失败",
567
+ "cancelled": "CANCELLED — 用户中断",
568
+ }.get(status, status)
569
+ console.print(Panel(banner_label, style=banner_style, border_style="panel.border.error"))
570
+ if is_intraday:
571
+ console.print(
572
+ Panel(
573
+ "INTRADAY MODE — 数据可能不完整,仅供盘中观察",
574
+ style="headline.alert",
575
+ border_style="panel.border.warn",
576
+ )
577
+ )
578
+
579
+ # ----- Header line -----------------------------------------------------
580
+ mode_tag = "辩论" if debate_mode else ""
581
+ console.print(
582
+ f"[title]打板策略{mode_tag}[/title] "
583
+ f"[k.label]T=[/k.label][k.value]{trade_date}[/k.value] "
584
+ f"[k.label]T+1=[/k.label][k.value]{next_trade_date}[/k.value] "
585
+ f"[k.label]入选/候选=[/k.label][k.value]{n_selected}/{n_total}[/k.value] "
586
+ f"[k.label]状态=[/k.label][status.{'success' if status == 'success' else 'error'}]{status}[/]"
587
+ )
588
+
589
+ if debate_mode:
590
+ _render_debate_terminal(console, root, snap, close_lookup)
591
+ elif not r2:
592
+ console.print("[subtitle](本轮无连板预测候选)[/subtitle]")
593
+ else:
594
+ # When final_ranking ran, sort by final_rank; else by rank
595
+ sort_key = "final_rank" if has_final else "rank"
596
+ # Group by prediction
597
+ groups: dict[str, list[dict]] = {"top_candidate": [], "watchlist": [], "avoid": []}
598
+ for p in r2:
599
+ groups.setdefault(p.get("prediction", "watchlist"), []).append(p)
600
+ for g in groups.values():
601
+ g.sort(key=lambda x: x.get(sort_key, x.get("rank", 0)))
602
+
603
+ # All three groups share the same table layout; visual hierarchy comes
604
+ # from title/border styles only.
605
+ section_styles = [
606
+ ("top_candidate", "次日重点关注", "title", "panel.border.ok"),
607
+ ("watchlist", "观察仓", "subtitle", "panel.border.primary"),
608
+ ("avoid", "回避", "subtitle", "panel.border.warn"),
609
+ ]
610
+ for key, label, title_style, border_style in section_styles:
611
+ group = groups[key]
612
+ if not group:
613
+ continue
614
+ _render_prediction_table(
615
+ console,
616
+ Table,
617
+ group,
618
+ title=f"{label} · {len(group)} 只",
619
+ title_style=title_style,
620
+ border_style=border_style,
621
+ sort_key=sort_key,
622
+ close_lookup=close_lookup,
623
+ )
624
+
625
+ # ----- Footer ---------------------------------------------------------
626
+ console.print(f"\n[k.label]报告目录:[/k.label] [k.value]{root}[/k.value]")
627
+ console.print(
628
+ f"[k.label]完整报告:[/k.label] [k.value]deeptrade strategy report {run_id}[/k.value] "
629
+ "[subtitle](查看 markdown 全文 + R1 全表 + 数据快照)[/subtitle]"
630
+ )
631
+ console.print("[subtitle]免责声明: 本报告仅用于策略研究,不构成投资建议。[/subtitle]")
632
+
633
+
634
+ def _safe_load_json(path: Path, *, default: Any) -> Any:
635
+ if not path.is_file():
636
+ return default
637
+ try:
638
+ return json.loads(path.read_text(encoding="utf-8"))
639
+ except (OSError, json.JSONDecodeError):
640
+ return default
641
+
642
+
643
+ def _conf_short(c: str) -> str:
644
+ """Map LLM 'high'/'medium'/'low' to a 1-char display."""
645
+ return {"high": "高", "medium": "中", "low": "低"}.get(c, c[:1].upper() if c else "?")
646
+
647
+
648
+ def _render_prediction_table(
649
+ console: Any,
650
+ table_cls: Any,
651
+ group: list[dict],
652
+ *,
653
+ title: str,
654
+ title_style: str,
655
+ border_style: str,
656
+ sort_key: str,
657
+ close_lookup: dict,
658
+ ) -> None:
659
+ """Render one prediction-class section as a uniform table.
660
+
661
+ All three R2 prediction classes (top_candidate / watchlist / avoid) share the
662
+ same column layout — visual hierarchy is conveyed via title_style and
663
+ border_style only.
664
+ """
665
+ t = table_cls(
666
+ title=title,
667
+ title_style=title_style,
668
+ border_style=border_style,
669
+ header_style="k.label",
670
+ expand=True,
671
+ )
672
+ t.add_column("#", justify="right", width=3)
673
+ t.add_column("代码", style="k.value", no_wrap=True, width=11)
674
+ t.add_column("名称", no_wrap=True, max_width=10)
675
+ t.add_column("T收盘", justify="right", width=7)
676
+ t.add_column("分", justify="right", width=4)
677
+ t.add_column("信", width=4)
678
+ t.add_column("理由", overflow="fold")
679
+ for p in group:
680
+ t.add_row(
681
+ str(p.get(sort_key, p.get("rank", "?"))),
682
+ p.get("ts_code", "?"),
683
+ p.get("name", "?"),
684
+ _close_str(close_lookup.get(p.get("ts_code"))),
685
+ f"{p.get('continuation_score', 0):.0f}",
686
+ _conf_short(p.get("confidence", "")),
687
+ p.get("rationale", ""),
688
+ )
689
+ console.print(t)
690
+
691
+
692
+ def export_llm_calls(run_id: str, db, *, reports_root: Path | None = None) -> int: # noqa: ANN001
693
+ """Pull this run's llm_calls rows into reports/<run_id>/llm_calls.jsonl."""
694
+ root = (reports_root or paths.reports_dir()) / str(run_id)
695
+ root.mkdir(parents=True, exist_ok=True)
696
+ rows = db.fetchall(
697
+ "SELECT call_id, model, prompt_hash, input_tokens, output_tokens, "
698
+ "latency_ms, validation_status, error, created_at "
699
+ "FROM llm_calls WHERE run_id = ? ORDER BY created_at",
700
+ (run_id,),
701
+ )
702
+ out_path = root / "llm_calls.jsonl"
703
+ with out_path.open("w", encoding="utf-8") as fh:
704
+ for row in rows:
705
+ fh.write(
706
+ json.dumps(
707
+ {
708
+ "call_id": str(row[0]),
709
+ "model": row[1],
710
+ "prompt_hash": row[2],
711
+ "input_tokens": row[3],
712
+ "output_tokens": row[4],
713
+ "latency_ms": row[5],
714
+ "validation_status": row[6],
715
+ "error": row[7],
716
+ "created_at": str(row[8]),
717
+ },
718
+ ensure_ascii=False,
719
+ )
720
+ + "\n"
721
+ )
722
+ return len(rows)
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # Helpers
727
+ # ---------------------------------------------------------------------------
728
+
729
+
730
+ def _industry_for(cid: str, candidates: list[dict[str, Any]]) -> str:
731
+ for c in candidates:
732
+ if c["candidate_id"] == cid:
733
+ return str(c.get("industry") or c.get("lu_desc") or "—")
734
+ return "—"
735
+
736
+
737
+ def _name_for(cid: str, predictions: list[ContinuationCandidate]) -> str:
738
+ for p in predictions:
739
+ if p.candidate_id == cid:
740
+ return p.name
741
+ return cid
742
+
743
+
744
+ def _close_for(cid: str, candidates: list[dict[str, Any]]) -> str:
745
+ """Format the T-close price (元) for a candidate, '—' when missing."""
746
+ for c in candidates:
747
+ if c.get("candidate_id") == cid:
748
+ return _close_str(c.get("close_yuan"))
749
+ return "—"
750
+
751
+
752
+ def _close_str(v: Any) -> str:
753
+ if v is None:
754
+ return "—"
755
+ try:
756
+ return f"{float(v):.2f}"
757
+ except (TypeError, ValueError):
758
+ return "—"
759
+
760
+
761
+ def _render_debate_terminal(
762
+ console: Any,
763
+ root: Path,
764
+ snap: dict[str, Any],
765
+ close_lookup: dict[str, Any],
766
+ ) -> None:
767
+ """Render debate-mode tables: per-provider summary + cross-LLM matrix."""
768
+ from rich.table import Table
769
+
770
+ debate_dir = root / "debate"
771
+ if not debate_dir.is_dir():
772
+ console.print("[subtitle](无 debate/ 目录,可能 Phase A 全部失败)[/subtitle]")
773
+ return
774
+
775
+ providers: list[str] = list(snap.get("debate_providers") or [])
776
+ if not providers:
777
+ providers = sorted(p.name for p in debate_dir.iterdir() if p.is_dir())
778
+
779
+ # Load each provider's initial + revised
780
+ initials: dict[str, list[dict]] = {}
781
+ reviseds: dict[str, list[dict]] = {}
782
+ revision_summaries: dict[str, str] = {}
783
+ for p in providers:
784
+ pdir = debate_dir / p
785
+ initials[p] = _safe_load_json(pdir / "round2_initial.json", default=[]) or []
786
+ rev_obj = _safe_load_json(pdir / "round2_revised.json", default={}) or {}
787
+ reviseds[p] = rev_obj.get("candidates", []) if isinstance(rev_obj, dict) else []
788
+ if isinstance(rev_obj, dict):
789
+ revision_summaries[p] = rev_obj.get("revision_summary", "") or ""
790
+
791
+ # ----- Provider status panel ------------------------------------------
792
+ status_t = Table(
793
+ title=f"参与 LLM · {len(providers)} 位",
794
+ title_style="title",
795
+ border_style="panel.border.primary",
796
+ header_style="k.label",
797
+ )
798
+ status_t.add_column("Provider", style="k.value", no_wrap=True)
799
+ status_t.add_column("R2 初始", justify="right", width=8)
800
+ status_t.add_column("R3 修订", justify="right", width=8)
801
+ status_t.add_column("升级", justify="right", width=4)
802
+ status_t.add_column("保持", justify="right", width=4)
803
+ status_t.add_column("降级", justify="right", width=4)
804
+ for p in providers:
805
+ init_map = {c.get("candidate_id"): c for c in initials.get(p, [])}
806
+ ups = downs = keeps = 0
807
+ for r in reviseds.get(p, []):
808
+ cid = r.get("candidate_id")
809
+ init = init_map.get(cid)
810
+ if init is None:
811
+ continue
812
+ ranking = {"avoid": 0, "watchlist": 1, "top_candidate": 2}
813
+ i_r = ranking.get(str(init.get("prediction") or ""), 1)
814
+ r_r = ranking.get(str(r.get("prediction") or ""), 1)
815
+ if r_r > i_r:
816
+ ups += 1
817
+ elif r_r < i_r:
818
+ downs += 1
819
+ else:
820
+ keeps += 1
821
+ status_t.add_row(
822
+ p,
823
+ str(len(initials.get(p, []))),
824
+ str(len(reviseds.get(p, []))),
825
+ str(ups),
826
+ str(keeps),
827
+ str(downs),
828
+ )
829
+ console.print(status_t)
830
+
831
+ # ----- Cross-LLM disagreement matrix (Revised) ------------------------
832
+ union_ids: dict[str, tuple[str, str]] = {}
833
+ pred_by_provider: dict[str, dict[str, dict]] = {}
834
+ for p in providers:
835
+ prov_map: dict[str, dict] = {}
836
+ # Prefer revised; fall back to initial when R3 failed for this provider.
837
+ source = reviseds.get(p) or initials.get(p, [])
838
+ for c in source:
839
+ cid = c.get("candidate_id")
840
+ if cid is None:
841
+ continue
842
+ prov_map[cid] = c
843
+ union_ids.setdefault(
844
+ cid, (c.get("ts_code", "?"), c.get("name", "?"))
845
+ )
846
+ pred_by_provider[p] = prov_map
847
+
848
+ if not union_ids:
849
+ console.print("[subtitle](所有 LLM 均无连板预测候选)[/subtitle]")
850
+ return
851
+
852
+ def coverage(cid: str) -> int:
853
+ return sum(1 for m in pred_by_provider.values() if cid in m)
854
+
855
+ sorted_ids = sorted(union_ids.keys(), key=lambda c: (-coverage(c), c))
856
+
857
+ matrix = Table(
858
+ title=f"分歧矩阵 (Revised) · {len(sorted_ids)} 只 × {len(providers)} 个 LLM",
859
+ title_style="title",
860
+ border_style="panel.border.ok",
861
+ header_style="k.label",
862
+ )
863
+ matrix.add_column("代码", style="k.value", no_wrap=True, width=11)
864
+ matrix.add_column("名称", no_wrap=True, max_width=10)
865
+ matrix.add_column("T收盘", justify="right", width=7)
866
+ for p in providers:
867
+ matrix.add_column(p, justify="center", width=8)
868
+ for cid in sorted_ids:
869
+ ts_code, name = union_ids[cid]
870
+ row = [ts_code, name, _close_str(close_lookup.get(ts_code))]
871
+ for p in providers:
872
+ entry: dict | None = pred_by_provider[p].get(cid)
873
+ if entry is None:
874
+ row.append("—")
875
+ else:
876
+ row.append(
877
+ f"{_glyph(entry.get('prediction'))} "
878
+ f"{float(entry.get('continuation_score', 0)):.0f}"
879
+ )
880
+ matrix.add_row(*row)
881
+ console.print(matrix)
882
+ console.print(
883
+ "[subtitle]▲ top_candidate / ○ watchlist / ▽ avoid / — 该 LLM 未将此股纳入预测[/subtitle]"
884
+ )
885
+
886
+ # ----- revision_summary per provider ----------------------------------
887
+ for p in providers:
888
+ s = revision_summaries.get(p)
889
+ if s:
890
+ console.print(f"[k.label]{p} · revision_summary:[/k.label] {s}")