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