from __future__ import annotations import threading import unittest from datetime import datetime, timedelta from unittest.mock import patch from backend.data.providers.tushare_client import TushareError from backend.data.realtime import RealtimeAggregateError from backend.features.market.charts import ChartDataError from backend.features.market.service import TODAY_DAILY_UNAVAILABLE_NOTICE from server import DashboardService class DetailDatabaseStub: @staticmethod def list_watchlist(user_id): return [] @staticmethod def list_notes(user_id, code=""): return [] @staticmethod def get_snapshot(trade_date): return {} class RealtimeClientStub: quote_calls = 0 def __init__(self, token): self.token = token @staticmethod def resolve_trade_context(requested_date): return requested_date, requested_date @classmethod def realtime_stock_quote(cls, ts_code, reference_date=""): cls.quote_calls += 1 return { "name": "测试股票", "sector": "测试行业", "price": 9.8, "change": -2.0, "open": 10.1, "high": 10.2, "low": 9.7, "volume": 123400, "amount_billion": 1.25, "turnover_rate": 3.5, } class DataGatewayStub: def __init__(self, client): self.client = client def tushare(self): return self.client @staticmethod def datahub_status(): return {"configured": True} class FixedMarketDatetime(datetime): fixed_now = datetime(2026, 7, 31, 10, 30).astimezone() @classmethod def now(cls, tz=None): return cls.fixed_now class FixedPreopenDatetime(datetime): fixed_now = datetime(2026, 7, 31, 8, 45).astimezone() @classmethod def now(cls, tz=None): return cls.fixed_now class FixedLunchDatetime(datetime): fixed_now = datetime(2026, 7, 31, 11, 45).astimezone() @classmethod def now(cls, tz=None): return cls.fixed_now class FixedAfterCloseDatetime(datetime): fixed_now = datetime(2026, 7, 31, 15, 30).astimezone() @classmethod def now(cls, tz=None): return cls.fixed_now class DeniedRealtimeClientStub: quote_calls = 0 def __init__(self, token): self.token = token @staticmethod def resolve_trade_context(requested_date): return requested_date, requested_date @classmethod def realtime_stock_quote(cls, ts_code, reference_date=""): cls.quote_calls += 1 raise TushareError("没有接口访问权限") class FreeQuoteAggregator: def __init__(self, quote=None, fail=False): self.quote = quote self.fail = fail self.tencent_calls = 0 self.eastmoney_calls = 0 def tencent_stock_quote(self, code, expected_date=""): self.tencent_calls += 1 if self.fail: raise RealtimeAggregateError("tencent down") if self.quote and self.quote.get("source") == "eastmoney_stock": raise RealtimeAggregateError("tencent empty") if self.quote: return self.quote raise RealtimeAggregateError("tencent empty") def eastmoney_stock_quote(self, code, expected_date=""): self.eastmoney_calls += 1 if self.fail: raise RealtimeAggregateError("eastmoney down") if self.quote and self.quote.get("source") == "eastmoney_stock": return self.quote raise RealtimeAggregateError("eastmoney empty") class IntradayChartStub: def __init__(self, points, previous_close=10.0, trade_date="2026-07-31"): self.points = points self.previous_close = previous_close self.trade_date = trade_date def stock_daily(self, code, end_date, limit=90): raise ChartDataError("iFinD daily unavailable") def stock_intraday(self, code): return { "trade_date": self.trade_date, "previous_close": self.previous_close, "points": self.points, } def _history_payload(code="002141"): yesterday = (FixedMarketDatetime.fixed_now - timedelta(days=1)).strftime("%Y-%m-%d") return { "meta": {"trade_date": yesterday, "source": "tushare"}, "stock": {"code": code, "name": "旧名称", "price": 10, "change": 7.1}, "prices": [ { "trade_date": yesterday, "open": 9.5, "high": 10.1, "low": 9.4, "close": 10, "change": 7.1, "volume": 100, "amount_billion": 1.1, } ], "moneyflow": {}, } def _free_quote(source="tencent_qt", **overrides): quote = { "ts_code": "002141.SZ", "name": "贤程科技", "pre_close": 10.0, "open": 10.2, "high": 10.8, "low": 10.1, "close": 10.6, "vol": 250000, "amount": 26_500_000, "quote_date": "20260731", "quote_time_epoch": int(datetime(2026, 7, 31, 10, 31).timestamp()), "source": source, "turnover_rate": 2.5, } quote.update(overrides) return quote class StockDetailRealtimeTests(unittest.TestCase): def setUp(self): self.service = DashboardService.__new__(DashboardService) self.service._system_credentials = {"tushare_token": "test-token"} self.service.database = DetailDatabaseStub() self.service._request_context = threading.local() self.service._request_context.user_id = 1 self.service.ifind = None self.service.realtime_aggregator = None self.service.chart_data = None self.service._market_client_override = RealtimeClientStub("datahub") self.service.data_gateway = DataGatewayStub(self.service._market_client_override) RealtimeClientStub.quote_calls = 0 DeniedRealtimeClientStub.quote_calls = 0 def test_today_detail_merges_rt_quote_without_mutating_daily_cache(self): today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d") yesterday = (FixedMarketDatetime.fixed_now - timedelta(days=1)).strftime("%Y-%m-%d") cached = { "meta": {"trade_date": today, "source": "tushare"}, "stock": {"code": "002141", "name": "旧名称", "price": 10, "change": 7.1}, "prices": [ { "trade_date": yesterday, "open": 9.5, "high": 10.1, "low": 9.4, "close": 10, "change": 7.1, "volume": 100, } ], "moneyflow": {}, } with patch("backend.features.market.service.datetime", FixedMarketDatetime): result = self.service._prepare_stock_detail(cached, "002141", today) self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d")) self.assertTrue(result["meta"]["realtime"]) self.assertEqual(result["stock"]["price"], 9.8) self.assertEqual(result["stock"]["change"], -2.0) self.assertEqual(result["prices"][-1]["change"], -2.0) self.assertEqual(result["prices"][-1]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d")) self.assertEqual(cached["stock"]["change"], 7.1) self.assertEqual(len(cached["prices"]), 1) self.assertEqual(RealtimeClientStub.quote_calls, 1) def test_historical_detail_never_requests_realtime_quote(self): historical = (FixedMarketDatetime.fixed_now - timedelta(days=5)).strftime("%Y%m%d") payload = { "meta": {"trade_date": historical, "source": "tushare"}, "stock": {"code": "002141", "price": 10, "change": 1.2}, "prices": [{"trade_date": historical, "close": 10, "change": 1.2}], } with patch("backend.features.market.service.datetime", FixedMarketDatetime): result = self.service._prepare_stock_detail(payload, "002141", historical) self.assertEqual(result["stock"]["change"], 1.2) self.assertFalse(result["meta"].get("realtime", False)) self.assertEqual(RealtimeClientStub.quote_calls, 0) def test_today_detail_keeps_last_traded_bar_before_market_open(self): today = FixedPreopenDatetime.fixed_now.strftime("%Y%m%d") today_display = FixedPreopenDatetime.fixed_now.strftime("%Y-%m-%d") yesterday = (FixedPreopenDatetime.fixed_now - timedelta(days=1)).strftime("%Y-%m-%d") payload = { "meta": {"trade_date": yesterday, "source": "tushare"}, "stock": {"code": "002141", "price": 10, "change": 0}, "prices": [ { "trade_date": yesterday, "open": 9.8, "high": 10.1, "low": 9.7, "close": 10, "change": 1.2, "volume": 100, }, { "trade_date": today_display, "open": 10, "high": 10, "low": 10, "close": 10, "change": 0, "volume": 0, "amount_billion": 0, "realtime": True, }, ], } with patch("backend.features.market.service.datetime", FixedPreopenDatetime): result = self.service._prepare_stock_detail(payload, "002141", today) self.assertEqual(result["meta"]["trade_date"], yesterday) self.assertFalse(result["meta"].get("realtime", False)) self.assertEqual(result["prices"][-1]["trade_date"], yesterday) self.assertEqual(result["stock"]["change"], 1.2) self.assertEqual(RealtimeClientStub.quote_calls, 0) def test_today_detail_falls_back_to_tencent_quote_when_rt_k_denied(self): today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d") aggregator = FreeQuoteAggregator(_free_quote()) self.service.realtime_aggregator = aggregator self.service._market_client_override = DeniedRealtimeClientStub("datahub") DeniedRealtimeClientStub.quote_calls = 0 with patch("backend.features.market.service.datetime", FixedMarketDatetime): result = self.service._prepare_stock_detail(_history_payload(), "002141", today) bar = result["prices"][-1] self.assertEqual(bar["trade_date"], "2026-07-31") self.assertTrue(bar["realtime"]) self.assertEqual(bar["open"], 10.2) self.assertEqual(bar["high"], 10.8) self.assertEqual(bar["low"], 10.1) self.assertEqual(bar["close"], 10.6) self.assertAlmostEqual(bar["change"], 6.0, places=4) self.assertEqual(bar["volume"], 2500) self.assertAlmostEqual(bar["amount_billion"], 0.265) self.assertEqual(len(result["prices"]), 2) self.assertEqual(result["meta"]["notice"], "") self.assertEqual(aggregator.tencent_calls, 1) self.assertEqual(DeniedRealtimeClientStub.quote_calls, 1) def test_today_detail_falls_back_to_eastmoney_then_intraday(self): today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d") aggregator = FreeQuoteAggregator( _free_quote( "eastmoney_stock", ts_code="600000.SH", name="浦发银行", net_mf_amount=12, ), ) self.service.realtime_aggregator = aggregator self.service._market_client_override = DeniedRealtimeClientStub("datahub") DeniedRealtimeClientStub.quote_calls = 0 with patch("backend.features.market.service.datetime", FixedMarketDatetime): result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today) self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31") self.assertEqual(result["prices"][-1]["close"], 10.6) self.assertEqual(aggregator.tencent_calls, 1) self.assertEqual(aggregator.eastmoney_calls, 1) aggregator = FreeQuoteAggregator(fail=True) self.service.realtime_aggregator = aggregator self.service.chart_data = IntradayChartStub( [ { "date": "2026-07-31", "time": "09:30", "open": 10.1, "high": 10.2, "low": 10.0, "close": 10.15, "volume": 120, "amount": 121800, }, { "date": "2026-07-31", "time": "10:05", "open": 10.15, "high": 10.5, "low": 9.9, "close": 10.4, "volume": 80, "amount": 83200, }, ] ) with patch("backend.features.market.service.datetime", FixedMarketDatetime): result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today) bar = result["prices"][-1] self.assertEqual(bar["trade_date"], "2026-07-31") self.assertEqual(bar["open"], 10.1) self.assertEqual(bar["high"], 10.5) self.assertEqual(bar["low"], 9.9) self.assertEqual(bar["close"], 10.4) self.assertAlmostEqual(bar["change"], 4.0, places=4) self.assertEqual(bar["volume"], 200) self.assertTrue(bar["realtime"]) def test_today_detail_keeps_history_when_free_sources_fail(self): today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d") self.service.realtime_aggregator = FreeQuoteAggregator(fail=True) self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30") self.service._market_client_override = DeniedRealtimeClientStub("datahub") DeniedRealtimeClientStub.quote_calls = 0 with patch("backend.features.market.service.datetime", FixedMarketDatetime): result = self.service._prepare_stock_detail(_history_payload(), "002141", today) self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30") self.assertFalse(result["meta"].get("realtime", False)) self.assertEqual(result["meta"]["notice"], TODAY_DAILY_UNAVAILABLE_NOTICE) self.assertEqual(len(result["prices"]), 1) def test_lunch_keeps_morning_realtime_bar(self): today = FixedLunchDatetime.fixed_now.strftime("%Y%m%d") self.service.realtime_aggregator = FreeQuoteAggregator( _free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp())) ) self.service._market_client_override = DeniedRealtimeClientStub("datahub") DeniedRealtimeClientStub.quote_calls = 0 with patch("backend.features.market.service.datetime", FixedLunchDatetime): result = self.service._prepare_stock_detail(_history_payload(), "002141", today) self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31") self.assertTrue(result["meta"]["realtime"]) def test_after_close_keeps_forming_bar_until_official_ready(self): today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d") self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote()) self.service._market_client_override = DeniedRealtimeClientStub("datahub") DeniedRealtimeClientStub.quote_calls = 0 with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime): forming = self.service._prepare_stock_detail(_history_payload(), "002141", today) self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31") self.assertTrue(forming["prices"][-1]["realtime"]) official = _history_payload() official["prices"].append( { "trade_date": "2026-07-31", "open": 10.15, "high": 10.9, "low": 10.05, "close": 10.7, "change": 7.0, "volume": 1800, "amount_billion": 0.3, } ) RealtimeClientStub.quote_calls = 0 self.service._market_client_override = RealtimeClientStub("datahub") with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime): replaced = self.service._prepare_stock_detail(official, "002141", today) self.assertEqual(replaced["prices"][-1]["close"], 10.7) self.assertFalse(replaced["prices"][-1].get("realtime", False)) self.assertEqual(len(replaced["prices"]), 2) self.assertEqual(RealtimeClientStub.quote_calls, 0) def test_same_date_bar_is_replaced_not_duplicated(self): today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d") payload = _history_payload() payload["prices"].append( { "trade_date": "2026-07-31", "open": 10.0, "high": 10.1, "low": 9.9, "close": 10.05, "change": 0.5, "volume": 10, "amount_billion": 0.01, "realtime": True, } ) self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote()) self.service._market_client_override = DeniedRealtimeClientStub("datahub") DeniedRealtimeClientStub.quote_calls = 0 with patch("backend.features.market.service.datetime", FixedMarketDatetime): result = self.service._prepare_stock_detail(payload, "002141", today) self.assertEqual(len(result["prices"]), 2) self.assertEqual(result["prices"][-1]["close"], 10.6) self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31") if __name__ == "__main__": unittest.main()