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,772 @@
|
|
|
1
|
+
"""Plugin-internal run lifecycle for volume-anomaly.
|
|
2
|
+
|
|
3
|
+
Three modes — screen / analyze / prune — each with its own execute method.
|
|
4
|
+
All write to ``va_runs`` (with ``mode`` column) and ``va_events``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from deeptrade.core.run_status import RunStatus
|
|
18
|
+
from deeptrade.core.tushare_client import TushareUnauthorizedError
|
|
19
|
+
from deeptrade.plugins_api.events import EventLevel, EventType, StrategyEvent
|
|
20
|
+
|
|
21
|
+
from .calendar import TradeCalendar
|
|
22
|
+
from .data import (
|
|
23
|
+
EVALUATE_DEFAULT_LOOKBACK_DAYS,
|
|
24
|
+
EVALUATE_HORIZONS,
|
|
25
|
+
EVALUATE_WINDOW_5D,
|
|
26
|
+
EVALUATE_WINDOW_10D,
|
|
27
|
+
AnalyzeBundle,
|
|
28
|
+
ScreenResult,
|
|
29
|
+
ScreenRules,
|
|
30
|
+
_classify_data_status,
|
|
31
|
+
_compute_realized_returns,
|
|
32
|
+
_resolve_horizon_dates,
|
|
33
|
+
append_anomaly_history,
|
|
34
|
+
collect_analyze_bundle,
|
|
35
|
+
fetch_anomaly_dates_within_lookback,
|
|
36
|
+
fetch_completed_realized_keys,
|
|
37
|
+
prune_watchlist,
|
|
38
|
+
resolve_trade_date,
|
|
39
|
+
screen_anomalies,
|
|
40
|
+
upsert_realized_return,
|
|
41
|
+
upsert_watchlist,
|
|
42
|
+
)
|
|
43
|
+
from .pipeline import run_analyze
|
|
44
|
+
from .render import (
|
|
45
|
+
EvaluateOutcome,
|
|
46
|
+
export_llm_calls,
|
|
47
|
+
render_terminal_summary,
|
|
48
|
+
write_analyze_report,
|
|
49
|
+
write_evaluate_report,
|
|
50
|
+
write_prune_report,
|
|
51
|
+
write_screen_report,
|
|
52
|
+
)
|
|
53
|
+
from .runtime import VaRuntime, build_tushare_client, pick_llm_provider
|
|
54
|
+
from .schemas import VATrendCandidate
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
DEFAULT_PRUNE_DAYS = 30
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ScreenParams:
|
|
63
|
+
trade_date: str | None = None
|
|
64
|
+
allow_intraday: bool = False
|
|
65
|
+
force_sync: bool = False
|
|
66
|
+
screen_rules: dict[str, Any] | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class AnalyzeParams:
|
|
71
|
+
trade_date: str | None = None
|
|
72
|
+
allow_intraday: bool = False
|
|
73
|
+
force_sync: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class PruneParams:
|
|
78
|
+
trade_date: str | None = None
|
|
79
|
+
allow_intraday: bool = False
|
|
80
|
+
days: int = DEFAULT_PRUNE_DAYS
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class EvaluateParams:
|
|
85
|
+
"""v0.4.0 P1-3 — evaluate mode: compute T+N realised returns for past hits."""
|
|
86
|
+
trade_date: str | None = None
|
|
87
|
+
allow_intraday: bool = False
|
|
88
|
+
lookback_days: int = EVALUATE_DEFAULT_LOOKBACK_DAYS
|
|
89
|
+
backfill_all: bool = False # F12 — when True, ignore lookback_days
|
|
90
|
+
force_recompute: bool = False # re-evaluate rows that are already 'complete'
|
|
91
|
+
force_sync: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class RunOutcome:
|
|
96
|
+
run_id: str
|
|
97
|
+
status: RunStatus
|
|
98
|
+
error: str | None
|
|
99
|
+
seen_events: list[StrategyEvent]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class VaRunner:
|
|
103
|
+
def __init__(self, rt: VaRuntime) -> None:
|
|
104
|
+
self._rt = rt
|
|
105
|
+
self._pending: list[StrategyEvent] = []
|
|
106
|
+
|
|
107
|
+
# ----- entry points --------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def execute_screen(self, params: ScreenParams) -> RunOutcome:
|
|
110
|
+
return self._drive("screen", params, self._iter_screen(params))
|
|
111
|
+
|
|
112
|
+
def execute_analyze(self, params: AnalyzeParams) -> RunOutcome:
|
|
113
|
+
return self._drive("analyze", params, self._iter_analyze(params))
|
|
114
|
+
|
|
115
|
+
def execute_prune(self, params: PruneParams) -> RunOutcome:
|
|
116
|
+
return self._drive("prune", params, self._iter_prune(params))
|
|
117
|
+
|
|
118
|
+
def execute_evaluate(self, params: EvaluateParams) -> RunOutcome:
|
|
119
|
+
# G10 — evaluate writes va_runs / va_events with mode='evaluate' so it
|
|
120
|
+
# appears in `deeptrade volume-anomaly history` alongside other modes.
|
|
121
|
+
return self._drive("evaluate", params, self._iter_evaluate(params))
|
|
122
|
+
|
|
123
|
+
# ----- driver --------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _drive(
|
|
126
|
+
self, mode: str, params: Any, iterator: Iterable[StrategyEvent]
|
|
127
|
+
) -> RunOutcome:
|
|
128
|
+
run_id = str(uuid.uuid4())
|
|
129
|
+
self._rt.run_id = run_id
|
|
130
|
+
self._rt.is_intraday = bool(getattr(params, "allow_intraday", False))
|
|
131
|
+
self._record_run_start(run_id, mode, params)
|
|
132
|
+
|
|
133
|
+
events: list[StrategyEvent] = []
|
|
134
|
+
seen_validation_failed = False
|
|
135
|
+
terminal_status = RunStatus.SUCCESS
|
|
136
|
+
terminal_error: str | None = None
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
seq = 0
|
|
140
|
+
for ev in iterator:
|
|
141
|
+
seq += 1
|
|
142
|
+
self._persist_event(run_id, seq, ev)
|
|
143
|
+
events.append(ev)
|
|
144
|
+
self._render_event(ev)
|
|
145
|
+
if ev.type == EventType.VALIDATION_FAILED:
|
|
146
|
+
seen_validation_failed = True
|
|
147
|
+
except KeyboardInterrupt:
|
|
148
|
+
terminal_status = RunStatus.CANCELLED
|
|
149
|
+
terminal_error = "KeyboardInterrupt"
|
|
150
|
+
except Exception as e: # noqa: BLE001
|
|
151
|
+
terminal_status = RunStatus.FAILED
|
|
152
|
+
terminal_error = f"{type(e).__name__}: {e}"
|
|
153
|
+
logger.exception("volume-anomaly %s run %s raised", mode, run_id)
|
|
154
|
+
|
|
155
|
+
if terminal_status == RunStatus.SUCCESS and seen_validation_failed:
|
|
156
|
+
terminal_status = RunStatus.PARTIAL_FAILED
|
|
157
|
+
|
|
158
|
+
self._record_run_finish(run_id, terminal_status, terminal_error, events)
|
|
159
|
+
return RunOutcome(
|
|
160
|
+
run_id=run_id, status=terminal_status, error=terminal_error, seen_events=events
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ----- screen --------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _iter_screen(self, params: ScreenParams) -> Iterable[StrategyEvent]:
|
|
166
|
+
rt = self._rt
|
|
167
|
+
rt.tushare = build_tushare_client(
|
|
168
|
+
rt, intraday=params.allow_intraday, event_cb=self._on_tushare_event
|
|
169
|
+
)
|
|
170
|
+
cfg = rt.config.get_app_config()
|
|
171
|
+
|
|
172
|
+
yield rt.emit(EventType.STEP_STARTED, "Step 0: resolve trade date")
|
|
173
|
+
cal_df = rt.tushare.call("trade_cal")
|
|
174
|
+
cal = TradeCalendar(cal_df)
|
|
175
|
+
T, T1 = resolve_trade_date(
|
|
176
|
+
datetime.now(),
|
|
177
|
+
cal,
|
|
178
|
+
user_specified=params.trade_date,
|
|
179
|
+
allow_intraday=params.allow_intraday,
|
|
180
|
+
close_after=cfg.app_close_after if cfg is not None else time(18, 0),
|
|
181
|
+
)
|
|
182
|
+
yield rt.emit(
|
|
183
|
+
EventType.STEP_FINISHED,
|
|
184
|
+
f"Step 0: T={T} T+1={T1}",
|
|
185
|
+
payload={"trade_date": T, "next_trade_date": T1},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
rules = ScreenRules.from_dict(params.screen_rules)
|
|
189
|
+
yield rt.emit(EventType.DATA_SYNC_STARTED, "Step 1: screen anomalies")
|
|
190
|
+
try:
|
|
191
|
+
result: ScreenResult = screen_anomalies(
|
|
192
|
+
tushare=rt.tushare,
|
|
193
|
+
calendar=cal,
|
|
194
|
+
trade_date=T,
|
|
195
|
+
rules=rules,
|
|
196
|
+
force_sync=params.force_sync,
|
|
197
|
+
)
|
|
198
|
+
except TushareUnauthorizedError as e:
|
|
199
|
+
yield rt.emit(
|
|
200
|
+
EventType.LOG, f"required tushare api unauthorized: {e}", level=EventLevel.ERROR
|
|
201
|
+
)
|
|
202
|
+
raise
|
|
203
|
+
yield from self._drain_pending()
|
|
204
|
+
yield rt.emit(
|
|
205
|
+
EventType.DATA_SYNC_FINISHED,
|
|
206
|
+
f"funnel: {result.n_main_board} → {result.n_after_st_susp} → "
|
|
207
|
+
f"{result.n_after_t_day_rules} → {result.n_after_turnover} → "
|
|
208
|
+
f"{result.n_after_vol_rules}",
|
|
209
|
+
payload={
|
|
210
|
+
"n_main_board": result.n_main_board,
|
|
211
|
+
"n_after_st_susp": result.n_after_st_susp,
|
|
212
|
+
"n_after_t_day_rules": result.n_after_t_day_rules,
|
|
213
|
+
"n_after_turnover": result.n_after_turnover,
|
|
214
|
+
"n_after_vol_rules": result.n_after_vol_rules,
|
|
215
|
+
"data_unavailable": result.data_unavailable,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
n_new, n_updated = upsert_watchlist(rt.db, result.hits, trade_date=T)
|
|
220
|
+
append_anomaly_history(rt.db, result.hits)
|
|
221
|
+
watchlist_total = int(rt.db.fetchone("SELECT COUNT(*) FROM va_watchlist")[0])
|
|
222
|
+
|
|
223
|
+
report_path = write_screen_report(
|
|
224
|
+
rt.run_id,
|
|
225
|
+
status=RunStatus.SUCCESS,
|
|
226
|
+
is_intraday=params.allow_intraday,
|
|
227
|
+
result=result,
|
|
228
|
+
n_new=n_new,
|
|
229
|
+
n_updated=n_updated,
|
|
230
|
+
watchlist_total=watchlist_total,
|
|
231
|
+
)
|
|
232
|
+
export_llm_calls(rt.run_id, rt.db)
|
|
233
|
+
yield rt.emit(
|
|
234
|
+
EventType.RESULT_PERSISTED,
|
|
235
|
+
f"screen done — {n_new} new, {n_updated} updated, pool={watchlist_total}",
|
|
236
|
+
payload={
|
|
237
|
+
"report_dir": str(report_path),
|
|
238
|
+
"n_new": n_new,
|
|
239
|
+
"n_updated": n_updated,
|
|
240
|
+
"watchlist_total": watchlist_total,
|
|
241
|
+
"n_hits": len(result.hits),
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# ----- analyze -------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _iter_analyze(self, params: AnalyzeParams) -> Iterable[StrategyEvent]:
|
|
248
|
+
rt = self._rt
|
|
249
|
+
rt.tushare = build_tushare_client(
|
|
250
|
+
rt, intraday=params.allow_intraday, event_cb=self._on_tushare_event
|
|
251
|
+
)
|
|
252
|
+
from deeptrade.core import paths
|
|
253
|
+
|
|
254
|
+
provider_name = pick_llm_provider(rt)
|
|
255
|
+
reports_dir = paths.reports_dir() / rt.run_id if rt.run_id else None
|
|
256
|
+
llm = rt.llms.get_client(
|
|
257
|
+
provider_name,
|
|
258
|
+
plugin_id=rt.plugin_id,
|
|
259
|
+
run_id=rt.run_id,
|
|
260
|
+
reports_dir=reports_dir,
|
|
261
|
+
)
|
|
262
|
+
cfg = rt.config.get_app_config()
|
|
263
|
+
|
|
264
|
+
yield rt.emit(EventType.STEP_STARTED, "Step 0: resolve trade date")
|
|
265
|
+
cal_df = rt.tushare.call("trade_cal")
|
|
266
|
+
cal = TradeCalendar(cal_df)
|
|
267
|
+
T, T1 = resolve_trade_date(
|
|
268
|
+
datetime.now(),
|
|
269
|
+
cal,
|
|
270
|
+
user_specified=params.trade_date,
|
|
271
|
+
allow_intraday=params.allow_intraday,
|
|
272
|
+
close_after=cfg.app_close_after if cfg is not None else time(18, 0),
|
|
273
|
+
)
|
|
274
|
+
yield rt.emit(
|
|
275
|
+
EventType.STEP_FINISHED,
|
|
276
|
+
f"Step 0: T={T} T+1={T1}",
|
|
277
|
+
payload={"trade_date": T, "next_trade_date": T1},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
yield rt.emit(EventType.STEP_STARTED, "Step 1: data assembly")
|
|
281
|
+
try:
|
|
282
|
+
bundle: AnalyzeBundle = collect_analyze_bundle(
|
|
283
|
+
tushare=rt.tushare,
|
|
284
|
+
db=rt.db,
|
|
285
|
+
calendar=cal,
|
|
286
|
+
trade_date=T,
|
|
287
|
+
next_trade_date=T1,
|
|
288
|
+
force_sync=params.force_sync,
|
|
289
|
+
)
|
|
290
|
+
except TushareUnauthorizedError as e:
|
|
291
|
+
yield rt.emit(
|
|
292
|
+
EventType.LOG, f"required tushare api unauthorized: {e}", level=EventLevel.ERROR
|
|
293
|
+
)
|
|
294
|
+
raise
|
|
295
|
+
yield from self._drain_pending()
|
|
296
|
+
# G8 — explicit WARN log when index_daily is unavailable so users notice
|
|
297
|
+
# the alpha-field degradation rather than silently losing the signal.
|
|
298
|
+
for entry in bundle.data_unavailable:
|
|
299
|
+
if entry.startswith("index_daily"):
|
|
300
|
+
yield rt.emit(
|
|
301
|
+
EventType.LOG,
|
|
302
|
+
entry,
|
|
303
|
+
level=EventLevel.WARN,
|
|
304
|
+
)
|
|
305
|
+
break
|
|
306
|
+
yield rt.emit(
|
|
307
|
+
EventType.STEP_FINISHED,
|
|
308
|
+
f"Step 1: {len(bundle.candidates)} candidates from watchlist",
|
|
309
|
+
payload={
|
|
310
|
+
"candidates": len(bundle.candidates),
|
|
311
|
+
"data_unavailable": bundle.data_unavailable,
|
|
312
|
+
"sector_strength_source": bundle.sector_strength_source,
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if not bundle.candidates:
|
|
317
|
+
yield from self._emit_empty_analyze_report(bundle, params, reason="empty watchlist")
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
preset = cfg.app_profile # v0.7: per-stage tuning resolved by plugin
|
|
321
|
+
analyze_result = None
|
|
322
|
+
for ev, res in run_analyze(llm=llm, bundle=bundle, preset=preset):
|
|
323
|
+
yield ev
|
|
324
|
+
if res is not None:
|
|
325
|
+
analyze_result = res
|
|
326
|
+
|
|
327
|
+
predictions: list[VATrendCandidate] = (
|
|
328
|
+
analyze_result.predictions if analyze_result else []
|
|
329
|
+
)
|
|
330
|
+
market_ctx_summary = (
|
|
331
|
+
analyze_result.market_context_summaries[0]
|
|
332
|
+
if analyze_result and analyze_result.market_context_summaries
|
|
333
|
+
else None
|
|
334
|
+
)
|
|
335
|
+
risk_disclaimer = (
|
|
336
|
+
analyze_result.risk_disclaimers[0]
|
|
337
|
+
if analyze_result and analyze_result.risk_disclaimers
|
|
338
|
+
else None
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
terminal_status = RunStatus.SUCCESS
|
|
342
|
+
if analyze_result and analyze_result.failed_batches > 0:
|
|
343
|
+
terminal_status = RunStatus.PARTIAL_FAILED
|
|
344
|
+
|
|
345
|
+
_write_stage_results(rt, "analyze", predictions, bundle)
|
|
346
|
+
failed_batches = list(analyze_result.failed_batch_ids) if analyze_result else []
|
|
347
|
+
|
|
348
|
+
report_path = write_analyze_report(
|
|
349
|
+
rt.run_id,
|
|
350
|
+
status=terminal_status,
|
|
351
|
+
is_intraday=params.allow_intraday,
|
|
352
|
+
bundle=bundle,
|
|
353
|
+
predictions=predictions,
|
|
354
|
+
market_context_summary=market_ctx_summary,
|
|
355
|
+
risk_disclaimer=risk_disclaimer,
|
|
356
|
+
failed_batch_ids=failed_batches or None,
|
|
357
|
+
)
|
|
358
|
+
export_llm_calls(rt.run_id, rt.db)
|
|
359
|
+
|
|
360
|
+
n_imminent = sum(1 for c in predictions if c.prediction == "imminent_launch")
|
|
361
|
+
yield rt.emit(
|
|
362
|
+
EventType.RESULT_PERSISTED,
|
|
363
|
+
f"Report written: {report_path}",
|
|
364
|
+
payload={
|
|
365
|
+
"report_dir": str(report_path),
|
|
366
|
+
"predictions": len(predictions),
|
|
367
|
+
"imminent_launch": n_imminent,
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _emit_empty_analyze_report(
|
|
372
|
+
self, bundle: AnalyzeBundle, params: AnalyzeParams, *, reason: str
|
|
373
|
+
) -> Iterable[StrategyEvent]:
|
|
374
|
+
rt = self._rt
|
|
375
|
+
report_path = write_analyze_report(
|
|
376
|
+
rt.run_id,
|
|
377
|
+
status=RunStatus.SUCCESS,
|
|
378
|
+
is_intraday=params.allow_intraday,
|
|
379
|
+
bundle=bundle,
|
|
380
|
+
predictions=[],
|
|
381
|
+
market_context_summary=None,
|
|
382
|
+
risk_disclaimer=None,
|
|
383
|
+
)
|
|
384
|
+
export_llm_calls(rt.run_id, rt.db)
|
|
385
|
+
yield rt.emit(
|
|
386
|
+
EventType.RESULT_PERSISTED,
|
|
387
|
+
f"empty analyze report ({reason})",
|
|
388
|
+
payload={"report_dir": str(report_path), "reason": reason},
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# ----- prune ---------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def _iter_prune(self, params: PruneParams) -> Iterable[StrategyEvent]:
|
|
394
|
+
rt = self._rt
|
|
395
|
+
rt.tushare = build_tushare_client(
|
|
396
|
+
rt, intraday=params.allow_intraday, event_cb=self._on_tushare_event
|
|
397
|
+
)
|
|
398
|
+
cfg = rt.config.get_app_config()
|
|
399
|
+
|
|
400
|
+
if params.days < 0:
|
|
401
|
+
raise ValueError(f"days must be ≥ 0, got {params.days}")
|
|
402
|
+
|
|
403
|
+
yield rt.emit(EventType.STEP_STARTED, "Step 0: resolve today")
|
|
404
|
+
cal_df = rt.tushare.call("trade_cal")
|
|
405
|
+
cal = TradeCalendar(cal_df)
|
|
406
|
+
today, _ = resolve_trade_date(
|
|
407
|
+
datetime.now(),
|
|
408
|
+
cal,
|
|
409
|
+
user_specified=params.trade_date,
|
|
410
|
+
allow_intraday=params.allow_intraday,
|
|
411
|
+
close_after=cfg.app_close_after if cfg is not None else time(18, 0),
|
|
412
|
+
)
|
|
413
|
+
yield rt.emit(
|
|
414
|
+
EventType.STEP_FINISHED, f"Step 0: today={today}", payload={"today": today}
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
before_total = int(rt.db.fetchone("SELECT COUNT(*) FROM va_watchlist")[0])
|
|
418
|
+
yield rt.emit(EventType.STEP_STARTED, "Step 1: prune watchlist")
|
|
419
|
+
pruned = prune_watchlist(rt.db, min_tracked_calendar_days=params.days, today=today)
|
|
420
|
+
remaining = int(rt.db.fetchone("SELECT COUNT(*) FROM va_watchlist")[0])
|
|
421
|
+
yield rt.emit(
|
|
422
|
+
EventType.STEP_FINISHED,
|
|
423
|
+
f"pruned {len(pruned)}; remaining {remaining}",
|
|
424
|
+
payload={
|
|
425
|
+
"pruned": len(pruned),
|
|
426
|
+
"before_total": before_total,
|
|
427
|
+
"remaining": remaining,
|
|
428
|
+
"min_tracked_days": params.days,
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
report_path = write_prune_report(
|
|
433
|
+
rt.run_id,
|
|
434
|
+
status=RunStatus.SUCCESS,
|
|
435
|
+
today=today,
|
|
436
|
+
min_tracked_days=params.days,
|
|
437
|
+
pruned=pruned,
|
|
438
|
+
watchlist_remaining=remaining,
|
|
439
|
+
)
|
|
440
|
+
export_llm_calls(rt.run_id, rt.db)
|
|
441
|
+
yield rt.emit(
|
|
442
|
+
EventType.RESULT_PERSISTED,
|
|
443
|
+
f"prune done — removed {len(pruned)} / remaining {remaining}",
|
|
444
|
+
payload={
|
|
445
|
+
"report_dir": str(report_path),
|
|
446
|
+
"pruned": len(pruned),
|
|
447
|
+
"watchlist_remaining": remaining,
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# ----- evaluate (v0.4.0 P1-3) ----------------------------------------
|
|
452
|
+
|
|
453
|
+
def _iter_evaluate(self, params: EvaluateParams) -> Iterable[StrategyEvent]:
|
|
454
|
+
rt = self._rt
|
|
455
|
+
rt.tushare = build_tushare_client(
|
|
456
|
+
rt, intraday=params.allow_intraday, event_cb=self._on_tushare_event
|
|
457
|
+
)
|
|
458
|
+
cfg = rt.config.get_app_config()
|
|
459
|
+
|
|
460
|
+
yield rt.emit(EventType.STEP_STARTED, "Step 0: resolve today")
|
|
461
|
+
cal_df = rt.tushare.call("trade_cal")
|
|
462
|
+
cal = TradeCalendar(cal_df)
|
|
463
|
+
today, _ = resolve_trade_date(
|
|
464
|
+
datetime.now(),
|
|
465
|
+
cal,
|
|
466
|
+
user_specified=params.trade_date,
|
|
467
|
+
allow_intraday=params.allow_intraday,
|
|
468
|
+
close_after=cfg.app_close_after if cfg is not None else time(18, 0),
|
|
469
|
+
)
|
|
470
|
+
yield rt.emit(
|
|
471
|
+
EventType.STEP_FINISHED,
|
|
472
|
+
f"Step 0: today={today}",
|
|
473
|
+
payload={"today": today},
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# F12 — backfill_all overrides lookback_days; everyone gets re-evaluated.
|
|
477
|
+
lookback = 365 * 10 if params.backfill_all else params.lookback_days
|
|
478
|
+
anomaly_pairs = fetch_anomaly_dates_within_lookback(
|
|
479
|
+
rt.db, today=today, lookback_days=lookback
|
|
480
|
+
)
|
|
481
|
+
completed = (
|
|
482
|
+
set() if params.force_recompute else fetch_completed_realized_keys(rt.db)
|
|
483
|
+
)
|
|
484
|
+
# Skip already-complete rows unless --force-recompute.
|
|
485
|
+
targets = [pair for pair in anomaly_pairs if pair not in completed]
|
|
486
|
+
yield rt.emit(
|
|
487
|
+
EventType.STEP_FINISHED,
|
|
488
|
+
f"Step 1: {len(targets)} target hits "
|
|
489
|
+
f"({len(anomaly_pairs)} total within lookback, "
|
|
490
|
+
f"{len(anomaly_pairs) - len(targets)} already complete)",
|
|
491
|
+
payload={
|
|
492
|
+
"targets": len(targets),
|
|
493
|
+
"total_in_lookback": len(anomaly_pairs),
|
|
494
|
+
"skipped_complete": len(anomaly_pairs) - len(targets),
|
|
495
|
+
"lookback_days": lookback,
|
|
496
|
+
},
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# Group target codes by anomaly_date so we can batch the tushare calls.
|
|
500
|
+
by_date: dict[str, list[str]] = {}
|
|
501
|
+
for adate, code in targets:
|
|
502
|
+
by_date.setdefault(adate, []).append(code)
|
|
503
|
+
|
|
504
|
+
# Resolve required future trade_dates for every distinct anomaly_date.
|
|
505
|
+
# We'll fetch each unique trade_date (T + horizon) once via daily(td=...)
|
|
506
|
+
# and cache the per-code close lookup.
|
|
507
|
+
all_dates_to_fetch: set[str] = set()
|
|
508
|
+
anomaly_horizon_dates: dict[str, dict[int, str]] = {}
|
|
509
|
+
for adate in by_date:
|
|
510
|
+
try:
|
|
511
|
+
horizon_dates = _resolve_horizon_dates(cal, adate, EVALUATE_HORIZONS)
|
|
512
|
+
except ValueError as e:
|
|
513
|
+
logger.warning("evaluate: cannot resolve horizons for %s (%s)", adate, e)
|
|
514
|
+
horizon_dates = {}
|
|
515
|
+
anomaly_horizon_dates[adate] = horizon_dates
|
|
516
|
+
all_dates_to_fetch.add(adate)
|
|
517
|
+
all_dates_to_fetch.update(
|
|
518
|
+
d for d in horizon_dates.values() if d is not None and d <= today
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# Fetch all needed daily(trade_date=X) frames once. The TushareClient
|
|
522
|
+
# already caches each call as trade_day_immutable so subsequent runs
|
|
523
|
+
# are zero-incremental.
|
|
524
|
+
close_by_code_date: dict[tuple[str, str], float] = {}
|
|
525
|
+
n_fetched = 0
|
|
526
|
+
for d in sorted(all_dates_to_fetch):
|
|
527
|
+
df = rt.tushare.call("daily", trade_date=d, force_sync=params.force_sync)
|
|
528
|
+
if df is None or df.empty or "close" not in df.columns:
|
|
529
|
+
continue
|
|
530
|
+
n_fetched += 1
|
|
531
|
+
for r in df[["ts_code", "close"]].itertuples(index=False):
|
|
532
|
+
if r.close is not None:
|
|
533
|
+
close_by_code_date[(str(r.ts_code), str(d))] = float(r.close)
|
|
534
|
+
|
|
535
|
+
yield rt.emit(
|
|
536
|
+
EventType.STEP_FINISHED,
|
|
537
|
+
f"Step 2: fetched daily for {n_fetched}/{len(all_dates_to_fetch)} unique dates",
|
|
538
|
+
payload={
|
|
539
|
+
"dates_fetched": n_fetched,
|
|
540
|
+
"dates_planned": len(all_dates_to_fetch),
|
|
541
|
+
},
|
|
542
|
+
)
|
|
543
|
+
yield from self._drain_pending()
|
|
544
|
+
|
|
545
|
+
# 5d / 10d window dates for max_close / max_dd computation.
|
|
546
|
+
# We need T+1..T+5 and T+1..T+10 trade dates.
|
|
547
|
+
n_complete = 0
|
|
548
|
+
n_partial = 0
|
|
549
|
+
n_pending = 0
|
|
550
|
+
for adate, codes in by_date.items():
|
|
551
|
+
horizon_dates = anomaly_horizon_dates.get(adate, {})
|
|
552
|
+
window5_dates = self._range_horizon_dates(cal, adate, EVALUATE_WINDOW_5D)
|
|
553
|
+
window10_dates = self._range_horizon_dates(cal, adate, EVALUATE_WINDOW_10D)
|
|
554
|
+
# Make sure those window dates are also fetched (they typically
|
|
555
|
+
# overlap with horizon_dates so we just augment the cache lazily).
|
|
556
|
+
extra = (set(window5_dates) | set(window10_dates)) - all_dates_to_fetch
|
|
557
|
+
extra = {d for d in extra if d <= today}
|
|
558
|
+
for d in extra:
|
|
559
|
+
df = rt.tushare.call(
|
|
560
|
+
"daily", trade_date=d, force_sync=params.force_sync
|
|
561
|
+
)
|
|
562
|
+
if df is None or df.empty or "close" not in df.columns:
|
|
563
|
+
continue
|
|
564
|
+
for r in df[["ts_code", "close"]].itertuples(index=False):
|
|
565
|
+
if r.close is not None:
|
|
566
|
+
close_by_code_date[(str(r.ts_code), str(d))] = float(r.close)
|
|
567
|
+
for code in codes:
|
|
568
|
+
t_close = close_by_code_date.get((code, adate))
|
|
569
|
+
horizon_closes = {
|
|
570
|
+
n: close_by_code_date.get((code, d))
|
|
571
|
+
for n, d in horizon_dates.items()
|
|
572
|
+
}
|
|
573
|
+
window5_closes = [
|
|
574
|
+
close_by_code_date.get((code, d)) for d in window5_dates
|
|
575
|
+
]
|
|
576
|
+
window10_closes = [
|
|
577
|
+
close_by_code_date.get((code, d)) for d in window10_dates
|
|
578
|
+
]
|
|
579
|
+
metrics = _compute_realized_returns(
|
|
580
|
+
t_close=t_close,
|
|
581
|
+
horizon_closes=horizon_closes,
|
|
582
|
+
window_5d_closes=window5_closes,
|
|
583
|
+
window_10d_closes=window10_closes,
|
|
584
|
+
)
|
|
585
|
+
status = _classify_data_status(
|
|
586
|
+
horizon_closes=horizon_closes,
|
|
587
|
+
horizons=EVALUATE_HORIZONS,
|
|
588
|
+
today=today,
|
|
589
|
+
horizon_dates=horizon_dates,
|
|
590
|
+
)
|
|
591
|
+
upsert_realized_return(
|
|
592
|
+
rt.db,
|
|
593
|
+
anomaly_date=adate,
|
|
594
|
+
ts_code=code,
|
|
595
|
+
t_close=t_close,
|
|
596
|
+
horizon_closes=horizon_closes,
|
|
597
|
+
metrics=metrics,
|
|
598
|
+
data_status=status,
|
|
599
|
+
)
|
|
600
|
+
if status == "complete":
|
|
601
|
+
n_complete += 1
|
|
602
|
+
elif status == "partial":
|
|
603
|
+
n_partial += 1
|
|
604
|
+
else:
|
|
605
|
+
n_pending += 1
|
|
606
|
+
|
|
607
|
+
outcome = EvaluateOutcome(
|
|
608
|
+
today=today,
|
|
609
|
+
n_targets=len(targets),
|
|
610
|
+
n_skipped_complete=len(anomaly_pairs) - len(targets),
|
|
611
|
+
n_complete=n_complete,
|
|
612
|
+
n_partial=n_partial,
|
|
613
|
+
n_pending=n_pending,
|
|
614
|
+
lookback_days=lookback,
|
|
615
|
+
backfill_all=params.backfill_all,
|
|
616
|
+
)
|
|
617
|
+
report_path = write_evaluate_report(rt.run_id, outcome=outcome)
|
|
618
|
+
export_llm_calls(rt.run_id, rt.db)
|
|
619
|
+
yield rt.emit(
|
|
620
|
+
EventType.RESULT_PERSISTED,
|
|
621
|
+
f"evaluate done — complete={n_complete}, partial={n_partial}, "
|
|
622
|
+
f"pending={n_pending}",
|
|
623
|
+
payload={
|
|
624
|
+
"report_dir": str(report_path),
|
|
625
|
+
"n_complete": n_complete,
|
|
626
|
+
"n_partial": n_partial,
|
|
627
|
+
"n_pending": n_pending,
|
|
628
|
+
"lookback_days": lookback,
|
|
629
|
+
"backfill_all": params.backfill_all,
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
@staticmethod
|
|
634
|
+
def _range_horizon_dates(
|
|
635
|
+
cal: TradeCalendar, anomaly_date: str, n: int
|
|
636
|
+
) -> list[str]:
|
|
637
|
+
"""Return the n trade dates immediately AFTER ``anomaly_date``
|
|
638
|
+
(T+1..T+n). Stops early if the calendar runs out, returning shorter
|
|
639
|
+
list (caller handles ``None`` for missing entries via the close map)."""
|
|
640
|
+
out: list[str] = []
|
|
641
|
+
cursor = anomaly_date
|
|
642
|
+
for _ in range(n):
|
|
643
|
+
try:
|
|
644
|
+
cursor = cal.next_open(cursor)
|
|
645
|
+
except ValueError:
|
|
646
|
+
break
|
|
647
|
+
out.append(cursor)
|
|
648
|
+
return out
|
|
649
|
+
|
|
650
|
+
# ----- helpers -------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
def _on_tushare_event(self, event_type: str, message: str, payload: dict) -> None:
|
|
653
|
+
try:
|
|
654
|
+
etype = EventType(event_type)
|
|
655
|
+
except ValueError:
|
|
656
|
+
logger.warning("unknown tushare event type: %s", event_type)
|
|
657
|
+
return
|
|
658
|
+
self._pending.append(
|
|
659
|
+
StrategyEvent(type=etype, level=EventLevel.WARN, message=message, payload=payload)
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def _drain_pending(self) -> Iterable[StrategyEvent]:
|
|
663
|
+
while self._pending:
|
|
664
|
+
yield self._pending.pop(0)
|
|
665
|
+
|
|
666
|
+
def _render_event(self, ev: StrategyEvent) -> None:
|
|
667
|
+
glyph = "✔" if ev.level == EventLevel.INFO else ("⚠" if ev.level == EventLevel.WARN else "✘")
|
|
668
|
+
print(f" {glyph} [{ev.type.value}] {ev.message}", flush=True)
|
|
669
|
+
|
|
670
|
+
# ----- DB helpers ----------------------------------------------------
|
|
671
|
+
|
|
672
|
+
def _record_run_start(self, run_id: str, mode: str, params: Any) -> None:
|
|
673
|
+
self._rt.db.execute(
|
|
674
|
+
"INSERT INTO va_runs(run_id, mode, trade_date, status, is_intraday, started_at, "
|
|
675
|
+
"params_json) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)",
|
|
676
|
+
(
|
|
677
|
+
run_id,
|
|
678
|
+
mode,
|
|
679
|
+
getattr(params, "trade_date", None) or "",
|
|
680
|
+
RunStatus.RUNNING.value,
|
|
681
|
+
bool(getattr(params, "allow_intraday", False)),
|
|
682
|
+
json.dumps(params.__dict__, ensure_ascii=False, default=str),
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def _record_run_finish(
|
|
687
|
+
self,
|
|
688
|
+
run_id: str,
|
|
689
|
+
status: RunStatus,
|
|
690
|
+
error: str | None,
|
|
691
|
+
events: list[StrategyEvent],
|
|
692
|
+
) -> None:
|
|
693
|
+
summary = {
|
|
694
|
+
"event_count": len(events),
|
|
695
|
+
"validation_failed_count": sum(
|
|
696
|
+
1 for e in events if e.type == EventType.VALIDATION_FAILED
|
|
697
|
+
),
|
|
698
|
+
}
|
|
699
|
+
self._rt.db.execute(
|
|
700
|
+
"UPDATE va_runs SET status=?, finished_at=CURRENT_TIMESTAMP, "
|
|
701
|
+
"summary_json=?, error=? WHERE run_id=?",
|
|
702
|
+
(status.value, json.dumps(summary, ensure_ascii=False), error, run_id),
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
def _persist_event(self, run_id: str, seq: int, ev: StrategyEvent) -> None:
|
|
706
|
+
self._rt.db.execute(
|
|
707
|
+
"INSERT INTO va_events(run_id, seq, level, event_type, message, payload_json) "
|
|
708
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
709
|
+
(
|
|
710
|
+
run_id,
|
|
711
|
+
seq,
|
|
712
|
+
ev.level.value,
|
|
713
|
+
ev.type.value,
|
|
714
|
+
ev.message,
|
|
715
|
+
json.dumps(ev.payload, ensure_ascii=False, default=str),
|
|
716
|
+
),
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _write_stage_results(
|
|
721
|
+
rt: VaRuntime,
|
|
722
|
+
stage: str,
|
|
723
|
+
items: list[VATrendCandidate],
|
|
724
|
+
bundle: AnalyzeBundle,
|
|
725
|
+
) -> None:
|
|
726
|
+
if not items:
|
|
727
|
+
return
|
|
728
|
+
tracked_lookup = {
|
|
729
|
+
c["candidate_id"]: int(c.get("tracked_days") or 0)
|
|
730
|
+
for c in bundle.candidates
|
|
731
|
+
if isinstance(c, dict)
|
|
732
|
+
}
|
|
733
|
+
for item in items:
|
|
734
|
+
d = item.model_dump(mode="json")
|
|
735
|
+
# v0.6.0 P1-2 — split dimension_scores into 6 dedicated DOUBLE columns
|
|
736
|
+
# so `stats --by dimension_scores` can aggregate via plain SQL (G6).
|
|
737
|
+
dim = d.get("dimension_scores") or {}
|
|
738
|
+
rt.db.execute(
|
|
739
|
+
"INSERT INTO va_stage_results(run_id, stage, batch_no, trade_date, ts_code, name, "
|
|
740
|
+
"rank, launch_score, confidence, prediction, pattern, rationale, tracked_days, "
|
|
741
|
+
"evidence_json, risk_flags_json, raw_response_json, "
|
|
742
|
+
"dim_washout, dim_pattern, dim_capital, dim_sector, dim_historical, dim_risk) "
|
|
743
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
744
|
+
(
|
|
745
|
+
rt.run_id,
|
|
746
|
+
stage,
|
|
747
|
+
d.get("batch_no", 0),
|
|
748
|
+
bundle.trade_date,
|
|
749
|
+
d.get("ts_code", ""),
|
|
750
|
+
d.get("name"),
|
|
751
|
+
d.get("rank"),
|
|
752
|
+
d.get("launch_score"),
|
|
753
|
+
d.get("confidence"),
|
|
754
|
+
d.get("prediction"),
|
|
755
|
+
d.get("pattern"),
|
|
756
|
+
d.get("rationale"),
|
|
757
|
+
tracked_lookup.get(d.get("candidate_id", ""), 0),
|
|
758
|
+
json.dumps(d.get("key_evidence") or [], ensure_ascii=False),
|
|
759
|
+
json.dumps(d.get("risk_flags") or [], ensure_ascii=False),
|
|
760
|
+
json.dumps(d, ensure_ascii=False),
|
|
761
|
+
dim.get("washout"),
|
|
762
|
+
dim.get("pattern"),
|
|
763
|
+
dim.get("capital"),
|
|
764
|
+
dim.get("sector"),
|
|
765
|
+
dim.get("historical"),
|
|
766
|
+
dim.get("risk"),
|
|
767
|
+
),
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def render_finished_run(run_id: str) -> None:
|
|
772
|
+
render_terminal_summary(run_id)
|