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.
- deeptrade/__init__.py +8 -0
- deeptrade/channels_builtin/__init__.py +0 -0
- deeptrade/channels_builtin/stdout/__init__.py +0 -0
- deeptrade/channels_builtin/stdout/deeptrade_plugin.yaml +25 -0
- deeptrade/channels_builtin/stdout/migrations/20260429_001_init.sql +13 -0
- deeptrade/channels_builtin/stdout/stdout_channel/__init__.py +0 -0
- deeptrade/channels_builtin/stdout/stdout_channel/channel.py +180 -0
- deeptrade/cli.py +214 -0
- deeptrade/cli_config.py +396 -0
- deeptrade/cli_data.py +33 -0
- deeptrade/cli_plugin.py +176 -0
- deeptrade/core/__init__.py +8 -0
- deeptrade/core/config.py +344 -0
- deeptrade/core/config_migrations.py +138 -0
- deeptrade/core/db.py +176 -0
- deeptrade/core/llm_client.py +591 -0
- deeptrade/core/llm_manager.py +174 -0
- deeptrade/core/logging_config.py +61 -0
- deeptrade/core/migrations/__init__.py +0 -0
- deeptrade/core/migrations/core/20260427_001_init.sql +121 -0
- deeptrade/core/migrations/core/20260501_002_drop_llm_calls_stage.sql +10 -0
- deeptrade/core/migrations/core/__init__.py +0 -0
- deeptrade/core/notifier.py +302 -0
- deeptrade/core/paths.py +49 -0
- deeptrade/core/plugin_manager.py +616 -0
- deeptrade/core/run_status.py +29 -0
- deeptrade/core/secrets.py +152 -0
- deeptrade/core/tushare_client.py +824 -0
- deeptrade/plugins_api/__init__.py +44 -0
- deeptrade/plugins_api/base.py +66 -0
- deeptrade/plugins_api/channel.py +42 -0
- deeptrade/plugins_api/events.py +61 -0
- deeptrade/plugins_api/llm.py +46 -0
- deeptrade/plugins_api/metadata.py +84 -0
- deeptrade/plugins_api/notify.py +67 -0
- deeptrade/strategies_builtin/__init__.py +0 -0
- deeptrade/strategies_builtin/limit_up_board/__init__.py +0 -0
- deeptrade/strategies_builtin/limit_up_board/deeptrade_plugin.yaml +101 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/__init__.py +0 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/calendar.py +65 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/cli.py +269 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/config.py +76 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/data.py +1191 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/pipeline.py +869 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/plugin.py +30 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/profiles.py +85 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/prompts.py +485 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/render.py +890 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/runner.py +1087 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/runtime.py +172 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/schemas.py +178 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260430_001_init.sql +150 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260501_002_lub_stage_results_llm_provider.sql +8 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_001_lub_lhb_tables.sql +36 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_002_lub_cyq_perf.sql +18 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_003_lub_lhb_pk_fix.sql +46 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_004_lub_lhb_drop_pk.sql +53 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_005_lub_config.sql +17 -0
- deeptrade/strategies_builtin/volume_anomaly/__init__.py +0 -0
- deeptrade/strategies_builtin/volume_anomaly/deeptrade_plugin.yaml +59 -0
- deeptrade/strategies_builtin/volume_anomaly/migrations/20260430_001_init.sql +94 -0
- deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_001_realized_returns.sql +44 -0
- deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_002_dimension_scores.sql +13 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/__init__.py +0 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/calendar.py +52 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/cli.py +247 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/data.py +2154 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/pipeline.py +327 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/plugin.py +22 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/profiles.py +49 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts.py +187 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts_examples.py +84 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/render.py +906 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runner.py +772 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runtime.py +90 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/schemas.py +97 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/stats.py +174 -0
- deeptrade/theme.py +48 -0
- deeptrade_quant-0.0.2.dist-info/METADATA +166 -0
- deeptrade_quant-0.0.2.dist-info/RECORD +83 -0
- deeptrade_quant-0.0.2.dist-info/WHEEL +4 -0
- deeptrade_quant-0.0.2.dist-info/entry_points.txt +2 -0
- 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)
|