Skip to content

Commit c8061b7

Browse files
committed
feat(data): track and retry missing data tasks
Record failed or partial analyst tool calls without interrupting graph execution. Persist task metadata so reports can expose incomplete inputs and retry the exact tool invocation later. Cache successful retry outputs for the next fresh analysis, consume them through ToolNode wrappers, and archive them only after that reanalysis completes. Keep task-index updates serialized for parallel analyst nodes. Add report controls for inspecting and retrying missing data while keeping current PDF exports available. A successful retry explicitly starts a fresh analysis so downstream reports and decisions are regenerated. Cover task detection, retries, cache lifecycle, snapshot synchronization, PDF behavior, and fresh reanalysis with focused regression tests.
1 parent 11045d6 commit c8061b7

5 files changed

Lines changed: 1377 additions & 10 deletions

File tree

tests/test_missing_data_tasks.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
"""Tests for missing-data task recording and retry helpers."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
import sys
8+
import types
9+
from unittest.mock import MagicMock
10+
11+
import pytest
12+
13+
messages_stub = types.ModuleType("langchain_core.messages")
14+
messages_stub.ToolMessage = type("ToolMessage", (), {})
15+
langchain_stub = types.ModuleType("langchain_core")
16+
langchain_stub.messages = messages_stub
17+
dataflow_utils_stub = types.ModuleType("tradingagents.dataflows.utils")
18+
dataflow_utils_stub.safe_ticker_component = lambda value: str(value).strip().upper()
19+
sys.modules.setdefault("langchain_core", langchain_stub)
20+
sys.modules.setdefault("langchain_core.messages", messages_stub)
21+
sys.modules.setdefault("tradingagents.dataflows.utils", dataflow_utils_stub)
22+
23+
from tradingagents.dataflows import missing_data
24+
25+
26+
@pytest.fixture()
27+
def missing_index(tmp_path, monkeypatch):
28+
index = tmp_path / "missing_data_tasks.json"
29+
cache_dir = tmp_path / "missing_data_cache"
30+
monkeypatch.setattr(missing_data, "_MISSING_DATA_TASKS_FILE", index)
31+
monkeypatch.setattr(missing_data, "_MISSING_DATA_CACHE_DIR", cache_dir)
32+
return index
33+
34+
35+
def test_records_explicit_missing_items(missing_index):
36+
task = missing_data.record_tool_result(
37+
ticker="300476",
38+
trade_date="2026-06-03",
39+
stage="fundamentals",
40+
tool_name="get_fundamentals",
41+
args={"ticker": "300476", "curr_date": "2026-06-03"},
42+
content="| ROE | -- | [数据缺失: ROE] |\n| EPS | -- | [数据缺失: 机构一致预期EPS] |",
43+
)
44+
45+
assert task is not None
46+
assert task["ticker"] == "300476"
47+
assert task["stage_label"] == "基本面"
48+
assert task["tool_label"] == "综合基本面"
49+
assert task["missing_items"] == ["ROE", "机构一致预期EPS"]
50+
51+
tasks = missing_data.get_missing_tasks("300476", "2026-06-03")
52+
assert [t["id"] for t in tasks] == [task["id"]]
53+
54+
55+
def test_no_realtime_northbound_data_is_recorded_as_missing(missing_index):
56+
task = missing_data.record_tool_result(
57+
ticker="600602",
58+
trade_date="2026-06-05",
59+
stage="market",
60+
tool_name="get_northbound_flow",
61+
args={"curr_date": "2026-06-05", "include_history": True},
62+
content="# Northbound Capital Flow\nNo realtime data (non-trading hours or holiday)",
63+
)
64+
65+
assert task is not None
66+
assert task["tool_label"] == "北向资金"
67+
assert task["missing_items"] == ["接口返回失败或空数据"]
68+
69+
70+
def test_successful_retry_resolves_task(missing_index, monkeypatch):
71+
task = missing_data.record_tool_result(
72+
ticker="300476",
73+
trade_date="2026-06-03",
74+
stage="market",
75+
tool_name="get_indicators",
76+
args={"symbol": "300476", "curr_date": "2026-06-03", "look_back_days": 90},
77+
content="Error retrieving technical summary for 300476: timeout",
78+
)
79+
assert task is not None
80+
81+
fake_tool = MagicMock()
82+
fake_tool.invoke.return_value = "最新收盘价: 12.34\n| 指标 | 数值 |\n|---|---|\n| RSI | 55 |"
83+
monkeypatch.setattr(missing_data, "_tool_registry", lambda: {"get_indicators": fake_tool})
84+
monkeypatch.setattr(
85+
missing_data,
86+
"sync_missing_snapshot_to_analysis_log",
87+
lambda ticker, date, **kwargs: None,
88+
)
89+
90+
result = missing_data.retry_missing_tasks("300476", "2026-06-03")
91+
92+
assert result["attempted"] == 1
93+
assert result["resolved_count"] == 1
94+
assert result["remaining_count"] == 0
95+
fake_tool.invoke.assert_called_once_with(task["args"])
96+
97+
all_tasks = missing_data.get_missing_tasks("300476", "2026-06-03", active_only=False)
98+
assert all_tasks[0]["status"] == "resolved"
99+
assert all_tasks[0]["retry_attempts"] == 1
100+
101+
102+
def test_retry_keeps_unresolved_task_when_still_missing(missing_index, monkeypatch):
103+
missing_data.record_tool_result(
104+
ticker="300476",
105+
trade_date="2026-06-03",
106+
stage="fundamentals",
107+
tool_name="get_profit_forecast",
108+
args={"ticker": "300476"},
109+
content="[数据缺失: 机构一致预期EPS]",
110+
)
111+
112+
fake_tool = MagicMock()
113+
fake_tool.invoke.return_value = "[数据缺失: 机构一致预期EPS]"
114+
monkeypatch.setattr(missing_data, "_tool_registry", lambda: {"get_profit_forecast": fake_tool})
115+
monkeypatch.setattr(
116+
missing_data,
117+
"sync_missing_snapshot_to_analysis_log",
118+
lambda ticker, date, **kwargs: None,
119+
)
120+
121+
result = missing_data.retry_missing_tasks("300476", "2026-06-03")
122+
123+
assert result["resolved_count"] == 0
124+
assert result["remaining_count"] == 1
125+
assert result["remaining"][0]["status"] == "active"
126+
assert result["remaining"][0]["retry_attempts"] == 1
127+
128+
129+
def test_snapshot_sync_updates_saved_report(missing_index, tmp_path, monkeypatch):
130+
log_path = tmp_path / "full_states_log_2026-06-03.json"
131+
log_path.write_text('{"final_trade_decision": "HOLD"}', encoding="utf-8")
132+
monkeypatch.setattr(missing_data, "analysis_log_path", lambda ticker, date: Path(log_path))
133+
134+
missing_data.record_tool_result(
135+
ticker="300476",
136+
trade_date="2026-06-03",
137+
stage="news",
138+
tool_name="get_news",
139+
args={"ticker": "300476", "start_date": "2026-05-27", "end_date": "2026-06-03"},
140+
content="Error fetching news for 300476: timeout",
141+
)
142+
143+
missing_data.sync_missing_snapshot_to_analysis_log("300476", "2026-06-03")
144+
145+
text = log_path.read_text(encoding="utf-8")
146+
assert '"missing_data_complete": false' in text
147+
assert '"tool_name": "get_news"' in text
148+
149+
150+
def test_snapshot_sync_preserves_reanalysis_flag(missing_index, tmp_path, monkeypatch):
151+
log_path = tmp_path / "full_states_log_2026-06-03.json"
152+
log_path.write_text(
153+
'{"missing_data_requires_reanalysis": true}', encoding="utf-8"
154+
)
155+
monkeypatch.setattr(
156+
missing_data, "analysis_log_path", lambda ticker, date: log_path
157+
)
158+
159+
missing_data.sync_missing_snapshot_to_analysis_log("300476", "2026-06-03")
160+
161+
saved = json.loads(log_path.read_text(encoding="utf-8"))
162+
assert saved["missing_data_requires_reanalysis"] is True
163+
164+
165+
def test_cached_retry_output_is_available_for_reanalysis(missing_index, monkeypatch):
166+
args = {"symbol": "300476", "curr_date": "2026-06-03", "look_back_days": 90}
167+
missing_data.record_tool_result(
168+
ticker="300476",
169+
trade_date="2026-06-03",
170+
stage="market",
171+
tool_name="get_indicators",
172+
args=args,
173+
content="Error retrieving technical summary for 300476: timeout",
174+
)
175+
176+
fake_tool = MagicMock()
177+
fake_tool.invoke.return_value = "补齐后的技术摘要"
178+
monkeypatch.setattr(missing_data, "_tool_registry", lambda: {"get_indicators": fake_tool})
179+
monkeypatch.setattr(
180+
missing_data,
181+
"sync_missing_snapshot_to_analysis_log",
182+
lambda ticker, date, **kwargs: None,
183+
)
184+
185+
missing_data.retry_missing_tasks("300476", "2026-06-03")
186+
187+
output = missing_data.cached_retry_output(
188+
ticker="300476",
189+
trade_date="2026-06-03",
190+
stage="market",
191+
tool_name="get_indicators",
192+
args=args,
193+
)
194+
195+
assert output == "补齐后的技术摘要"
196+
all_tasks = missing_data.get_missing_tasks("300476", "2026-06-03", active_only=False)
197+
assert all_tasks[0]["used_in_reanalysis_at"] is not None
198+
199+
200+
def test_consumed_resolved_task_removes_cached_output(missing_index, monkeypatch):
201+
args = {"symbol": "300476", "curr_date": "2026-06-03", "look_back_days": 90}
202+
task = missing_data.record_tool_result(
203+
ticker="300476",
204+
trade_date="2026-06-03",
205+
stage="market",
206+
tool_name="get_indicators",
207+
args=args,
208+
content="Error retrieving technical summary for 300476: timeout",
209+
)
210+
assert task is not None
211+
212+
fake_tool = MagicMock()
213+
fake_tool.invoke.return_value = "补齐后的技术摘要"
214+
monkeypatch.setattr(missing_data, "_tool_registry", lambda: {"get_indicators": fake_tool})
215+
monkeypatch.setattr(
216+
missing_data,
217+
"sync_missing_snapshot_to_analysis_log",
218+
lambda ticker, date, **kwargs: None,
219+
)
220+
221+
missing_data.retry_missing_tasks("300476", "2026-06-03")
222+
resolved = [
223+
task
224+
for task in missing_data.get_missing_tasks(
225+
"300476", "2026-06-03", active_only=False
226+
)
227+
if task["status"] == "resolved"
228+
]
229+
cache_path = Path(resolved[0]["resolved_output_path"])
230+
assert cache_path.exists()
231+
232+
assert (
233+
missing_data.cached_retry_output(
234+
ticker="300476",
235+
trade_date="2026-06-03",
236+
stage="market",
237+
tool_name="get_indicators",
238+
args=args,
239+
)
240+
== "补齐后的技术摘要"
241+
)
242+
missing_data.mark_resolved_tasks_consumed("300476", "2026-06-03")
243+
244+
assert not cache_path.exists()
245+
all_tasks = missing_data.get_missing_tasks("300476", "2026-06-03", active_only=False)
246+
assert all_tasks[0]["status"] == "consumed"
247+
assert (
248+
missing_data.cached_retry_output(
249+
ticker="300476",
250+
trade_date="2026-06-03",
251+
stage="market",
252+
tool_name="get_indicators",
253+
args=args,
254+
)
255+
is None
256+
)
257+
258+
259+
def test_unconsumed_retry_output_survives_unrelated_completed_run(
260+
missing_index, monkeypatch
261+
):
262+
args = {"symbol": "300476", "curr_date": "2026-06-03"}
263+
missing_data.record_tool_result(
264+
ticker="300476",
265+
trade_date="2026-06-03",
266+
stage="market",
267+
tool_name="get_indicators",
268+
args=args,
269+
content="Error fetching indicators",
270+
)
271+
fake_tool = MagicMock()
272+
fake_tool.invoke.return_value = "补齐后的技术摘要"
273+
monkeypatch.setattr(
274+
missing_data, "_tool_registry", lambda: {"get_indicators": fake_tool}
275+
)
276+
monkeypatch.setattr(
277+
missing_data,
278+
"sync_missing_snapshot_to_analysis_log",
279+
lambda ticker, date, **kwargs: None,
280+
)
281+
282+
missing_data.retry_missing_tasks("300476", "2026-06-03")
283+
missing_data.mark_resolved_tasks_consumed("300476", "2026-06-03")
284+
285+
tasks = missing_data.get_missing_tasks(
286+
"300476", "2026-06-03", active_only=False
287+
)
288+
assert tasks[0]["status"] == "resolved"
289+
assert Path(tasks[0]["resolved_output_path"]).exists()
290+
291+
292+
def test_fresh_run_removes_stale_active_and_consumed_tasks(missing_index):
293+
args = {"symbol": "300476"}
294+
task = missing_data.record_tool_result(
295+
ticker="300476",
296+
trade_date="2026-06-03",
297+
stage="market",
298+
tool_name="get_indicators",
299+
args=args,
300+
content="Error fetching indicators",
301+
)
302+
assert task is not None
303+
task["status"] = "consumed"
304+
missing_data._save_index([task])
305+
306+
missing_data.reset_missing_tasks_for_run("300476", "2026-06-03")
307+
308+
assert missing_data.get_missing_tasks(
309+
"300476", "2026-06-03", active_only=False
310+
) == []

0 commit comments

Comments
 (0)