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,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)