diff --git a/backend/data/realtime.py b/backend/data/realtime.py index 7222571..3c7d533 100644 --- a/backend/data/realtime.py +++ b/backend/data/realtime.py @@ -19,6 +19,8 @@ class RealtimeAggregateError(RuntimeError): EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get" +EASTMONEY_STOCK_URL = "https://push2.eastmoney.com/api/qt/stock/get" +EASTMONEY_STOCK_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f60,f86,f168" EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get" EASTMONEY_A_SHARE_BOARDS = ( "m:0+t:6", @@ -283,6 +285,42 @@ class WebRealtimeAggregator: ) return self._filter_quotes_by_date(quotes, expected_date) + def tencent_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]: + symbol, _secid, ts_code = _a_share_identity(code) + raw, _cache_age = self._get_text( + f"{TENCENT_QUOTE_URL}{symbol}", + referer="https://gu.qq.com/", + encoding="gb18030", + ) + quote = next( + ( + item + for line in raw.splitlines() + if (item := _parse_tencent_stock_quote(line)) + ), + None, + ) + if not quote: + raise RealtimeAggregateError(f"Tencent stock quote unavailable for {ts_code}") + return _require_quote_date(quote, expected_date) + + def eastmoney_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]: + _symbol, secid, ts_code = _a_share_identity(code) + payload = self._get_json( + EASTMONEY_STOCK_URL, + { + "secid": secid, + "invt": "2", + "fltt": "2", + "fields": EASTMONEY_STOCK_FIELDS, + }, + referer="https://quote.eastmoney.com/", + ) + quote = _normalize_eastmoney_stock_quote(payload.get("data") or {}, ts_code) + if not quote: + raise RealtimeAggregateError(f"Eastmoney stock quote unavailable for {ts_code}") + return _require_quote_date(quote, expected_date) + def tencent_indices(self) -> list[dict[str, Any]]: raw, cache_age = self._get_text( TENCENT_INDEX_URL, @@ -553,6 +591,55 @@ def _diff_rows(data: dict[str, Any]) -> list[dict[str, Any]]: return [row for row in diff if isinstance(row, dict)] +def _a_share_identity(code: str) -> tuple[str, str, str]: + raw = str(code or "").strip().upper() + symbol = raw.split(".")[0] + if not symbol.isdigit() or len(symbol) != 6: + raise RealtimeAggregateError("Invalid stock code") + if raw.endswith(".SH") or symbol.startswith(("5", "6", "9")): + return f"sh{symbol}", f"1.{symbol}", f"{symbol}.SH" + if raw.endswith(".BJ") or symbol.startswith(("4", "8")): + return f"bj{symbol}", f"0.{symbol}", f"{symbol}.BJ" + return f"sz{symbol}", f"0.{symbol}", f"{symbol}.SZ" + + +def _require_quote_date(quote: dict[str, Any], expected_date: str) -> dict[str, Any]: + want = str(expected_date or "").replace("-", "") + got = str(quote.get("quote_date") or "") + if want and got != want: + raise RealtimeAggregateError(f"quote date {got or 'empty'} is not {want}") + return quote + + +def _normalize_eastmoney_stock_quote( + row: dict[str, Any], ts_code: str +) -> dict[str, Any] | None: + close = _number(row.get("f43")) + previous_close = _number(row.get("f60")) + if close <= 0 or previous_close <= 0: + return None + epoch = int(_number(row.get("f86"))) + quote_date = "" + if epoch > 0: + quote_date = datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") + return { + "ts_code": ts_code, + "name": row.get("f58") or ts_code.split(".")[0], + "pre_close": previous_close, + "open": _number(row.get("f46")), + "high": _number(row.get("f44")), + "low": _number(row.get("f45")), + "close": close, + "vol": _number(row.get("f47")) * 100, + "amount": _number(row.get("f48")), + "num": 0, + "quote_date": quote_date, + "quote_time_epoch": epoch, + "turnover_rate": _number(row.get("f168")), + "source": "eastmoney_stock", + } + + def _parse_tencent_stock_quote(line: str) -> dict[str, Any] | None: if '="' not in line: return None diff --git a/backend/features/market/service.py b/backend/features/market/service.py index 07c9385..23041d2 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -15,6 +15,7 @@ from backend.bootstrap.config import ( ) from backend.data.providers.ifind_client import IfindError from backend.data.providers.tushare_client import TushareClient, TushareError +from backend.data.realtime import RealtimeAggregateError from backend.features.market.backfill_history import ( DEFAULT_RECENT_TRADING_DAYS, MAX_RANGE_TRADING_DAYS, @@ -42,6 +43,7 @@ SEARCH_TYPE_LABELS = { "theme": "题材", "index": "指数", } +TODAY_DAILY_UNAVAILABLE_NOTICE = "今日日K暂不可用,仍显示最近收盘K线。" THS_SEARCH_TYPES = { "I": ("sector", "行业板块"), "R": ("sector", "地域板块"), @@ -814,26 +816,27 @@ class MarketServiceMixin: "trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}", } today = now.strftime("%Y%m%d") + latest_bar = (result.get("prices") or [{}])[-1] if result.get("prices") else {} + official_today = ( + actual_date == today and not bool(latest_bar.get("realtime")) + ) + after_close = now.time().replace(tzinfo=None) >= dt_time(15, 0) should_merge = ( requested_date == today and actual_date <= today and now.weekday() < 5 and now.time().replace(tzinfo=None) >= dt_time(9, 30) + and not (official_today and after_close) ) if should_merge: - quote = self._ifind_realtime_stock_quote(code) + quote = self._resolve_today_daily_quote(code, today, result) if quote and self._valid_realtime_stock_quote(quote, today): self._merge_realtime_stock_detail(result, quote, requested_date) - elif self.configured and actual_date < today: - client = self._tushare_client() - try: - resolved_date, _ = client.resolve_trade_context(requested_date) - if resolved_date == today: - quote = client.realtime_stock_quote(tushare_code(code), requested_date) - if self._valid_realtime_stock_quote(quote, today): - self._merge_realtime_stock_detail(result, quote, requested_date) - except TushareError: - pass + elif actual_date < today: + result["meta"] = { + **(result.get("meta") or {}), + "notice": TODAY_DAILY_UNAVAILABLE_NOTICE, + } return self._enrich_stock_detail(result) @staticmethod @@ -948,6 +951,134 @@ class MarketServiceMixin: "quote_time": str(row.get("time") or ""), } + def _resolve_today_daily_quote( + self, code: str, today: str, payload: dict[str, Any] + ) -> dict[str, Any] | None: + quote = self._ifind_realtime_stock_quote(code) + if quote and self._valid_realtime_stock_quote(quote, today): + return quote + if self.configured: + try: + client = self._tushare_client() + resolve = getattr(client, "resolve_trade_context", None) + resolved = today + if callable(resolve): + resolved, _ = resolve(today) + if str(resolved or "") == today: + quote = client.realtime_stock_quote(tushare_code(code), today) + if self._valid_realtime_stock_quote(quote, today): + return quote + except TushareError: + pass + quote = self._free_realtime_stock_quote(code, today) + if quote and self._valid_realtime_stock_quote(quote, today): + return quote + return self._intraday_realtime_stock_quote(code, today, payload) + + def _free_realtime_stock_quote(self, code: str, today: str) -> dict[str, Any] | None: + aggregator = getattr(self, "realtime_aggregator", None) + if aggregator is None: + return None + ts_code = tushare_code(code) + for loader in ( + getattr(aggregator, "tencent_stock_quote", None), + getattr(aggregator, "eastmoney_stock_quote", None), + ): + if not callable(loader): + continue + try: + row = loader(ts_code, expected_date=today) + except (RealtimeAggregateError, Exception): + continue + quote = self._quote_from_free_row(code, today, row) + if quote: + return quote + return None + + def _quote_from_free_row( + self, code: str, today: str, row: dict[str, Any] + ) -> dict[str, Any] | None: + price = float(row.get("close") or 0) + previous_close = float(row.get("pre_close") or 0) + if price <= 0 or previous_close <= 0: + return None + try: + name, sector = self._stock_identity(code, today) + except Exception: + name, sector = "--", "其他" + epoch = int(row.get("quote_time_epoch") or 0) + if epoch > 0: + quote_time = datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds") + else: + quote_date = str(row.get("quote_date") or today) + quote_time = f"{quote_date[:4]}-{quote_date[4:6]}-{quote_date[6:]}" + return { + "name": str(row.get("name") or name or "--"), + "sector": sector, + "price": price, + "open": float(row.get("open") or 0), + "high": float(row.get("high") or 0), + "low": float(row.get("low") or 0), + "change": round((price / previous_close - 1) * 100, 4), + "volume": float(row.get("vol") or 0), + "amount_billion": float(row.get("amount") or 0) / 100_000_000, + "turnover_rate": float(row.get("turnover_rate") or 0), + "quote_time": quote_time, + } + + def _intraday_realtime_stock_quote( + self, code: str, today: str, payload: dict[str, Any] + ) -> dict[str, Any] | None: + chart_data = getattr(self, "chart_data", None) + if chart_data is None: + return None + try: + chart = chart_data.stock_intraday(code) + except (AttributeError, ChartDataError, Exception): + return None + points = [ + point + for point in list(chart.get("points") or []) + if str(point.get("date") or "").replace("-", "") == today + ] + if not points: + return None + opens = [float(point.get("open") or 0) for point in points if float(point.get("open") or 0) > 0] + highs = [float(point.get("high") or 0) for point in points if float(point.get("high") or 0) > 0] + lows = [float(point.get("low") or 0) for point in points if float(point.get("low") or 0) > 0] + closes = [float(point.get("close") or 0) for point in points if float(point.get("close") or 0) > 0] + if not opens or not highs or not lows or not closes: + return None + price = closes[-1] + previous_close = float(chart.get("previous_close") or 0) + if previous_close <= 0: + history = list(payload.get("prices") or []) + previous_close = float((history[-1] if history else {}).get("close") or 0) + if previous_close <= 0: + return None + volume = sum(float(point.get("volume") or 0) for point in points) + amount = sum(float(point.get("amount") or 0) for point in points) + if volume <= 0 and amount <= 0: + return None + try: + name, sector = self._stock_identity(code, today) + except Exception: + name, sector = "--", "其他" + return { + "name": name, + "sector": sector, + "price": price, + "open": opens[0], + "high": max(highs), + "low": min(lows), + "change": round((price / previous_close - 1) * 100, 4), + "volume": volume, + "volume_unit": "lots", + "amount_billion": amount / 100_000_000, + "turnover_rate": 0.0, + "quote_time": str(points[-1].get("date") or today), + } + @staticmethod def _merge_realtime_stock_detail( payload: dict[str, Any], quote: dict[str, Any], trade_date: str @@ -986,6 +1117,7 @@ class MarketServiceMixin: **(payload.get("meta") or {}), "trade_date": display_date, "realtime": True, + "notice": "", "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), } diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 6f20e27..1d4be55 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -533,8 +533,8 @@ }, { "path": "frontend/pages/market/preview.js", - "bytes": 18178, - "lines": 446 + "bytes": 18339, + "lines": 450 }, { "path": "backend/features/heaven/trend.py", @@ -681,16 +681,16 @@ "bytes": 6092, "lines": 138 }, + { + "path": "frontend/pages/market/stock-detail.js", + "bytes": 6041, + "lines": 134 + }, { "path": "frontend/pages/dragon-tiger/page.html", "bytes": 5754, "lines": 85 }, - { - "path": "frontend/pages/market/stock-detail.js", - "bytes": 5690, - "lines": 124 - }, { "path": "frontend/pages.config.js", "bytes": 5385, diff --git a/frontend/pages/market/preview.js b/frontend/pages/market/preview.js index 2bd83d6..4142f68 100644 --- a/frontend/pages/market/preview.js +++ b/frontend/pages/market/preview.js @@ -367,7 +367,11 @@ function selectStockPreviewChart(chart) { } } else if ((payload.prices || []).length) { setText("stockPreviewDate", payload.meta?.trade_date || "最新行情"); - setText("stockPreviewSource", `日 K 行情 · ${payload.prices.length} 个交易日`); + const notice = String(payload.meta?.notice || "").trim(); + setText( + "stockPreviewSource", + notice ? `日 K 行情 · ${payload.prices.length} 个交易日 · ${notice}` : `日 K 行情 · ${payload.prices.length} 个交易日`, + ); drawDailyPreviewChart(payload.prices); } else { setText("stockPreviewDate", payload.meta?.trade_date || "最新行情"); diff --git a/frontend/pages/market/stock-detail.js b/frontend/pages/market/stock-detail.js index f8b78a9..2906238 100644 --- a/frontend/pages/market/stock-detail.js +++ b/frontend/pages/market/stock-detail.js @@ -52,7 +52,11 @@ async function openStock(code, fallback = null) { renderStockNotes(payload.notes || []); updateWatchButton(); if (state.stockDetailChartMode === "daily") { - setText("chartSource", `日 K 行情 · ${payload.prices.length} 个交易日`); + const notice = String(payload.meta?.notice || "").trim(); + setText( + "chartSource", + notice ? `日 K 行情 · ${payload.prices.length} 个交易日 · ${notice}` : `日 K 行情 · ${payload.prices.length} 个交易日`, + ); requestAnimationFrame(() => drawPriceChart(payload.prices || [])); } } catch (error) { @@ -69,7 +73,13 @@ async function selectStockDetailChart(mode) { syncDetailChartButtons("stock", selected); if (selected === "daily") { const prices = state.stockDetail?.prices || []; - setText("chartSource", prices.length ? `日 K 行情 · ${prices.length} 个交易日` : "正在加载行情"); + const notice = String(state.stockDetail?.meta?.notice || "").trim(); + setText( + "chartSource", + prices.length + ? (notice ? `日 K 行情 · ${prices.length} 个交易日 · ${notice}` : `日 K 行情 · ${prices.length} 个交易日`) + : "正在加载行情", + ); if (prices.length) requestAnimationFrame(() => drawPriceChart(prices)); else clearPriceChart("正在加载日 K 数据"); return; diff --git a/tests/test_heaven_realtime.py b/tests/test_heaven_realtime.py index 323eeeb..27275ad 100644 --- a/tests/test_heaven_realtime.py +++ b/tests/test_heaven_realtime.py @@ -6,7 +6,7 @@ import unittest from datetime import datetime from unittest.mock import MagicMock, patch -from backend.data.realtime import WebRealtimeAggregator +from backend.data.realtime import RealtimeAggregateError, WebRealtimeAggregator from backend.features.heaven.engine import _market_line_scores, build_manual_market_hexagram from server import DashboardService from backend.data.providers.tushare_client import ( @@ -415,6 +415,50 @@ class RealtimeAggregatorTests(unittest.TestCase): self.assertEqual(quotes[0]["vol"], 1000) self.assertEqual(quotes[0]["quote_date"], "20260720") + @patch.object(WebRealtimeAggregator, "_get_text") + def test_tencent_stock_quote_keeps_expected_date(self, get_text: MagicMock): + fields = [""] * 38 + fields[1] = "浦发银行" + fields[2] = "600000" + fields[3] = "11.20" + fields[4] = "11.00" + fields[5] = "11.10" + fields[6] = "1234" + fields[30] = "20260720103000" + fields[33] = "11.30" + fields[34] = "11.00" + fields[37] = "1380" + get_text.return_value = (f'v_sh600000="{"~".join(fields)}";', 0) + + quote = WebRealtimeAggregator().tencent_stock_quote("600000", "20260720") + + self.assertEqual(quote["ts_code"], "600000.SH") + self.assertEqual(quote["quote_date"], "20260720") + self.assertEqual(quote["vol"], 123400) + self.assertAlmostEqual(quote["amount"], 13_800_000) + + @patch.object(WebRealtimeAggregator, "_get_json") + def test_eastmoney_stock_quote_rejects_stale_date(self, get_json: MagicMock): + epoch = datetime(2026, 7, 19, 15, 0).timestamp() + get_json.return_value = { + "rc": 0, + "data": { + "f43": 11.2, + "f44": 11.3, + "f45": 11.0, + "f46": 11.1, + "f47": 10, + "f48": 50000000, + "f57": "300750", + "f58": "宁德时代", + "f60": 11.0, + "f86": epoch, + }, + } + + with self.assertRaises(RealtimeAggregateError): + WebRealtimeAggregator().eastmoney_stock_quote("300750.SZ", "20260720") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_stock_detail_realtime.py b/tests/test_stock_detail_realtime.py index 010d701..164d779 100644 --- a/tests/test_stock_detail_realtime.py +++ b/tests/test_stock_detail_realtime.py @@ -5,6 +5,10 @@ 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 @@ -17,6 +21,10 @@ class DetailDatabaseStub: def list_notes(user_id, code=""): return [] + @staticmethod + def get_snapshot(trade_date): + return {} + class RealtimeClientStub: quote_calls = 0 @@ -61,6 +69,122 @@ class FixedPreopenDatetime(datetime): 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) @@ -68,7 +192,11 @@ class StockDetailRealtimeTests(unittest.TestCase): 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 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") @@ -162,6 +290,183 @@ class StockDetailRealtimeTests(unittest.TestCase): 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 + DeniedRealtimeClientStub.quote_calls = 0 + + with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( + "backend.features.market.service.TushareClient", DeniedRealtimeClientStub + ): + 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="浦发银行"), + ) + self.service.realtime_aggregator = aggregator + DeniedRealtimeClientStub.quote_calls = 0 + + with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( + "backend.features.market.service.TushareClient", DeniedRealtimeClientStub + ): + 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), patch( + "backend.features.market.service.TushareClient", DeniedRealtimeClientStub + ): + 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") + DeniedRealtimeClientStub.quote_calls = 0 + + with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( + "backend.features.market.service.TushareClient", DeniedRealtimeClientStub + ): + 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())) + ) + DeniedRealtimeClientStub.quote_calls = 0 + with patch("backend.features.market.service.datetime", FixedLunchDatetime), patch( + "backend.features.market.service.TushareClient", DeniedRealtimeClientStub + ): + 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()) + DeniedRealtimeClientStub.quote_calls = 0 + with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch( + "backend.features.market.service.TushareClient", DeniedRealtimeClientStub + ): + 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 + with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch( + "backend.features.market.service.TushareClient", RealtimeClientStub + ): + 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()) + DeniedRealtimeClientStub.quote_calls = 0 + with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch( + "backend.features.market.service.TushareClient", DeniedRealtimeClientStub + ): + 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()