from __future__ import annotations import json from datetime import datetime, time from typing import Any from zoneinfo import ZoneInfo from backend.data.contracts import SnapshotState from backend.data.gateway import DataGateway, MarketDataUnavailable from backend.data.providers.base import ProviderError from backend.data.repository import MarketRepository from backend.database.connection import Database from backend.features.market.insights.auction import build_auction, build_watchlist_rows from backend.features.market.insights.dragon import build_dragon_list from backend.features.market.insights.popularity import build_popularity from backend.features.market.insights.support import ( auction_phase as _auction_phase, ) from backend.features.market.insights.support import ( clock as _clock, ) from backend.features.market.insights.support import ( decorate as _decorate, ) from backend.features.market.insights.support import ( empty_auction as _empty_auction, ) from backend.features.market.insights.support import ( empty_standard as _empty_standard, ) from backend.features.market.insights.support import ( number as _number, ) from backend.features.market.insights.support import ( result as _result, ) from backend.features.market.insights.support import ( rows as _rows, ) from backend.features.market.insights.support import ( serialized_rows as _serialized_rows, ) from backend.features.market.insights.support import ( standard as _standard, ) from backend.features.market.insights.support import ( tuple_or_none as _tuple_or_none, ) from backend.features.market.insights.support import ( valid_date as _date, ) from backend.features.market.insights.themes import build_theme_detail, build_theme_library SHANGHAI = ZoneInfo("Asia/Shanghai") class MarketInsightError(RuntimeError): pass class MarketInsightService: def __init__( self, database: Database, repository: MarketRepository, gateway: DataGateway ) -> None: self._database = database self._repository = repository self._gateway = gateway def workspace( self, key: str, requested_date: str | None = None, *, user_id: int, force: bool = False, now: datetime | None = None, ) -> dict[str, Any]: if key == "auction": return self.auction(requested_date, user_id=user_id, force=force, now=now) if key == "themes": return self.themes(requested_date, force=force) if key == "popularity": return self.popularity(requested_date, force=force) if key == "dragon-list": return self.dragon_list(requested_date, force=force) raise MarketInsightError("不支持的市场洞察工作区") def auction( self, requested_date: str | None, *, user_id: int, force: bool = False, now: datetime | None = None, ) -> dict[str, Any]: clock = _clock(now) requested, trade_date, previous = self._trade_dates(requested_date, clock) phase = _auction_phase(requested, trade_date, clock) target = previous if phase == "pending" else trade_date baseline = self._previous_date(target) cached = self._snapshot("auction", target) if cached and not force and phase not in {"observing", "selection"}: return _decorate( self._personalize_auction(cached, user_id), requested, phase, carried_forward=target != requested, message=( "今日竞价尚未开始,显示前一交易日归档" if phase == "pending" else "" ), ) inputs = self._gateway.insight_inputs("auction", target, baseline) raw = _rows(inputs.get("auction")) dynamic = False observed_at = clock if phase in {"observing", "selection"}: identifiers = self._auction_universe(baseline, inputs) end = time(9, 25) if phase == "selection" else clock.time().replace(tzinfo=None) try: live = self._gateway.dynamic_auction( identifiers, f"{target} 09:15:00", f"{target} {end.strftime('%H:%M:%S')}", ) raw = live.rows observed_at = live.metadata.observed_at dynamic = True except (MarketDataUnavailable, ProviderError): if phase == "observing": prior = self._snapshot("auction", previous) if prior: return _decorate( self._personalize_auction(prior, user_id), requested, phase, carried_forward=True, message="今日动态竞价暂不可用,当前显示前一交易日归档", current_available=False, ) return _empty_auction( requested, previous, phase, "今日动态竞价暂不可用,且没有历史归档", ) if not raw: if cached: return _decorate( self._personalize_auction(cached, user_id), requested, phase, False, "当前读取失败,保留真实归档", ) return _empty_auction(requested, target, phase, "该交易日暂无可用竞价快照") payload = build_auction( trade_date=target, raw_rows=raw, price_limits=_rows(inputs.get("price_limits")), directory=self._gateway.stock_directory(), prior_snapshot=self._market_snapshot(baseline), ths_hot=_rows(inputs.get("ths_hot")), dc_hot=_rows(inputs.get("dc_hot")), history=self._auction_history(target), dynamic=dynamic, ) minimum = 0.8 if phase == "observing" else 0.9 if float(payload["coverage"]) < minimum: if cached: return _decorate( self._personalize_auction(cached, user_id), requested, phase, False, f"竞价覆盖率不足{minimum * 100:.0f}%,保留原有真实归档", ) return _empty_auction( requested, target, phase, f"竞价覆盖率不足{minimum * 100:.0f}%,未形成正式结果", ) state = ( SnapshotState.REALTIME if phase == "observing" else SnapshotState.FINAL if target == clock.date().isoformat() else SnapshotState.ARCHIVE ) payload["observed_at"] = observed_at.isoformat(timespec="seconds") payload["state"] = state.value if phase != "observing": source = "ifind" if dynamic else "tushare" self._save("auction", target, "", payload, state, source, payload["coverage"]) return _decorate( self._personalize_auction(payload, user_id), requested, phase, target != requested, "", ) def _personalize_auction( self, payload: dict[str, Any], user_id: int ) -> dict[str, Any]: result = {**payload} market_rows = list(result.pop("_market_rows", ())) with self._database.read() as connection: watchlist = tuple( dict(row) for row in self._repository.watchlist(connection, user_id) ) result["watchlist_rows"] = build_watchlist_rows( market_rows, list(result.get("rows") or ()), list(result.get("one_price_rows") or ()), watchlist, ) result["watchlist_ready"] = bool(market_rows) or not watchlist return result def themes(self, requested_date: str | None, *, force: bool = False) -> dict[str, Any]: requested, trade_date, _ = self._trade_dates(requested_date) cached = self._snapshot("themes", trade_date) if cached and not force: return _standard(cached, requested) inputs = self._gateway.insight_inputs("themes", trade_date) directory = _result(inputs.get("directory")) daily = _result(inputs.get("daily")) hot = _result(inputs.get("hot")) if directory is None: fallback = self._latest_snapshot("themes", trade_date) if fallback: return _standard(fallback, requested, "当前题材目录暂不可用,显示最近有效榜单") raise MarketInsightError("题材目录暂不可用") payload = build_theme_library( trade_date, directory.rows, daily.rows if daily else (), hot.rows if hot else (), ) payload["observed_at"] = directory.metadata.observed_at.isoformat(timespec="seconds") payload["state"] = SnapshotState.ARCHIVE.value payload["message"] = "" if daily and daily.rows else "该交易日暂无题材行情" with self._database.transaction() as connection: self._repository.replace_themes( connection, list(payload["items"]), directory.metadata.source.value, payload["observed_at"], ) self._save("themes", trade_date, "", payload, SnapshotState.ARCHIVE, "tushare", 1) return _standard(payload, requested) def theme_detail(self, identifier: str, requested_date: str | None) -> dict[str, Any]: library = self.themes(requested_date) code = identifier.strip().upper() theme = next((item for item in library["items"] if item["code"] == code), None) if theme is None: raise MarketInsightError("未找到该题材") trade_date = str(library["trade_date"]) cached = self._snapshot("themes", trade_date, code) if cached: return cached inputs = self._gateway.insight_inputs("theme-detail", trade_date, identifier=code) members = _result(inputs.get("members")) daily = _result(inputs.get("daily")) payload = build_theme_detail( trade_date, theme, members.rows if members else (), daily.rows if daily else (), ) payload["message"] = "" if members and members.rows else "该题材暂无可核验成分股" payload["observed_at"] = ( members.metadata.observed_at if members else datetime.now(SHANGHAI) ).isoformat(timespec="seconds") payload["state"] = SnapshotState.ARCHIVE.value if members is not None: self._save( "themes", trade_date, code, payload, SnapshotState.ARCHIVE, "tushare", 1 ) return payload def popularity( self, requested_date: str | None, *, force: bool = False ) -> dict[str, Any]: requested, trade_date, previous = self._trade_dates(requested_date) cached = self._snapshot("popularity", trade_date) if cached and not force: return _standard(cached, requested) inputs = self._gateway.insight_inputs("popularity", trade_date, previous) ths = _result(inputs.get("ths")) dc = _result(inputs.get("dc")) if not ((ths and ths.rows) or (dc and dc.rows)): fallback = self._latest_snapshot("popularity", previous) if fallback: return _standard(fallback, requested, "当日榜单尚未生成,显示最近有效榜单") return _empty_standard(requested, trade_date, "该交易日暂无可用人气榜") payload = build_popularity( trade_date, ths.rows if ths else (), dc.rows if dc else (), _rows(inputs.get("previous_ths")), _rows(inputs.get("previous_dc")), ) payload["observed_at"] = datetime.now(SHANGHAI).isoformat(timespec="seconds") payload["state"] = SnapshotState.ARCHIVE.value missing = [] if ths is None: missing.append("同花顺榜单暂不可用") if dc is None: missing.append("东方财富榜单暂不可用") payload["message"] = ";".join(missing) coverage = (int(ths is not None) + int(dc is not None)) / 2 self._save( "popularity", trade_date, "", payload, SnapshotState.ARCHIVE, "tushare", coverage, ) return _standard(payload, requested) def dragon_list( self, requested_date: str | None, *, force: bool = False ) -> dict[str, Any]: requested, trade_date, previous = self._trade_dates(requested_date) raw = self._snapshot("dragon-list", trade_date) if raw is None or force: inputs = self._gateway.insight_inputs("dragon-list", trade_date) raw = { "trade_date": trade_date, "observed_at": datetime.now(SHANGHAI).isoformat(timespec="seconds"), "state": SnapshotState.ARCHIVE.value, "official": _serialized_rows(inputs.get("official")), "profiles": _serialized_rows(inputs.get("profiles")), "stocks": _serialized_rows(inputs.get("stocks")), "seats": _serialized_rows(inputs.get("seats")), } coverage = sum(value is not None for value in raw.values() if isinstance(value, list)) self._save( "dragon-list", trade_date, "", raw, SnapshotState.ARCHIVE, "tushare", min(coverage / 4, 1), ) with self._database.read() as connection: aliases = self._repository.seat_aliases(connection) result = build_dragon_list( trade_date=trade_date, official_rows=_tuple_or_none(raw.get("official")), profile_rows=_tuple_or_none(raw.get("profiles")), stock_rows=_tuple_or_none(raw.get("stocks")), seat_rows=_tuple_or_none(raw.get("seats")), aliases=aliases, ) result.update( { "requested_date": requested, "previous_date": previous, "observed_at": raw.get("observed_at"), "state": SnapshotState.ARCHIVE.value, "carried_forward": False, } ) return result def save_seat_alias(self, seat_name: str, alias_name: str, user_id: int) -> dict[str, str]: seat = " ".join(seat_name.split()) alias = " ".join(alias_name.split()) if not seat or not alias: raise MarketInsightError("营业部和游资名称不能为空") with self._database.transaction() as connection: self._repository.save_seat_alias( connection, seat, alias, datetime.now(SHANGHAI).isoformat(timespec="seconds"), user_id, ) return {"seat_name": seat, "alias_name": alias} def _trade_dates( self, requested_date: str | None, clock: datetime | None = None ) -> tuple[str, str, str]: try: requested = _date( requested_date or (clock or datetime.now(SHANGHAI)).date().isoformat() ) except ValueError as exc: raise MarketInsightError("日期格式无效") from exc dates = self._gateway.trading_dates(requested, 2) if len(dates) < 2: raise MarketInsightError("请先同步完整交易日历") return requested, dates[0], dates[1] def _previous_date(self, trade_date: str) -> str: dates = self._gateway.trading_dates(trade_date, 2) if len(dates) < 2: raise MarketInsightError("缺少前一交易日") return dates[1] def _auction_universe( self, baseline: str, inputs: dict[str, Any] ) -> tuple[str, ...]: snapshot = self._market_snapshot(baseline) codes = { str(item.get("identifier") or "") for key in ("limits", "broken") for item in snapshot.get(key) or [] } for key, data_type in (("ths_hot", "热股"), ("dc_hot", "A股市场")): for row in _rows(inputs.get(key)): valid_type = str(row.get("data_type") or "") == data_type top_twenty = int(_number(row.get("rank"), 9999)) <= 20 if valid_type and top_twenty: codes.add(str(row.get("ts_code") or "")) return tuple(sorted(code for code in codes if code)) def _market_snapshot(self, trade_date: str) -> dict[str, Any]: with self._database.read() as connection: row = self._repository.latest_summary(connection, trade_date) if row is None or str(row["trade_date"]) != trade_date: return {} return json.loads(str(row["payload_json"])) def _auction_history(self, trade_date: str) -> list[dict[str, Any]]: with self._database.read() as connection: rows = self._repository.insight_snapshots(connection, "auction", trade_date, 10) result = [] for row in rows: payload = json.loads(str(row["payload_json"])) summary = payload.get("summary") or {} result.append( { "trade_date": str(row["trade_date"]), "amount_billion": _number(summary.get("amount_billion")), "stock_count": int(summary.get("stock_count") or 0), } ) return result def _snapshot( self, kind: str, trade_date: str, entity_key: str = "" ) -> dict[str, Any] | None: with self._database.read() as connection: row = self._repository.insight_snapshot(connection, kind, trade_date, entity_key) return json.loads(str(row["payload_json"])) if row else None def _latest_snapshot( self, kind: str, through: str, entity_key: str = "" ) -> dict[str, Any] | None: with self._database.read() as connection: row = self._repository.latest_insight_snapshot(connection, kind, through, entity_key) return json.loads(str(row["payload_json"])) if row else None def _save( self, kind: str, trade_date: str, entity_key: str, payload: dict[str, Any], state: SnapshotState, source: str, coverage: float, ) -> None: with self._database.transaction() as connection: self._repository.save_insight_snapshot( connection, kind=kind, trade_date=trade_date, entity_key=entity_key, observed_at=str( payload.get("observed_at") or datetime.now(SHANGHAI).isoformat(timespec="seconds") ), state=state.value, source=source, coverage=max(0, min(coverage, 1)), payload=payload, )