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