rt_k 失败、无权限、超时或空结果时改用东财全市场快照,再失败则用腾讯批量行情;两者都失败仍不退回昨天。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
454 lines
18 KiB
Python
454 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import threading
|
|
import unittest
|
|
from datetime import date, datetime, timedelta, timezone, time as dt_time
|
|
from unittest.mock import patch
|
|
from pathlib import Path
|
|
|
|
from backend.features.market.service import MarketServiceMixin
|
|
from backend.jobs.refresh import (
|
|
dashboard_has_usable_data,
|
|
official_catchup_due,
|
|
verified_dashboard_result,
|
|
)
|
|
from backend.data.providers.tushare_transport import TushareError
|
|
|
|
|
|
class AdminRefreshStatusTests(unittest.TestCase):
|
|
def test_carried_snapshot_is_usable_not_failed_job(self):
|
|
result = verified_dashboard_result(
|
|
{
|
|
"meta": {
|
|
"trade_date": "2026-09-01",
|
|
"requested_date": "2026-09-02",
|
|
"carried_forward": True,
|
|
"notice": "今日数据正在准备,当前展示 9 月 1 日",
|
|
"data_status": "preparing",
|
|
},
|
|
"overview": {"limit_up_count": 12},
|
|
}
|
|
)
|
|
|
|
self.assertNotEqual(result.get("status"), "failed")
|
|
self.assertEqual(result["meta"]["data_status"], "preparing")
|
|
self.assertTrue(dashboard_has_usable_data(result))
|
|
|
|
def test_derived_limit_snapshot_is_usable_not_failed_job(self):
|
|
dashboard = {
|
|
"meta": {
|
|
"trade_date": "2026-09-02",
|
|
"limit_data_source": "derived",
|
|
"notice": "涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
|
|
"data_status": "partial",
|
|
},
|
|
"overview": {"limit_up_count": 8},
|
|
}
|
|
|
|
self.assertIs(verified_dashboard_result(dashboard), dashboard)
|
|
|
|
def test_current_snapshot_is_reported_as_successful_job(self):
|
|
dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}}
|
|
|
|
self.assertIs(verified_dashboard_result(dashboard), dashboard)
|
|
|
|
def test_empty_payload_is_still_failed(self):
|
|
result = verified_dashboard_result({"meta": {}, "overview": {}})
|
|
self.assertEqual(result["status"], "failed")
|
|
|
|
|
|
class FakeSyncDatabase:
|
|
def __init__(self, latest=None):
|
|
self.latest = latest
|
|
self.saved = []
|
|
self.finished = []
|
|
|
|
def start_sync(self, *_args, **_kwargs):
|
|
return 1
|
|
|
|
def save_snapshot(self, trade_date, source, payload):
|
|
self.saved.append((trade_date, source, copy.deepcopy(payload)))
|
|
|
|
def save_data_snapshot(self, *_args, **_kwargs):
|
|
return None
|
|
|
|
def finish_sync(self, *args, **kwargs):
|
|
self.finished.append((args, kwargs))
|
|
|
|
def get_latest_real_snapshot(self, *_args, **_kwargs):
|
|
return copy.deepcopy(self.latest)
|
|
|
|
def get_snapshot(self, *_args, **_kwargs):
|
|
return None
|
|
|
|
def get_data_snapshot(self, *_args, **_kwargs):
|
|
return None
|
|
|
|
def reason_overrides(self, *_args, **_kwargs):
|
|
return {}
|
|
|
|
|
|
class FakeDerivedClient:
|
|
def dashboard(self, trade_date: str):
|
|
return {
|
|
"meta": {
|
|
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
"limit_data_source": "derived",
|
|
"notice": "涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
|
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
},
|
|
"overview": {"limit_up_count": 3},
|
|
"limits": [{"code": "000001"}],
|
|
"broken": [],
|
|
"down_limits": [],
|
|
"yesterday_limits": [],
|
|
}
|
|
|
|
|
|
SHANGHAI = timezone(timedelta(hours=8))
|
|
TRADE_DAY = date(2026, 9, 8)
|
|
|
|
|
|
def at_clock(hour: int, minute: int, day: date = TRADE_DAY) -> datetime:
|
|
return datetime(day.year, day.month, day.day, hour, minute, tzinfo=SHANGHAI)
|
|
|
|
|
|
class FakeMissingDailyClient:
|
|
def __init__(self, open_today: bool = True):
|
|
self.open_today = open_today
|
|
|
|
def dashboard(self, trade_date: str):
|
|
raise TushareError(f"No daily data returned for {trade_date}")
|
|
|
|
def resolve_trade_context(self, requested: str):
|
|
if self.open_today:
|
|
return requested, "20260907"
|
|
return "20260907", "20260904"
|
|
|
|
|
|
class FakeRealtimeTodayClient:
|
|
def dashboard(self, trade_date: str):
|
|
return {
|
|
"meta": {
|
|
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
"requested_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
"realtime": True,
|
|
"mode": "realtime",
|
|
"market_status": "trading",
|
|
"notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。",
|
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
},
|
|
"overview": {"limit_up_count": 15},
|
|
"limits": [{"code": "000001"}],
|
|
"broken": [],
|
|
"down_limits": [],
|
|
"yesterday_limits": [],
|
|
}
|
|
|
|
def resolve_trade_context(self, requested: str):
|
|
return requested, "20260907"
|
|
|
|
|
|
class FakeFreeRealtimeTodayClient:
|
|
def dashboard(self, trade_date: str):
|
|
return {
|
|
"meta": {
|
|
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
"requested_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
|
"realtime": True,
|
|
"mode": "realtime",
|
|
"quote_source": "eastmoney_clist",
|
|
"source": "eastmoney",
|
|
"market_status": "trading",
|
|
"notice": "盘中行情由东财免费实时快照计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。",
|
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
"indices": [{"code": "000001", "price": 3800.1, "change": 0.5}],
|
|
},
|
|
"overview": {"limit_up_count": 18, "up_count": 2100, "amount_billion": 12345.6},
|
|
"limits": [{"code": "000001"}],
|
|
"broken": [],
|
|
"down_limits": [],
|
|
"yesterday_limits": [],
|
|
}
|
|
|
|
def resolve_trade_context(self, requested: str):
|
|
return requested, "20260907"
|
|
|
|
|
|
class SyncHarness(MarketServiceMixin):
|
|
def __init__(self, client, latest=None, clock=None):
|
|
self.configured = True
|
|
self.sync_lock = threading.Lock()
|
|
self.database = FakeSyncDatabase(latest)
|
|
self._client = client
|
|
self.current_user_id = 1
|
|
self.clock = clock
|
|
|
|
def _tushare_client(self):
|
|
return self._client
|
|
|
|
def _enrich_dashboard_sentiment(self, dashboard, _trade_date):
|
|
return dashboard
|
|
|
|
def _apply_reason_overrides(self, dashboard):
|
|
return dashboard
|
|
|
|
|
|
class DashboardFreshnessTests(unittest.TestCase):
|
|
def test_derived_limits_are_kept_as_partial_success(self):
|
|
today = date.today().strftime("%Y%m%d")
|
|
harness = SyncHarness(FakeDerivedClient())
|
|
payload = harness.sync_dashboard(today)
|
|
meta = payload["meta"]
|
|
|
|
self.assertEqual(meta["limit_data_source"], "derived")
|
|
self.assertEqual(meta["data_status"], "partial")
|
|
self.assertFalse(meta.get("carried_forward"))
|
|
self.assertIn("日线数据推算", meta["display_notice"])
|
|
self.assertEqual(harness.database.finished[0][0][1], "success")
|
|
self.assertEqual(verified_dashboard_result(payload), payload)
|
|
|
|
def test_intraday_refresh_keeps_today_and_does_not_fall_back_to_yesterday(self):
|
|
today = TRADE_DAY.strftime("%Y%m%d")
|
|
latest = {
|
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
|
"overview": {"limit_up_count": 20},
|
|
}
|
|
harness = SyncHarness(
|
|
FakeRealtimeTodayClient(),
|
|
latest,
|
|
clock=lambda: at_clock(10, 5),
|
|
)
|
|
payload = harness.sync_dashboard(today)
|
|
meta = payload["meta"]
|
|
|
|
self.assertFalse(meta.get("carried_forward"))
|
|
self.assertTrue(meta["realtime"])
|
|
self.assertEqual(meta["data_status"], "intraday")
|
|
self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
|
|
self.assertNotIn("今日数据正在准备", meta.get("display_notice") or "")
|
|
self.assertEqual(harness.database.saved[0][0], today)
|
|
|
|
def test_intraday_free_source_keeps_today_and_indices(self):
|
|
today = TRADE_DAY.strftime("%Y%m%d")
|
|
latest = {
|
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
|
"overview": {"limit_up_count": 20},
|
|
}
|
|
harness = SyncHarness(
|
|
FakeFreeRealtimeTodayClient(),
|
|
latest,
|
|
clock=lambda: at_clock(10, 5),
|
|
)
|
|
payload = harness.sync_dashboard(today)
|
|
meta = payload["meta"]
|
|
self.assertFalse(meta.get("carried_forward"))
|
|
self.assertTrue(meta["realtime"])
|
|
self.assertEqual(meta["data_status"], "intraday")
|
|
self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
|
|
self.assertEqual(meta["quote_source"], "eastmoney_clist")
|
|
self.assertEqual(payload["overview"]["amount_billion"], 12345.6)
|
|
self.assertEqual(meta["indices"][0]["price"], 3800.1)
|
|
self.assertEqual(harness.database.saved[0][0], today)
|
|
|
|
def test_intraday_missing_quotes_do_not_carry_yesterday(self):
|
|
today = TRADE_DAY.strftime("%Y%m%d")
|
|
latest = {
|
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
|
"overview": {"limit_up_count": 20},
|
|
}
|
|
harness = SyncHarness(
|
|
FakeMissingDailyClient(),
|
|
latest,
|
|
clock=lambda: at_clock(10, 5),
|
|
)
|
|
with self.assertRaises(ValueError) as ctx:
|
|
harness.sync_dashboard(today)
|
|
self.assertIn("当天盘中行情", str(ctx.exception))
|
|
self.assertFalse(harness.database.saved)
|
|
|
|
def test_intraday_keeps_existing_today_snapshot_when_refresh_fails(self):
|
|
today = TRADE_DAY.strftime("%Y%m%d")
|
|
existing = {
|
|
"meta": {
|
|
"trade_date": "2026-09-08",
|
|
"realtime": True,
|
|
"mode": "realtime",
|
|
"source": "tushare",
|
|
},
|
|
"overview": {"limit_up_count": 11},
|
|
"limits": [{"code": "600000"}],
|
|
"broken": [],
|
|
"down_limits": [],
|
|
"yesterday_limits": [],
|
|
}
|
|
harness = SyncHarness(
|
|
FakeMissingDailyClient(),
|
|
clock=lambda: at_clock(10, 5),
|
|
)
|
|
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(existing)
|
|
payload = harness.sync_dashboard(today)
|
|
meta = payload["meta"]
|
|
self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
|
|
self.assertTrue(meta["realtime"])
|
|
self.assertEqual(meta["data_status"], "intraday")
|
|
self.assertFalse(meta.get("carried_forward"))
|
|
|
|
def test_lunch_and_after_hours_keep_today_until_official_arrives(self):
|
|
today = TRADE_DAY.strftime("%Y%m%d")
|
|
for clock in (lambda: at_clock(12, 0), lambda: at_clock(16, 10)):
|
|
harness = SyncHarness(
|
|
FakeRealtimeTodayClient(),
|
|
clock=clock,
|
|
)
|
|
payload = harness.sync_dashboard(today)
|
|
self.assertEqual(str(payload["meta"]["trade_date"]).replace("-", ""), today)
|
|
self.assertFalse(payload["meta"].get("carried_forward"))
|
|
|
|
def test_preopen_and_weekend_still_carry_last_session(self):
|
|
latest = {
|
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
|
"overview": {"limit_up_count": 20},
|
|
}
|
|
preopen = SyncHarness(
|
|
FakeMissingDailyClient(),
|
|
latest,
|
|
clock=lambda: at_clock(8, 30),
|
|
)
|
|
preopen_payload = preopen.sync_dashboard(TRADE_DAY.strftime("%Y%m%d"))
|
|
self.assertTrue(preopen_payload["meta"]["carried_forward"])
|
|
self.assertEqual(preopen_payload["meta"]["data_status"], "preparing")
|
|
self.assertIn("今日数据正在准备,当前展示", preopen_payload["meta"]["display_notice"])
|
|
|
|
weekend = SyncHarness(
|
|
FakeMissingDailyClient(open_today=False),
|
|
latest,
|
|
clock=lambda: at_clock(10, 5, date(2026, 9, 5)),
|
|
)
|
|
weekend_payload = weekend.sync_dashboard("20260905")
|
|
self.assertTrue(weekend_payload["meta"]["carried_forward"])
|
|
|
|
def test_history_date_still_uses_official_or_preparing_notice(self):
|
|
latest = {
|
|
"meta": {"trade_date": "2026-09-01", "source": "tushare"},
|
|
"overview": {"limit_up_count": 8},
|
|
}
|
|
harness = SyncHarness(
|
|
FakeMissingDailyClient(),
|
|
latest,
|
|
clock=lambda: at_clock(10, 5),
|
|
)
|
|
payload = harness.sync_dashboard("20260902")
|
|
self.assertTrue(payload["meta"]["carried_forward"])
|
|
self.assertIn("所选日期数据尚未到齐", payload["meta"]["display_notice"])
|
|
|
|
def test_carried_today_snapshot_is_retried_immediately_in_session(self):
|
|
today = TRADE_DAY.strftime("%Y%m%d")
|
|
snapshot = {
|
|
"meta": {
|
|
"source": "tushare",
|
|
"trade_date": "2026-09-07",
|
|
"carried_forward": True,
|
|
"requested_date": "2026-09-08",
|
|
"updated_at": at_clock(10, 0).isoformat(),
|
|
},
|
|
"overview": {"limit_up_count": 1},
|
|
}
|
|
harness = SyncHarness(
|
|
FakeRealtimeTodayClient(),
|
|
clock=lambda: at_clock(10, 5),
|
|
)
|
|
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(snapshot)
|
|
payload = harness.get_dashboard(today)
|
|
self.assertEqual(str(payload["meta"]["trade_date"]).replace("-", ""), today)
|
|
self.assertEqual(payload["meta"]["data_status"], "intraday")
|
|
self.assertTrue(harness.database.saved)
|
|
|
|
def test_weekend_carry_is_not_labeled_as_preparing(self):
|
|
snapshot = {
|
|
"meta": {"trade_date": "2026-07-24", "source": "tushare", "updated_at": "2026-07-24T15:00:00+08:00"},
|
|
"overview": {"limit_up_count": 1},
|
|
}
|
|
harness = SyncHarness(FakeMissingDailyClient())
|
|
carried = harness._carry_dashboard(snapshot, "20260725", "非交易日沿用最近交易日收盘行情")
|
|
self.assertEqual(carried["meta"]["data_status"], "carried")
|
|
self.assertIn("非交易日", carried["meta"]["display_notice"])
|
|
|
|
def test_stale_derived_snapshot_is_retried(self):
|
|
today = date.today().strftime("%Y%m%d")
|
|
old = datetime.now(timezone.utc) - timedelta(minutes=5)
|
|
snapshot = {
|
|
"meta": {
|
|
"source": "tushare",
|
|
"trade_date": f"{today[:4]}-{today[4:6]}-{today[6:8]}",
|
|
"limit_data_source": "derived",
|
|
"updated_at": old.isoformat(),
|
|
},
|
|
"overview": {"limit_up_count": 1},
|
|
}
|
|
harness = SyncHarness(FakeDerivedClient())
|
|
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(snapshot)
|
|
payload = harness.get_dashboard(today)
|
|
self.assertEqual(payload["meta"]["data_status"], "partial")
|
|
self.assertTrue(harness.database.saved)
|
|
|
|
def test_official_catchup_skips_complete_today_snapshot(self):
|
|
today = date.today().strftime("%Y%m%d")
|
|
iso = f"{today[:4]}-{today[4:6]}-{today[6:8]}"
|
|
due = official_catchup_due(
|
|
today,
|
|
{"meta": {"trade_date": iso, "limit_data_source": "official"}},
|
|
)
|
|
derived_due = official_catchup_due(
|
|
today,
|
|
{"meta": {"trade_date": iso, "limit_data_source": "derived"}},
|
|
)
|
|
now = datetime.now().astimezone().time().replace(tzinfo=None)
|
|
if dt_time(15, 5) <= now < dt_time(22, 0):
|
|
self.assertFalse(due)
|
|
self.assertTrue(derived_due)
|
|
else:
|
|
self.assertFalse(due)
|
|
self.assertFalse(derived_due)
|
|
|
|
def test_official_catchup_is_due_for_intraday_snapshot_after_close(self):
|
|
today = TRADE_DAY.strftime("%Y%m%d")
|
|
snapshot = {
|
|
"meta": {
|
|
"trade_date": "2026-09-08",
|
|
"realtime": True,
|
|
"mode": "realtime",
|
|
}
|
|
}
|
|
with patch("backend.jobs.refresh.datetime") as mocked:
|
|
mocked.now.return_value = at_clock(16, 10)
|
|
mocked.strptime = datetime.strptime
|
|
self.assertTrue(official_catchup_due(today, snapshot))
|
|
official = {
|
|
"meta": {
|
|
"trade_date": "2026-09-08",
|
|
"limit_data_source": "official",
|
|
"realtime": False,
|
|
}
|
|
}
|
|
self.assertFalse(official_catchup_due(today, official))
|
|
|
|
|
|
class FrontendRefreshCopyTests(unittest.TestCase):
|
|
def test_dashboard_script_distinguishes_partial_from_failure(self):
|
|
script = (Path(__file__).resolve().parents[1] / "frontend" / "shared" / "dashboard.js").read_text(encoding="utf-8")
|
|
self.assertIn("今日数据正在准备,当前展示", script)
|
|
self.assertIn("部分正式数据尚未到齐", script)
|
|
self.assertIn("盘中行情", script)
|
|
self.assertIn("meta.realtime && actualCompact === requestedCompact", script)
|
|
self.assertIn('job.status === "failed"', script)
|
|
failed_block = script.split("if (job.status === \"failed\")", 1)[1].split("const query", 1)[0]
|
|
self.assertIn("后台刷新失败", failed_block)
|
|
success_block = script.split("const freshness = dashboardFreshnessMessage(meta);", 1)[1]
|
|
self.assertNotIn("后台刷新失败", success_block.split("} else {", 1)[0])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|