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,869 @@
1
+ """LLM pipeline for limit-up-board: R1 / R2 / final_ranking.
2
+
3
+ Implements:
4
+ plan_r1_batches() — F5 input + output token dual budget
5
+ run_r1_batches() — yields events; collects StrongCandidate
6
+ run_r2() — single-batch by default; auto multi-batch + final_ranking
7
+ run_final_ranking() — only when R2 multi-batch (M4)
8
+ set_equality_check() — strict candidate_id ⊆ ⊇ check
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from collections.abc import Iterable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from deeptrade.core.llm_client import (
21
+ LLMClient,
22
+ LLMTransportError,
23
+ LLMValidationError,
24
+ )
25
+ from deeptrade.plugins_api import StageProfile
26
+ from deeptrade.plugins_api.events import EventLevel, EventType, StrategyEvent
27
+
28
+ from .data import Round1Bundle, SectorStrength
29
+ from .profiles import STAGE_FINAL, STAGE_R1, STAGE_R2, STAGE_R3, resolve_profile
30
+ from .prompts import (
31
+ FINAL_RANKING_SYSTEM,
32
+ R1_SYSTEM,
33
+ R2_SYSTEM,
34
+ R3_DEBATE_SYSTEM,
35
+ final_ranking_user_prompt,
36
+ r1_user_prompt,
37
+ r2_user_prompt,
38
+ r3_user_prompt,
39
+ )
40
+ from .schemas import (
41
+ ContinuationCandidate,
42
+ ContinuationResponse,
43
+ FinalRankingResponse,
44
+ RevisedContinuationCandidate,
45
+ RevisionResponse,
46
+ StrongAnalysisResponse,
47
+ StrongCandidate,
48
+ )
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Token budgets / batching
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ # Conservative average per-candidate token estimates. Better-than-nothing
59
+ # defaults; production overrides via configure() if needed.
60
+ # avg_out raised from 600 → 800 after audit-log measurements showed actual
61
+ # per-candidate output (~250 tok with the old prompt, but the new full-schema
62
+ # prompt is more verbose) plus we want smaller batches: 32k * 0.85 / 800 ≈ 34
63
+ # candidates/batch instead of ~46. Smaller batches = shorter wall-clock per
64
+ # call and lower risk of hitting the per-call output cap.
65
+ DEFAULT_AVG_INPUT_TOKENS_PER_CANDIDATE = 350
66
+ DEFAULT_AVG_OUTPUT_TOKENS_PER_CANDIDATE = 800
67
+ DEFAULT_R1_INPUT_BUDGET = 80_000
68
+ DEFAULT_R2_INPUT_BUDGET = 200_000
69
+ SAFETY_RATIO = 0.85
70
+
71
+
72
+ @dataclass
73
+ class BatchPlan:
74
+ batch_size: int
75
+ n_batches: int
76
+
77
+
78
+ def plan_r1_batches(
79
+ *,
80
+ n_candidates: int,
81
+ input_budget: int = DEFAULT_R1_INPUT_BUDGET,
82
+ output_budget: int, # = stage profile's max_output_tokens (R1 default 32k)
83
+ overhead_input_tokens: int = 4_000, # system prompt + market summary + sector
84
+ avg_in: int = DEFAULT_AVG_INPUT_TOKENS_PER_CANDIDATE,
85
+ avg_out: int = DEFAULT_AVG_OUTPUT_TOKENS_PER_CANDIDATE,
86
+ ) -> BatchPlan:
87
+ """Pick the largest batch_size satisfying BOTH input and output budgets.
88
+
89
+ F5 fix: BOTH budgets matter — input alone won't catch the case where the
90
+ LLM tries to write 30 candidates × 600 tokens > 8k output cap.
91
+ """
92
+ if n_candidates <= 0:
93
+ return BatchPlan(batch_size=0, n_batches=0)
94
+
95
+ in_room = max(0, input_budget - overhead_input_tokens)
96
+ by_in = max(1, in_room // max(avg_in, 1))
97
+ by_out = max(1, int(output_budget * SAFETY_RATIO) // max(avg_out, 1))
98
+ batch_size = max(1, min(by_in, by_out, n_candidates))
99
+ n_batches = (n_candidates + batch_size - 1) // batch_size
100
+ return BatchPlan(batch_size=batch_size, n_batches=n_batches)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Set equality check (M5 propagation)
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def candidate_id_set_equal(
109
+ inputs: list[dict[str, Any]] | list[StrongCandidate],
110
+ outputs: list[StrongCandidate] | list[ContinuationCandidate],
111
+ ) -> bool:
112
+ """True iff input.candidate_id == output.candidate_id (as sets)."""
113
+ in_ids = _ids(inputs)
114
+ out_ids = {c.candidate_id for c in outputs}
115
+ return in_ids == out_ids
116
+
117
+
118
+ def _ids(items: Any) -> set[str]:
119
+ """Extract candidate_id set from a list of dicts or BaseModels."""
120
+ return {(c["candidate_id"] if isinstance(c, dict) else c.candidate_id) for c in items}
121
+
122
+
123
+ def _set_mismatch_repair_hint(expected: set[str], actual: set[str]) -> str:
124
+ """Build a corrective instruction appended to user prompt on retry."""
125
+ missing = sorted(expected - actual)
126
+ extra = sorted(actual - expected)
127
+ parts = ["\n\n⚠ 上一次响应集合不一致,请严格按照原 candidate_id 列表重新输出。"]
128
+ if missing:
129
+ parts.append(f"missing (你必须包含): {missing}")
130
+ if extra:
131
+ parts.append(f"extra (你不能包含): {extra}")
132
+ parts.append("不可遗漏,不可新增,不可改名。")
133
+ return "\n".join(parts)
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Pipeline result containers
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ @dataclass
142
+ class RoundResult:
143
+ """Outcome of a single R1 / R2 / final_ranking phase."""
144
+
145
+ success_batches: int = 0
146
+ failed_batches: int = 0
147
+ candidates_in: int = 0
148
+ candidates_out: int = 0
149
+ selected: list[StrongCandidate] = field(default_factory=list)
150
+ predictions: list[ContinuationCandidate] = field(default_factory=list)
151
+ final_items: list[Any] = field(default_factory=list)
152
+ # F-L3 — concrete failed batch IDs (e.g. "r1.batch.3") for report banner
153
+ failed_batch_ids: list[str] = field(default_factory=list)
154
+ # F-M3 — record actual batch_size so finalists selection isn't hardcoded
155
+ batch_size: int = 0
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # F-H1 — set-mismatch repair retry
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ class _SetMismatchError(Exception):
164
+ """Raised when LLM output's candidate_id set still doesn't equal the
165
+ expected set after one repair retry."""
166
+
167
+
168
+ def _complete_with_set_check(
169
+ llm: LLMClient,
170
+ *,
171
+ system: str,
172
+ user: str,
173
+ schema: type[BaseModel],
174
+ profile: StageProfile,
175
+ expected_ids: set[str],
176
+ output_attr: str = "candidates",
177
+ repair_retries: int = 1,
178
+ envelope_defaults: dict[str, Any] | None = None,
179
+ ) -> tuple[Any, dict[str, Any]]:
180
+ """Call LLM and verify the output's id-set matches. On mismatch, retry
181
+ once with a corrective hint appended to the user prompt.
182
+
183
+ Args:
184
+ profile: caller-resolved StageProfile (see profiles.py).
185
+ output_attr: name of the BaseModel field that holds the list of items
186
+ (each having a ``.candidate_id`` attr). 'candidates' for R1/R2,
187
+ 'finalists' for final_ranking.
188
+ envelope_defaults: caller-controlled top-level fields (e.g. ``stage``,
189
+ ``trade_date``, ``batch_no``) injected when the LLM omits them.
190
+ """
191
+ current_user = user
192
+ last_actual: set[str] = set()
193
+ for attempt in range(repair_retries + 1):
194
+ raw, meta = llm.complete_json(
195
+ system=system,
196
+ user=current_user,
197
+ schema=schema,
198
+ profile=profile,
199
+ envelope_defaults=envelope_defaults,
200
+ )
201
+ obj = raw if isinstance(raw, schema) else schema.model_validate(raw)
202
+ items = getattr(obj, output_attr)
203
+ actual_ids = {item.candidate_id for item in items}
204
+ if expected_ids == actual_ids:
205
+ return obj, meta
206
+ last_actual = actual_ids
207
+ if attempt < repair_retries:
208
+ current_user = user + _set_mismatch_repair_hint(expected_ids, actual_ids)
209
+ raise _SetMismatchError(
210
+ f"set mismatch after {repair_retries + 1} attempts; "
211
+ f"missing={sorted(expected_ids - last_actual)}, "
212
+ f"extra={sorted(last_actual - expected_ids)}"
213
+ )
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # R1
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def run_r1(
222
+ *,
223
+ llm: LLMClient,
224
+ bundle: Round1Bundle,
225
+ preset: str,
226
+ input_budget: int = DEFAULT_R1_INPUT_BUDGET,
227
+ ) -> Iterable[tuple[StrategyEvent, RoundResult | None]]:
228
+ """Run all R1 batches, yielding (event, terminal_result_or_None).
229
+
230
+ The caller (strategy.run) re-yields the events into the runner. The final
231
+ iteration yields a result alongside the STEP_FINISHED event so the caller
232
+ can hand it on to R2.
233
+ """
234
+ profile = resolve_profile(preset, STAGE_R1)
235
+ candidates = bundle.candidates
236
+ plan = plan_r1_batches(
237
+ n_candidates=len(candidates),
238
+ input_budget=input_budget,
239
+ output_budget=profile.max_output_tokens,
240
+ )
241
+ yield (
242
+ StrategyEvent(
243
+ type=EventType.LIVE_STATUS,
244
+ message=(
245
+ f"[强势标的分析] 待处理 {len(candidates)} 只,分 {plan.n_batches} 批提交..."
246
+ ),
247
+ ),
248
+ None,
249
+ )
250
+ yield (
251
+ StrategyEvent(
252
+ type=EventType.STEP_STARTED,
253
+ message="Step 2: R1 strong target analysis",
254
+ payload={"n_candidates": len(candidates), "n_batches": plan.n_batches},
255
+ ),
256
+ None,
257
+ )
258
+
259
+ result = RoundResult(candidates_in=len(candidates), batch_size=plan.batch_size)
260
+ if plan.n_batches == 0:
261
+ yield (
262
+ StrategyEvent(
263
+ type=EventType.STEP_FINISHED,
264
+ message="Step 2: R1 strong target analysis",
265
+ payload={"selected": 0},
266
+ ),
267
+ result,
268
+ )
269
+ return
270
+
271
+ for i in range(plan.n_batches):
272
+ batch = candidates[i * plan.batch_size : (i + 1) * plan.batch_size]
273
+ yield (
274
+ StrategyEvent(
275
+ type=EventType.LIVE_STATUS,
276
+ message=(
277
+ f"[强势标的分析] 已提交第 {i + 1}/{plan.n_batches} 批 "
278
+ f"({len(batch)} 只),等待 LLM 响应..."
279
+ ),
280
+ ),
281
+ None,
282
+ )
283
+ yield (
284
+ StrategyEvent(
285
+ type=EventType.LLM_BATCH_STARTED,
286
+ message=f"R1 batch {i + 1}/{plan.n_batches}",
287
+ payload={"batch_no": i + 1, "size": len(batch)},
288
+ ),
289
+ None,
290
+ )
291
+
292
+ user = r1_user_prompt(
293
+ trade_date=bundle.trade_date,
294
+ batch_no=i + 1,
295
+ batch_total=plan.n_batches,
296
+ candidates=batch,
297
+ market_summary=bundle.market_summary,
298
+ sector_strength_source=bundle.sector_strength.source,
299
+ sector_strength_data=bundle.sector_strength.data,
300
+ data_unavailable=bundle.data_unavailable,
301
+ )
302
+
303
+ expected_ids = _ids(batch)
304
+ try:
305
+ obj, meta = _complete_with_set_check(
306
+ llm,
307
+ system=R1_SYSTEM,
308
+ user=user,
309
+ schema=StrongAnalysisResponse,
310
+ profile=profile,
311
+ expected_ids=expected_ids,
312
+ envelope_defaults={
313
+ "stage": STAGE_R1,
314
+ "trade_date": bundle.trade_date,
315
+ "batch_no": i + 1,
316
+ "batch_total": plan.n_batches,
317
+ "batch_summary": "",
318
+ },
319
+ )
320
+ except (LLMValidationError, LLMTransportError, _SetMismatchError) as e:
321
+ result.failed_batches += 1
322
+ result.failed_batch_ids.append(f"r1.batch.{i + 1}")
323
+ yield (
324
+ StrategyEvent(
325
+ type=EventType.VALIDATION_FAILED,
326
+ level=EventLevel.ERROR,
327
+ message=f"R1 batch {i + 1} failed: {e}",
328
+ payload={"batch_no": i + 1},
329
+ ),
330
+ None,
331
+ )
332
+ continue
333
+
334
+ result.success_batches += 1
335
+ result.candidates_out += len(obj.candidates)
336
+ result.selected.extend(c for c in obj.candidates if c.selected)
337
+ yield (
338
+ StrategyEvent(
339
+ type=EventType.LLM_BATCH_FINISHED,
340
+ message=f"R1 batch {i + 1}/{plan.n_batches} ok",
341
+ payload={
342
+ "batch_no": i + 1,
343
+ "input_tokens": meta["input_tokens"],
344
+ "output_tokens": meta["output_tokens"],
345
+ },
346
+ ),
347
+ None,
348
+ )
349
+ yield (
350
+ StrategyEvent(
351
+ type=EventType.LIVE_STATUS,
352
+ message=(
353
+ f"[强势标的分析] 第 {i + 1}/{plan.n_batches} 批响应已收到 "
354
+ f"(累计入选 {sum(1 for c in result.selected)} 只)"
355
+ ),
356
+ ),
357
+ None,
358
+ )
359
+
360
+ yield (
361
+ StrategyEvent(
362
+ type=EventType.LIVE_STATUS,
363
+ message=(
364
+ f"[强势标的分析] 完成 — 入选 {len(result.selected)}/{result.candidates_in}"
365
+ ),
366
+ ),
367
+ None,
368
+ )
369
+ yield (
370
+ StrategyEvent(
371
+ type=EventType.STEP_FINISHED,
372
+ message="Step 2: R1 strong target analysis",
373
+ payload={
374
+ "success_batches": result.success_batches,
375
+ "failed_batches": result.failed_batches,
376
+ "selected": len(result.selected),
377
+ },
378
+ ),
379
+ result,
380
+ )
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # R2 + (optional) final_ranking
385
+ # ---------------------------------------------------------------------------
386
+
387
+
388
+ def run_r2(
389
+ *,
390
+ llm: LLMClient,
391
+ selected: list[StrongCandidate],
392
+ bundle: Round1Bundle,
393
+ preset: str,
394
+ input_budget: int = DEFAULT_R2_INPUT_BUDGET,
395
+ ) -> Iterable[tuple[StrategyEvent, RoundResult | None]]:
396
+ """Run R2; if the candidate set exceeds the input budget, multi-batch + final_ranking."""
397
+ profile = resolve_profile(preset, STAGE_R2)
398
+ plan = plan_r1_batches(
399
+ n_candidates=len(selected),
400
+ input_budget=input_budget,
401
+ output_budget=profile.max_output_tokens,
402
+ )
403
+ yield (
404
+ StrategyEvent(
405
+ type=EventType.LIVE_STATUS,
406
+ message=f"[连板预测] 待处理 {len(selected)} 只,分 {plan.n_batches} 批提交...",
407
+ ),
408
+ None,
409
+ )
410
+ yield (
411
+ StrategyEvent(
412
+ type=EventType.STEP_STARTED,
413
+ message="Step 4: R2 continuation prediction",
414
+ payload={"n_candidates": len(selected), "n_batches": plan.n_batches},
415
+ ),
416
+ None,
417
+ )
418
+
419
+ result = RoundResult(candidates_in=len(selected), batch_size=plan.batch_size)
420
+ if plan.n_batches == 0:
421
+ yield (
422
+ StrategyEvent(
423
+ type=EventType.STEP_FINISHED,
424
+ message="Step 4: R2 continuation prediction",
425
+ payload={"predictions": 0},
426
+ ),
427
+ result,
428
+ )
429
+ return
430
+
431
+ # Build candidate dicts for the prompt (R1 selected → minimal payload)
432
+ payload_rows: list[dict[str, Any]] = [
433
+ _r2_row_from_selected(c, bundle.candidates) for c in selected
434
+ ]
435
+
436
+ for i in range(plan.n_batches):
437
+ batch_objs = selected[i * plan.batch_size : (i + 1) * plan.batch_size]
438
+ batch_rows = payload_rows[i * plan.batch_size : (i + 1) * plan.batch_size]
439
+ yield (
440
+ StrategyEvent(
441
+ type=EventType.LIVE_STATUS,
442
+ message=(
443
+ f"[连板预测] 已提交第 {i + 1}/{plan.n_batches} 批 "
444
+ f"({len(batch_objs)} 只),等待 LLM 响应..."
445
+ ),
446
+ ),
447
+ None,
448
+ )
449
+ yield (
450
+ StrategyEvent(
451
+ type=EventType.LLM_BATCH_STARTED,
452
+ message=f"R2 batch {i + 1}/{plan.n_batches}",
453
+ payload={"batch_no": i + 1, "size": len(batch_objs)},
454
+ ),
455
+ None,
456
+ )
457
+
458
+ user = r2_user_prompt(
459
+ trade_date=bundle.trade_date,
460
+ next_trade_date=bundle.next_trade_date,
461
+ candidates=batch_rows,
462
+ market_context=bundle.market_summary,
463
+ sector_strength_source=bundle.sector_strength.source,
464
+ sector_strength_data=bundle.sector_strength.data,
465
+ data_unavailable=bundle.data_unavailable,
466
+ )
467
+
468
+ expected_ids = _ids(batch_objs)
469
+ try:
470
+ obj, meta = _complete_with_set_check(
471
+ llm,
472
+ system=R2_SYSTEM,
473
+ user=user,
474
+ schema=ContinuationResponse,
475
+ profile=profile,
476
+ expected_ids=expected_ids,
477
+ envelope_defaults={
478
+ "stage": "limit_up_continuation_prediction",
479
+ "trade_date": bundle.trade_date,
480
+ "next_trade_date": bundle.next_trade_date,
481
+ "market_context_summary": "",
482
+ "risk_disclaimer": "",
483
+ },
484
+ )
485
+ except (LLMValidationError, LLMTransportError, _SetMismatchError) as e:
486
+ result.failed_batches += 1
487
+ result.failed_batch_ids.append(f"r2.batch.{i + 1}")
488
+ yield (
489
+ StrategyEvent(
490
+ type=EventType.VALIDATION_FAILED,
491
+ level=EventLevel.ERROR,
492
+ message=f"R2 batch {i + 1} failed: {e}",
493
+ payload={"batch_no": i + 1},
494
+ ),
495
+ None,
496
+ )
497
+ continue
498
+
499
+ result.success_batches += 1
500
+ result.predictions.extend(obj.candidates)
501
+ yield (
502
+ StrategyEvent(
503
+ type=EventType.LLM_BATCH_FINISHED,
504
+ message=f"R2 batch {i + 1}/{plan.n_batches} ok",
505
+ payload={
506
+ "batch_no": i + 1,
507
+ "input_tokens": meta["input_tokens"],
508
+ "output_tokens": meta["output_tokens"],
509
+ },
510
+ ),
511
+ None,
512
+ )
513
+ yield (
514
+ StrategyEvent(
515
+ type=EventType.LIVE_STATUS,
516
+ message=(
517
+ f"[连板预测] 第 {i + 1}/{plan.n_batches} 批响应已收到 "
518
+ f"(累计 {len(result.predictions)} 只预测)"
519
+ ),
520
+ ),
521
+ None,
522
+ )
523
+
524
+ yield (
525
+ StrategyEvent(
526
+ type=EventType.LIVE_STATUS,
527
+ message=f"[连板预测] 完成 — 共预测 {len(result.predictions)} 只",
528
+ ),
529
+ None,
530
+ )
531
+ yield (
532
+ StrategyEvent(
533
+ type=EventType.STEP_FINISHED,
534
+ message="Step 4: R2 continuation prediction",
535
+ payload={
536
+ "success_batches": result.success_batches,
537
+ "failed_batches": result.failed_batches,
538
+ "predictions": len(result.predictions),
539
+ },
540
+ ),
541
+ result,
542
+ )
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Final ranking (only triggered when R2 was multi-batch)
547
+ # ---------------------------------------------------------------------------
548
+
549
+
550
+ def select_finalists(
551
+ predictions: list[ContinuationCandidate],
552
+ *,
553
+ per_batch_top_ratio: float = 0.6,
554
+ batch_size_hint: int | None = None,
555
+ ) -> list[ContinuationCandidate]:
556
+ """Pick finalists for the global re-rank (S5).
557
+
558
+ F-M3 fix:
559
+ * Sort top_candidate / watchlist by `continuation_score` DESC before
560
+ truncation, so cap = "top by score", not "by batch order".
561
+ * Boundary avoids are also sorted by score before sampling.
562
+ """
563
+ top_and_watch = sorted(
564
+ (c for c in predictions if c.prediction in ("top_candidate", "watchlist")),
565
+ key=lambda c: c.continuation_score,
566
+ reverse=True,
567
+ )
568
+ avoids = sorted(
569
+ (c for c in predictions if c.prediction == "avoid"),
570
+ key=lambda c: c.continuation_score,
571
+ reverse=True,
572
+ )
573
+ finalists = top_and_watch
574
+ if batch_size_hint:
575
+ cap = max(1, int(batch_size_hint * per_batch_top_ratio))
576
+ finalists = finalists[:cap]
577
+ # Boundary samples: top-scored avoids (up to 1/5 of total predictions)
578
+ finalists.extend(avoids[: max(0, len(predictions) // 5)])
579
+ return finalists
580
+
581
+
582
+ def run_final_ranking(
583
+ *,
584
+ llm: LLMClient,
585
+ bundle: Round1Bundle,
586
+ finalists: list[ContinuationCandidate],
587
+ preset: str,
588
+ ) -> Iterable[tuple[StrategyEvent, FinalRankingResponse | None]]:
589
+ profile = resolve_profile(preset, STAGE_FINAL)
590
+ yield (
591
+ StrategyEvent(
592
+ type=EventType.LIVE_STATUS,
593
+ message=(
594
+ f"[全局重排] 合并 {len(finalists)} 只 finalists,等待 LLM 响应..."
595
+ ),
596
+ ),
597
+ None,
598
+ )
599
+ yield (
600
+ StrategyEvent(
601
+ type=EventType.STEP_STARTED,
602
+ message="Step 4.5: final_ranking global reconciliation",
603
+ payload={"n_finalists": len(finalists)},
604
+ ),
605
+ None,
606
+ )
607
+
608
+ finalist_payload = [_final_row_from_pred(c) for c in finalists]
609
+ user = final_ranking_user_prompt(
610
+ trade_date=bundle.trade_date,
611
+ next_trade_date=bundle.next_trade_date,
612
+ finalists=finalist_payload,
613
+ market_context=bundle.market_summary,
614
+ )
615
+ expected_ids = {f.candidate_id for f in finalists}
616
+ try:
617
+ # F-H1 — final_ranking now also checks candidate_id set equality
618
+ # (previously only `final_rank` 1..N denseness was validated).
619
+ obj, meta = _complete_with_set_check(
620
+ llm,
621
+ system=FINAL_RANKING_SYSTEM,
622
+ user=user,
623
+ schema=FinalRankingResponse,
624
+ profile=profile,
625
+ expected_ids=expected_ids,
626
+ output_attr="finalists",
627
+ envelope_defaults={
628
+ "stage": STAGE_FINAL,
629
+ "trade_date": bundle.trade_date,
630
+ "next_trade_date": bundle.next_trade_date,
631
+ },
632
+ )
633
+ except (LLMValidationError, LLMTransportError, _SetMismatchError) as e:
634
+ yield (
635
+ StrategyEvent(
636
+ type=EventType.VALIDATION_FAILED,
637
+ level=EventLevel.ERROR,
638
+ message=f"final_ranking failed: {e}",
639
+ ),
640
+ None,
641
+ )
642
+ yield (
643
+ StrategyEvent(
644
+ type=EventType.STEP_FINISHED,
645
+ message="Step 4.5: final_ranking global reconciliation",
646
+ payload={"success": False},
647
+ ),
648
+ None,
649
+ )
650
+ return
651
+
652
+ yield (
653
+ StrategyEvent(
654
+ type=EventType.LLM_FINAL_RANK,
655
+ message="final_ranking complete",
656
+ payload={
657
+ "input_tokens": meta["input_tokens"],
658
+ "output_tokens": meta["output_tokens"],
659
+ },
660
+ ),
661
+ None,
662
+ )
663
+ yield (
664
+ StrategyEvent(
665
+ type=EventType.LIVE_STATUS,
666
+ message=f"[全局重排] 完成 — {len(obj.finalists)} 只全局重排完毕",
667
+ ),
668
+ None,
669
+ )
670
+ yield (
671
+ StrategyEvent(
672
+ type=EventType.STEP_FINISHED,
673
+ message="Step 4.5: final_ranking global reconciliation",
674
+ payload={"success": True, "finalists": len(obj.finalists)},
675
+ ),
676
+ obj,
677
+ )
678
+
679
+
680
+ # ---------------------------------------------------------------------------
681
+ # Internal payload shapers
682
+ # ---------------------------------------------------------------------------
683
+
684
+
685
+ def _r2_row_from_selected(
686
+ sc: StrongCandidate, all_candidates: list[dict[str, Any]]
687
+ ) -> dict[str, Any]:
688
+ """Build a minimal R2 input row from an R1 selected verdict + R1 raw fields.
689
+
690
+ We re-attach the normalized fields so the LLM doesn't have to re-derive them.
691
+ """
692
+ base = next((r for r in all_candidates if r["candidate_id"] == sc.candidate_id), {})
693
+ row: dict[str, Any] = {
694
+ **base,
695
+ "r1_score": sc.score,
696
+ "r1_strength_level": sc.strength_level,
697
+ "r1_themes": [],
698
+ "r1_rationale": sc.rationale,
699
+ }
700
+ # Flatten list-valued source fields to scalar siblings so the LLM can cite
701
+ # them in EvidenceItem.value (which is scalar-only: str|int|float|None).
702
+ seats = row.pop("lhb_famous_seats", None)
703
+ seats_list = seats if isinstance(seats, list) else []
704
+ row["lhb_famous_seats_count"] = len(seats_list)
705
+ row["lhb_famous_seats_text"] = "; ".join(str(s) for s in seats_list)
706
+ return row
707
+
708
+
709
+ def _final_row_from_pred(p: ContinuationCandidate) -> dict[str, Any]:
710
+ return {
711
+ "candidate_id": p.candidate_id,
712
+ "ts_code": p.ts_code,
713
+ "name": p.name,
714
+ "continuation_score": p.continuation_score,
715
+ "confidence": p.confidence,
716
+ "prediction": p.prediction,
717
+ "rationale": p.rationale[:120],
718
+ "key_evidence": [
719
+ {"field": e.field, "value": e.value, "unit": e.unit, "interpretation": e.interpretation}
720
+ for e in p.key_evidence[:3]
721
+ ],
722
+ }
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # R3 — Debate-mode revision (each LLM revises its own R2 after seeing peers)
727
+ # ---------------------------------------------------------------------------
728
+
729
+
730
+ @dataclass
731
+ class DebateRoundResult:
732
+ """Outcome of a single LLM's R3 (debate-revision) phase."""
733
+
734
+ success: bool = False
735
+ error: str | None = None
736
+ candidates_in: int = 0
737
+ revision_summary: str = ""
738
+ revised: list[RevisedContinuationCandidate] = field(default_factory=list)
739
+
740
+
741
+ def run_r3_debate(
742
+ *,
743
+ llm: LLMClient,
744
+ bundle: Round1Bundle,
745
+ own_predictions: list[ContinuationCandidate],
746
+ peers: list[tuple[str, list[ContinuationCandidate]]],
747
+ preset: str,
748
+ self_label: str = "you",
749
+ ) -> Iterable[tuple[StrategyEvent, DebateRoundResult | None]]:
750
+ """Single-batch R3 revision; yields events + a terminal result.
751
+
752
+ The revising LLM receives its own ``own_predictions`` (full view) plus a
753
+ trimmed view of every entry in ``peers`` (already anonymised). The output
754
+ candidate_id set is enforced to equal the input ``own_predictions`` set.
755
+ """
756
+ profile = resolve_profile(preset, STAGE_R3)
757
+ n = len(own_predictions)
758
+ yield (
759
+ StrategyEvent(
760
+ type=EventType.LIVE_STATUS,
761
+ message=f"[辩论修订] 待修订 {n} 只,参考同行 {len(peers)} 位...",
762
+ ),
763
+ None,
764
+ )
765
+ yield (
766
+ StrategyEvent(
767
+ type=EventType.STEP_STARTED,
768
+ message="Step 4.7: R3 debate revision",
769
+ payload={"n_candidates": n, "n_peers": len(peers), "self_label": self_label},
770
+ ),
771
+ None,
772
+ )
773
+
774
+ result = DebateRoundResult(candidates_in=n)
775
+ if n == 0:
776
+ yield (
777
+ StrategyEvent(
778
+ type=EventType.STEP_FINISHED,
779
+ message="Step 4.7: R3 debate revision",
780
+ payload={"success": False, "reason": "empty own_predictions"},
781
+ ),
782
+ result,
783
+ )
784
+ return
785
+
786
+ user = r3_user_prompt(
787
+ trade_date=bundle.trade_date,
788
+ next_trade_date=bundle.next_trade_date,
789
+ own_predictions=own_predictions,
790
+ peers=peers,
791
+ market_context=bundle.market_summary,
792
+ )
793
+ expected_ids = {c.candidate_id for c in own_predictions}
794
+ yield (
795
+ StrategyEvent(
796
+ type=EventType.LLM_BATCH_STARTED,
797
+ message="R3 debate (single batch)",
798
+ payload={"size": n},
799
+ ),
800
+ None,
801
+ )
802
+ try:
803
+ obj, meta = _complete_with_set_check(
804
+ llm,
805
+ system=R3_DEBATE_SYSTEM,
806
+ user=user,
807
+ schema=RevisionResponse,
808
+ profile=profile,
809
+ expected_ids=expected_ids,
810
+ envelope_defaults={
811
+ "stage": "limit_up_continuation_revision",
812
+ "trade_date": bundle.trade_date,
813
+ "next_trade_date": bundle.next_trade_date,
814
+ "revision_summary": "",
815
+ },
816
+ )
817
+ except (LLMValidationError, LLMTransportError, _SetMismatchError) as e:
818
+ result.error = f"{type(e).__name__}: {e}"
819
+ yield (
820
+ StrategyEvent(
821
+ type=EventType.VALIDATION_FAILED,
822
+ level=EventLevel.ERROR,
823
+ message=f"R3 debate failed: {e}",
824
+ ),
825
+ None,
826
+ )
827
+ yield (
828
+ StrategyEvent(
829
+ type=EventType.STEP_FINISHED,
830
+ message="Step 4.7: R3 debate revision",
831
+ payload={"success": False},
832
+ ),
833
+ result,
834
+ )
835
+ return
836
+
837
+ result.success = True
838
+ result.revised = list(obj.candidates)
839
+ result.revision_summary = obj.revision_summary
840
+ yield (
841
+ StrategyEvent(
842
+ type=EventType.LLM_BATCH_FINISHED,
843
+ message="R3 debate ok",
844
+ payload={
845
+ "input_tokens": meta["input_tokens"],
846
+ "output_tokens": meta["output_tokens"],
847
+ },
848
+ ),
849
+ None,
850
+ )
851
+ yield (
852
+ StrategyEvent(
853
+ type=EventType.LIVE_STATUS,
854
+ message=f"[辩论修订] 完成 — 修订 {len(result.revised)} 只",
855
+ ),
856
+ None,
857
+ )
858
+ yield (
859
+ StrategyEvent(
860
+ type=EventType.STEP_FINISHED,
861
+ message="Step 4.7: R3 debate revision",
862
+ payload={"success": True, "revised": len(result.revised)},
863
+ ),
864
+ result,
865
+ )
866
+
867
+
868
+ # Suppress unused-import warning for fields we re-export indirectly via tests
869
+ _ = SectorStrength