Files
xiaobaifupan/app/backend/features/market/service.py
T

950 lines
40 KiB
Python

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 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 _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")
)