from __future__ import annotations import copy import re from datetime import date, datetime, time as dt_time, timedelta from typing import Any from backend.bootstrap.config import ( normalize_date, tushare_code, validate_stock_code, validate_text, ) from backend.data.providers.ifind_client import IfindError from backend.data.providers.tushare_client import TushareClient, TushareError from backend.features.market.charts import ChartDataError from backend.features.market.insights import MarketInsightsService from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION SEARCH_INDEXES = ( {"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"}, {"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"}, {"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"}, ) SEARCH_TYPE_LABELS = { "stock": "股票", "sector": "板块", "theme": "题材", "index": "指数", } THS_SEARCH_TYPES = { "I": ("sector", "行业板块"), "R": ("sector", "地域板块"), "N": ("theme", "概念题材"), } class MarketServiceMixin: def _market_insights(self) -> MarketInsightsService: if not self.configured: raise ValueError("行情数据尚未配置。") return MarketInsightsService( self.database, self._tushare_client(), ifind=self.ifind, ) def _tushare_client(self) -> TushareClient: gateway = getattr(self, "data_gateway", None) if gateway is not None: return gateway.tushare() # Compatibility for isolated legacy unit-test service stubs. return TushareClient(self.token) def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]: normalized_date = normalize_date(trade_date) now = datetime.now().astimezone() if ( normalized_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time() ): previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True) if previous: carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情") return self._apply_reason_overrides(self._with_storage(carried, cached=True)) if not force: snapshot = self.database.get_snapshot(normalized_date) if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo": snapshot = copy.deepcopy(snapshot) if normalized_date != now.strftime("%Y%m%d"): snapshot.setdefault("meta", {}).update( {"realtime": False, "market_status": "closed"} ) if not self._dashboard_sentiment_ready(snapshot): snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) self.database.save_snapshot( normalized_date, str((snapshot.get("meta") or {}).get("source") or "tushare"), snapshot, ) snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date) return self._apply_reason_overrides(self._with_storage(snapshot, cached=True)) resolved = self.database.get_data_snapshot( "dashboard_request_v1", normalized_date ) if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo": resolved = copy.deepcopy(resolved) resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date( normalized_date ) return self._apply_reason_overrides( self._with_storage(resolved, cached=True) ) if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5: previous = self.database.get_latest_real_snapshot(normalized_date) if previous: carried = self._carry_dashboard( previous, normalized_date, "非交易日沿用最近交易日收盘行情", ) self.database.save_data_snapshot( "dashboard_request_v1", normalized_date, "sqlite", carried ) return self._apply_reason_overrides( self._with_storage(carried, cached=True) ) return self.sync_dashboard(normalized_date) @staticmethod def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool: overview = dashboard.get("overview") or {} return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all( key in overview for key in ( "sentiment_score", "sentiment_label", "sentiment_phase", "sentiment_direction", "sentiment_components", ) ) @staticmethod def _display_compact_date(compact: str) -> str: return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}" def _carry_dashboard( self, snapshot: dict[str, Any], requested_date: str, reason: str ) -> dict[str, Any]: carried = copy.deepcopy(snapshot) meta = carried.setdefault("meta", {}) meta.update( { "requested_date": self._display_compact_date(requested_date), "carried_forward": True, "realtime": False, "market_status": "closed", "notice": reason, } ) return carried def _realtime_snapshot_due( self, normalized_date: str, snapshot: dict[str, Any], ) -> bool: if not self.configured or normalized_date != date.today().strftime("%Y%m%d"): return False now = datetime.now().astimezone() local_time = now.time().replace(tzinfo=None) realtime_start = datetime.strptime("09:15", "%H:%M").time() morning_end = datetime.strptime("11:35", "%H:%M").time() afternoon_start = datetime.strptime("12:55", "%H:%M").time() realtime_end = datetime.strptime("15:05", "%H:%M").time() in_session = ( realtime_start <= local_time < morning_end or afternoon_start <= local_time < realtime_end ) if not in_session: return False meta = snapshot.get("meta") or {} snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "") if snapshot_trade_date and snapshot_trade_date != normalized_date: return False if not meta.get("realtime"): return True try: updated_at = datetime.fromisoformat(str(meta.get("updated_at") or "")) if updated_at.tzinfo is None: updated_at = updated_at.replace(tzinfo=now.tzinfo) except ValueError: return True age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds() return age_seconds >= 8 def sync_dashboard(self, trade_date: str) -> dict[str, Any]: normalized_date = normalize_date(trade_date) source = "tushare" with self.sync_lock: sync_id = self.database.start_sync(normalized_date, source) try: if not self.configured: raise TushareError("公共行情尚未配置") dashboard = self._tushare_client().dashboard(normalized_date) dashboard["meta"]["source"] = source dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date) dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) record_count = self._record_count(dashboard) actual_date = normalize_date( str(dashboard.get("meta", {}).get("trade_date") or normalized_date) ) self.database.save_snapshot(actual_date, source, dashboard) if actual_date != normalized_date: dashboard.setdefault("meta", {}).update( { "carried_forward": True, "realtime": False, "market_status": "closed", } ) self.database.save_data_snapshot( "dashboard_request_v1", normalized_date, source, dashboard ) self.database.finish_sync( sync_id, "success", record_count, dashboard.get("meta", {}).get("notice", ""), source, ) return self._apply_reason_overrides(self._with_storage(dashboard, cached=False)) except TushareError as exc: fallback = self.database.get_latest_real_snapshot(normalized_date) if fallback: carried = self._carry_dashboard( fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}" ) self.database.finish_sync( sync_id, "fallback", self._record_count(carried), str(exc), "tushare" ) return self._apply_reason_overrides(self._with_storage(carried, cached=True)) self.database.finish_sync(sync_id, "failed", message=str(exc)) raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc except Exception as exc: self.database.finish_sync(sync_id, "failed", message=str(exc)) raise def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]: sector = validate_text(sector, "板块名称", 50) return self.realtime_aggregator.health_snapshot(sector) def _search_market_directory(self) -> list[dict[str, Any]]: cached = self.database.get_data_snapshot("search_directory", "ths") or {} cached_items = list(cached.get("items") or []) if cached_items and int(cached.get("schema_version") or 0) >= 2: return cached_items if not self.configured: return cached_items try: rows = self._tushare_client().query( "ths_index", {}, "ts_code,name,count,exchange,list_date,type", ) except TushareError: return cached_items items = [] for row in rows: mapping = THS_SEARCH_TYPES.get(str(row.get("type") or "").upper()) code = str(row.get("ts_code") or "").strip().upper() name = str(row.get("name") or "").strip() if not mapping or not code or not name or str(row.get("exchange") or "").upper() != "A": continue entity_type, subtitle = mapping items.append( { "id": code, "code": code, "name": name, "type": entity_type, "subtitle": subtitle, "member_count": int(float(row.get("count") or 0)), } ) if items: self.database.save_data_snapshot( "search_directory", "ths", "tushare", {"schema_version": 2, "items": items} ) return items @staticmethod def _search_match_score(item: dict[str, Any], query: str) -> tuple[int, int, str]: name = str(item.get("name") or "").casefold() code = str(item.get("code") or item.get("id") or "").casefold() needle = query.casefold() if code == needle: rank = 0 elif name == needle: rank = 1 elif code.startswith(needle): rank = 2 elif name.startswith(needle): rank = 3 else: rank = 4 return rank, len(name), code def search_entities(self, query: str, trade_date: str) -> dict[str, Any]: needle = str(query or "").strip() normalized_date = normalize_date(trade_date) groups: dict[str, list[dict[str, Any]]] = { "stocks": [], "sectors": [], "themes": [], "indices": [], } if not needle: return {"query": "", "trade_date": normalized_date, "groups": groups} stocks = [] for row in self.database.search_stock_master(needle, 12): stocks.append( { "id": str(row.get("code") or ""), "code": str(row.get("code") or ""), "name": str(row.get("name") or "--"), "type": "stock", "type_label": SEARCH_TYPE_LABELS["stock"], "industry": str(row.get("industry") or "其他"), "market": str(row.get("market") or ""), "subtitle": " · ".join( part for part in (str(row.get("industry") or ""), str(row.get("market") or "")) if part ) or "A股", } ) groups["stocks"] = stocks[:8] market_items = list(self._search_market_directory()) + [dict(item) for item in SEARCH_INDEXES] matched = [ item for item in market_items if needle.casefold() in str(item.get("name") or "").casefold() or needle.casefold() in str(item.get("code") or "").casefold() ] matched.sort(key=lambda item: self._search_match_score(item, needle)) group_keys = {"sector": "sectors", "theme": "themes", "index": "indices"} for item in matched: group_key = group_keys.get(str(item.get("type") or "")) if not group_key or len(groups[group_key]) >= 8: continue groups[group_key].append( { **item, "type_label": SEARCH_TYPE_LABELS[str(item["type"])], } ) return {"query": needle, "trade_date": normalized_date, "groups": groups} def get_search_detail( self, entity_type: str, identifier: str, trade_date: str ) -> dict[str, Any]: entity_type = str(entity_type or "").strip().lower() identifier = str(identifier or "").strip().upper() normalized_date = normalize_date(trade_date) if entity_type not in {"sector", "theme", "index"}: raise ValueError("搜索详情类型不支持。") if not re.fullmatch(r"[A-Z0-9.]{3,24}", identifier): raise ValueError("搜索详情标识无效。") if not self.configured: raise ValueError("行情数据源尚未配置。") if entity_type == "index": index_basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None) if not index_basic: raise ValueError("暂不支持该指数详情。") return self._index_search_detail(index_basic, normalized_date) directory = self._search_market_directory() basic = next( ( item for item in directory if item.get("id") == identifier and item.get("type") == entity_type ), None, ) if not basic: raise ValueError("未找到对应的板块或题材。") return self._ths_search_detail(basic, normalized_date) def get_intraday_chart( self, entity_type: str, identifier: str ) -> dict[str, Any]: entity_type = str(entity_type or "").strip().lower() identifier = str(identifier or "").strip().upper() if entity_type == "stock": code = validate_stock_code(identifier) chart = self.chart_data.stock_intraday(code) type_label = SEARCH_TYPE_LABELS["stock"] elif entity_type == "index": basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None) if not basic: raise ValueError("暂不支持该指数分时行情。") chart = self.chart_data.index_intraday(identifier) type_label = SEARCH_TYPE_LABELS["index"] elif entity_type in {"sector", "theme"}: basic = next( ( item for item in self._search_market_directory() if item.get("id") == identifier and item.get("type") == entity_type ), None, ) if not basic: raise ValueError("未找到对应的板块或题材。") chart = self.chart_data.board_intraday(identifier, str(basic.get("name") or "")) type_label = SEARCH_TYPE_LABELS[entity_type] else: raise ValueError("分时行情类型不支持。") return { "meta": { "trade_date": str(chart.get("trade_date") or ""), "previous_close": float(chart.get("previous_close") or 0), }, "entity": { "id": identifier, "code": str(chart.get("code") or identifier), "name": str(chart.get("name") or ""), "type": entity_type, "type_label": type_label, }, "points": list(chart.get("points") or []), } def _ths_search_detail( self, basic: dict[str, Any], trade_date: str ) -> dict[str, Any]: client = self._tushare_client() resolved_date, _ = client.resolve_trade_context(trade_date) end = datetime.strptime(resolved_date, "%Y%m%d") start_date = (end - timedelta(days=190)).strftime("%Y%m%d") identifier = str(basic["id"]) snapshot = client.sector_snapshot(identifier, resolved_date) rows = client.query( "ths_daily", {"ts_code": identifier, "start_date": start_date, "end_date": resolved_date}, "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate,total_mv,float_mv", ) rows.sort(key=lambda item: str(item.get("trade_date") or "")) series = [ { "trade_date": self._display_compact_date(str(row.get("trade_date") or "")), "open": float(row.get("open") or 0), "high": float(row.get("high") or 0), "low": float(row.get("low") or 0), "close": float(row.get("close") or 0), "change": float(row.get("pct_change") or 0), "volume": float(row.get("vol") or 0), "turnover_rate": float(row.get("turnover_rate") or 0), } for row in rows[-90:] ] try: chart_series = self.chart_data.board_daily(identifier, resolved_date, 90) if chart_series: series = chart_series except (AttributeError, ChartDataError): pass latest = series[-1] if series else {} snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date change = float( snapshot.get("change") if snapshot_is_current and snapshot.get("change") is not None else latest.get("change") or 0 ) if latest.get("realtime"): change = float(latest.get("change") or 0) turnover_rate = float( snapshot.get("turnover_rate") if snapshot_is_current and snapshot.get("turnover_rate") is not None else latest.get("turnover_rate") or 0 ) metrics = [ {"label": "涨跌幅", "value": round(change, 2), "unit": "%", "tone": "change"}, {"label": "换手率", "value": round(turnover_rate, 2), "unit": "%"}, {"label": "成份数量", "value": int(float(basic.get("member_count") or 0)), "unit": "只"}, ] up_count = int(float(snapshot.get("up_count") or 0)) down_count = int(float(snapshot.get("down_count") or 0)) if up_count or down_count: metrics.extend( [ {"label": "上涨家数", "value": up_count, "unit": "家"}, {"label": "下跌家数", "value": down_count, "unit": "家"}, ] ) leader = str(snapshot.get("leader") or "").strip() if leader and leader != "--": metrics.extend( [ {"label": "领涨标的", "value": leader, "unit": ""}, {"label": "领涨幅", "value": round(float(snapshot.get("leading_pct") or 0), 2), "unit": "%", "tone": "change"}, ] ) return { "meta": { "trade_date": self._display_compact_date(resolved_date), "realtime": bool(snapshot.get("realtime")), }, "entity": { "id": identifier, "code": identifier, "name": str(snapshot.get("name") or basic.get("name") or "--"), "type": str(basic.get("type") or "sector"), "type_label": SEARCH_TYPE_LABELS[str(basic.get("type") or "sector")], "subtitle": str(basic.get("subtitle") or ""), "value": float(latest.get("close") or 0), "change": change, }, "series": series, "metrics": metrics, } def _index_search_detail( self, basic: dict[str, Any], trade_date: str ) -> dict[str, Any]: client = self._tushare_client() resolved_date, _ = client.resolve_trade_context(trade_date) payload = ( client.realtime_market_indices(resolved_date) if client.should_use_realtime(trade_date, resolved_date) else client.market_indices(resolved_date, 90) ) current = next( (item for item in payload.get("indices") or [] if item.get("ts_code") == basic["id"]), None, ) if not current: raise ValueError("该指数暂无可用行情。") end = datetime.strptime(resolved_date, "%Y%m%d") rows = client.query( "index_daily", { "ts_code": basic["id"], "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"), "end_date": resolved_date, }, "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", ) rows.sort(key=lambda item: str(item.get("trade_date") or "")) series = [ { "trade_date": self._display_compact_date(str(row.get("trade_date") or "")), "open": float(row.get("open") or 0), "high": float(row.get("high") or 0), "low": float(row.get("low") or 0), "close": float(row.get("close") or 0), "change": float(row.get("pct_chg") or 0), "volume": float(row.get("vol") or 0), } for row in rows[-90:] ] try: chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90) if chart_series: series = chart_series except (AttributeError, ChartDataError): pass latest = series[-1] if series else {} latest_close = float(latest.get("close") or current.get("close") or 0) latest_change = float(latest.get("change") or current.get("pct_chg") or 0) def series_return(days: int) -> float: if len(series) <= days: return 0.0 previous = float(series[-days - 1].get("close") or 0) return (latest_close / previous - 1) * 100 if previous > 0 else 0.0 return { "meta": { "trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)), "realtime": bool(payload.get("realtime")), }, "entity": { **basic, "type_label": SEARCH_TYPE_LABELS["index"], "value": latest_close, "change": latest_change, }, "series": series, "metrics": [ {"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"}, {"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"}, {"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"}, {"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"}, ], } def get_stock_detail( self, code: str, trade_date: str, force: bool = False ) -> dict[str, Any]: code = validate_stock_code(code) normalized_date = normalize_date(trade_date) cache_key = f"{code}:{normalized_date}" if not force: cached = self.database.get_data_snapshot("stock_detail", cache_key) if cached and str((cached.get("meta") or {}).get("source") or "") != "demo": if not self._stock_detail_cache_needs_refresh(cached, normalized_date): cached["meta"] = {**cached.get("meta", {}), "cached": True} return self._prepare_stock_detail(cached, code, normalized_date) name, sector = self._stock_identity(code, normalized_date) source = "tushare" if self.configured: try: payload = self._tushare_client().stock_detail( tushare_code(code), normalized_date ) if not payload.get("prices"): raise TushareError("No price history returned") except TushareError as exc: payload = self.database.get_latest_data_snapshot( "stock_detail", f"{code}:", cache_key, exclude_source="demo" ) if not payload: raise ValueError(f"暂无 {code} 的真实行情数据:{exc}") from exc payload = copy.deepcopy(payload) payload["meta"] = { **payload.get("meta", {}), "cached": True, "notice": "最新行情暂不可用,已沿用最近真实收盘数据。", } return self._prepare_stock_detail(payload, code, normalized_date) else: payload = self.database.get_latest_data_snapshot( "stock_detail", f"{code}:", cache_key, exclude_source="demo" ) if not payload: raise ValueError(f"暂无 {code} 的真实行情数据,请等待后台完成首次同步。") payload = copy.deepcopy(payload) payload["meta"] = { **payload.get("meta", {}), "cached": True, "notice": "公共行情尚未配置,已沿用最近真实收盘数据。", } return self._prepare_stock_detail(payload, code, normalized_date) payload["meta"]["source"] = source payload["meta"]["cached"] = False self.database.save_data_snapshot("stock_detail", cache_key, source, payload) return self._prepare_stock_detail(payload, code, normalized_date) @staticmethod def _stock_detail_bar_date(payload: dict[str, Any]) -> str: prices = list(payload.get("prices") or []) return str((prices[-1] if prices else {}).get("trade_date") or "").replace("-", "") def _stock_detail_cache_needs_refresh( self, payload: dict[str, Any], requested_date: str ) -> bool: now = datetime.now().astimezone() return ( requested_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) >= dt_time(15, 0) and self._stock_detail_bar_date(payload) < requested_date ) def _prepare_stock_detail( self, payload: dict[str, Any], code: str, requested_date: str ) -> dict[str, Any]: result = copy.deepcopy(payload) now = datetime.now().astimezone() try: result["prices"] = self.chart_data.stock_daily(code, requested_date, 90) result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"} except (AttributeError, ChartDataError): pass result = self._sanitize_stock_detail_prices(result, now) actual_date = self._stock_detail_bar_date(result) if actual_date: result["meta"] = { **(result.get("meta") or {}), "trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}", } today = now.strftime("%Y%m%d") should_merge = ( requested_date == today and actual_date <= today and now.weekday() < 5 and now.time().replace(tzinfo=None) >= dt_time(9, 30) ) if should_merge: quote = self._ifind_realtime_stock_quote(code) 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 return self._enrich_stock_detail(result) @staticmethod def _sanitize_stock_detail_prices( payload: dict[str, Any], market_now: datetime ) -> dict[str, Any]: result = copy.deepcopy(payload) raw_prices = list(result.get("prices") or []) raw_latest_date = str( (raw_prices[-1] if raw_prices else {}).get("trade_date") or "" ).replace("-", "") prices = [] for bar in raw_prices: open_price = float(bar.get("open") or 0) high = float(bar.get("high") or 0) low = float(bar.get("low") or 0) close = float(bar.get("close") or 0) if ( open_price > 0 and high >= max(open_price, close) and 0 < low <= min(open_price, close) and close > 0 ): prices.append(bar) today = market_now.strftime("%Y%m%d") market_open = ( market_now.weekday() < 5 and market_now.time().replace(tzinfo=None) >= dt_time(9, 30) ) if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today: current = prices[-1] has_market_activity = ( float(current.get("volume") or 0) > 0 or float(current.get("amount_billion") or 0) > 0 ) if not market_open or not has_market_activity: prices.pop() if raw_latest_date == today and ( not prices or str(prices[-1].get("trade_date") or "").replace("-", "") != today ): result["meta"] = {**(result.get("meta") or {}), "realtime": False} result["prices"] = prices if prices: latest = prices[-1] stock = dict(result.get("stock") or {}) stock.update( { "price": float(latest.get("close") or 0), "change": float(latest.get("change") or 0), "amount_billion": float(latest.get("amount_billion") or 0), } ) result["stock"] = stock return result @staticmethod def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool: price = float(quote.get("price") or 0) open_price = float(quote.get("open") or 0) high = float(quote.get("high") or 0) low = float(quote.get("low") or 0) volume = float(quote.get("volume") or 0) amount = float(quote.get("amount_billion") or 0) quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "") return ( price > 0 and open_price > 0 and high >= max(open_price, price) and 0 < low <= min(open_price, price) and (volume > 0 or amount > 0) and (not quote_date or quote_date == trade_date) ) def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None: ifind = getattr(self, "ifind", None) if not ifind or not ifind.configured: return None try: rows = ifind.real_time( tushare_code(code), [ "open", "high", "low", "latest", "preClose", "volume", "amount", "turnoverRatio", ], cache_ttl=10, ) except IfindError: return None row = rows[0] if rows else {} price = float(row.get("latest") or 0) previous_close = float(row.get("preClose") or 0) if price <= 0: return None change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0 stock = self._stock_identity(code, date.today().strftime("%Y%m%d")) return { "name": stock[0], "sector": stock[1], "price": price, "open": float(row.get("open") or price), "high": float(row.get("high") or price), "low": float(row.get("low") or price), "change": round(change, 4), "volume": float(row.get("volume") or 0), "volume_unit": "lots", "amount_billion": float(row.get("amount") or 0) / 100_000_000, "turnover_rate": float(row.get("turnoverRatio") or 0), "quote_time": str(row.get("time") or ""), } @staticmethod def _merge_realtime_stock_detail( payload: dict[str, Any], quote: dict[str, Any], trade_date: str ) -> None: display_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}" realtime_bar = { "trade_date": display_date, "open": quote["open"], "high": quote["high"], "low": quote["low"], "close": quote["price"], "change": quote["change"], "volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100, "amount_billion": quote["amount_billion"], "realtime": True, } prices = list(payload.get("prices") or []) if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == trade_date: prices[-1] = realtime_bar else: prices.append(realtime_bar) payload["prices"] = prices[-90:] stock = dict(payload.get("stock") or {}) stock.update( { "name": quote["name"], "industry": quote["sector"], "price": quote["price"], "change": quote["change"], "amount_billion": quote["amount_billion"], "turnover_rate": quote["turnover_rate"], } ) payload["stock"] = stock payload["meta"] = { **(payload.get("meta") or {}), "trade_date": display_date, "realtime": True, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), } def get_stock_preview( self, code: str, trade_date: str, force: bool = False ) -> dict[str, Any]: code = validate_stock_code(code) # Hover previews deliberately follow the latest market day, independent # from the review date selected by the page. detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force) detail_meta = detail.get("meta") or {} resolved_date = str(detail_meta.get("trade_date") or trade_date) intraday_points: list[dict[str, Any]] = [] intraday_status = "unavailable" intraday_notice = "分时行情暂不可用。" intraday_trade_date = "" intraday_previous_close = 0.0 try: intraday = self.chart_data.stock_intraday(code) intraday_points = list(intraday.get("points") or []) intraday_trade_date = str(intraday.get("trade_date") or "") intraday_previous_close = float(intraday.get("previous_close") or 0) if intraday_points: intraday_status = "available" intraday_notice = "" else: intraday_status = "empty" intraday_notice = "最近交易日暂无分时数据。" except ChartDataError: intraday_status = "unavailable" intraday_notice = "分时行情暂不可用,请稍后重试。" prices = list(detail.get("prices") or [])[-60:] stock = dict(detail.get("stock") or {"code": code}) realtime = bool(detail_meta.get("realtime")) return { "meta": { "trade_date": resolved_date, "source": detail_meta.get("source") or "unavailable", "notice": detail_meta.get("notice") or "", "intraday_status": intraday_status, "intraday_notice": intraday_notice, "intraday_trade_date": intraday_trade_date, "intraday_previous_close": intraday_previous_close, "realtime": realtime, "refresh_interval_seconds": 10 if realtime else 0, }, "stock": stock, "prices": prices, "intraday": intraday_points, } def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]: start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date() end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date() if start > end: raise ValueError("开始日期不能晚于结束日期。") weekdays = [] current = start while current <= end: if current.weekday() < 5: weekdays.append(current) current += timedelta(days=1) if len(weekdays) > 15: raise ValueError("单次最多回补 15 个工作日。") results = [] for day in weekdays: dashboard = self.sync_dashboard(day.strftime("%Y%m%d")) results.append( { "requested_date": day.isoformat(), "trade_date": dashboard["meta"]["trade_date"], "source": dashboard["meta"]["source"], "records": self._record_count(dashboard), } ) return results def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]: snapshot = self.database.get_snapshot(trade_date) or {} for key in ("limits", "broken", "down_limits"): for row in snapshot.get(key) or []: if str(row.get("code")) == code: return row.get("name") or "--", row.get("sector") or "其他" for item in self.database.list_watchlist(self.current_user_id): if item["code"] == code: return item["name"], item["sector"] or "其他" return "--", "其他" def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]: result = dict(payload) stock = dict(payload.get("stock") or {}) code = str(stock.get("code") or "") watched = { item["code"]: item for item in self.database.list_watchlist(self.current_user_id) } stock["watchlist"] = watched.get(code) result["stock"] = stock result["notes"] = self.database.list_notes(self.current_user_id, code=code) return result def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]: result = dict(dashboard) result["meta"] = { **dashboard.get("meta", {}), "storage": "sqlite", "cached": cached, } return result @staticmethod def _record_count(dashboard: dict[str, Any]) -> int: return sum( len(dashboard.get(key) or []) for key in ("limits", "broken", "down_limits", "yesterday_limits") )