Files
xiaobai-review/backend/features/heaven/market_context.py
T
ef13d6feb5 fix(HEL-494): 盘中双免费源故障切换并禁止问天假0覆盖
主源东财失败后自动改走腾讯行情,成功结果写入缓存;两源都失败时返回最近真实快照并标明延迟,不再显示假0。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 17:38:38 +08:00

355 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
from backend.bootstrap.config import (
normalize_date,
tushare_code,
validate_stock_code,
validate_text,
)
from backend.data.providers.tushare_client import TushareError
class HeavenMarketContextMixin:
def _resolve_heaven_stock_code(self, query: str) -> str:
raw = validate_text(query, "股票代码或名称", 30, required=True)
code_match = re.fullmatch(r"(\d{6})(?:\.(?:SH|SZ|BJ))?", raw.upper())
if code_match:
return validate_stock_code(code_match.group(1))
candidates = self.database.search_stock_master(raw)
exact = [item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold()]
if not exact and self.configured:
try:
rows = self._tushare_client().query(
"stock_basic",
{"name": raw, "list_status": "L"},
"ts_code,symbol,name,industry,market,list_date",
)
except TushareError:
rows = []
if rows:
self.database.upsert_stock_master(rows)
candidates = self.database.search_stock_master(raw)
exact = [
item
for item in candidates
if str(item.get("name") or "").casefold() == raw.casefold()
]
matches = exact or candidates
if len(matches) == 1:
return validate_stock_code(str(matches[0].get("code") or ""))
if len(matches) > 1:
choices = "、".join(
f"{item.get('name') or '--'}{item.get('code') or '--'}"
for item in matches[:5]
)
raise ValueError(f"匹配到多只股票:{choices}。请输入六位股票代码。")
raise ValueError(f"未找到股票“{raw}”,请检查名称或输入六位股票代码。")
def _heaven_stock_context(
self,
stock_code: str,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str,
) -> dict[str, Any]:
"""Return the only stock contract accepted by heaven trend."""
pool_row = next(
(
dict(row) for key in ("limits", "broken", "down_limits")
for row in dashboard.get(key) or []
if str(row.get("code") or "") == stock_code
),
{},
)
if market_mode == "intraday":
if self.configured:
try:
quote = self._tushare_client().realtime_stock_quote(
tushare_code(stock_code),
trade_date,
)
return {
**quote,
"status": pool_row.get("status") or "普通",
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"precise": True,
}
except TushareError:
pass
if pool_row:
return {
**pool_row,
"data_source": "dashboard_rt" if dashboard.get("meta", {}).get("realtime") else "dashboard",
"trade_date": trade_date,
"realtime": bool(dashboard.get("meta", {}).get("realtime")),
"precise": False,
}
return {
"code": stock_code,
"name": "--",
"sector": "其他",
"trade_date": trade_date,
"realtime": False,
"precise": False,
}
detail = self.get_stock_detail(stock_code, trade_date, force=True)
detail_meta = detail.get("meta") or {}
stock = detail.get("stock") or {}
resolved_date = normalize_date(str(detail_meta.get("trade_date") or trade_date))
source = str(detail_meta.get("source") or "")
return {
"code": stock_code,
"name": stock.get("name") or pool_row.get("name") or "--",
"sector": stock.get("industry") or pool_row.get("sector") or "其他",
"status": pool_row.get("status") or "普通",
"change": stock.get("change") or 0,
"turnover_rate": stock.get("turnover_rate") or 0,
"amount_billion": stock.get("amount_billion") or 0,
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"data_source": source,
"trade_date": resolved_date,
"realtime": False,
"precise": source == "tushare" and resolved_date == trade_date,
}
def _heaven_index_context(
self,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str = "historical",
) -> dict[str, Any]:
cached = self.database.get_data_snapshot("heaven_indices", trade_date)
cached_valid = False
if cached:
cached_rows = list(cached.get("indices") or [])
cached_dates = {
str(row.get("trade_date") or "").replace("-", "")
for row in cached_rows
}
cached_valid = (
len(cached_rows) == 3
and cached_dates == {trade_date}
and bool(cached.get("precise"))
and not cached.get("realtime")
and str(cached.get("source") or "") == "tushare"
and int(cached.get("schema_version") or 0) >= 3
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
error = "Tushare Token 未配置"
else:
try:
client = self._tushare_client()
if market_mode == "intraday":
payload = self._aggregate_index_context(trade_date)
payload["schema_version"] = 3
return payload
payload = client.market_indices(trade_date)
payload["schema_version"] = 3
if market_mode == "closed":
payload["finalized"] = True
self.database.save_data_snapshot(
"heaven_indices",
trade_date,
str(payload.get("source") or "tushare"),
payload,
)
return payload
except Exception as exc:
error = str(exc)
overview = dashboard.get("overview") or {}
up_count = float(overview.get("up_count") or 0)
down_count = float(overview.get("down_count") or 0)
breadth = (up_count - down_count) / max(up_count + down_count, 1)
return {
"source": "market_breadth_proxy",
"trade_date": trade_date,
"realtime": False,
"precise": False,
"schema_version": 3,
"notice": f"指数数据不可用,当前以市场宽度代理:{error}",
"indices": [],
"aggregate": {
"average_pct_chg": round(breadth * 2.5, 3),
"average_return_5d": 0,
"average_return_20d": 0,
},
}
def _aggregate_index_context(
self,
trade_date: str,
tushare_error: str = "",
) -> dict[str, Any]:
quotes = self.realtime_aggregator.tencent_indices()
epochs = [int(item.get("quote_time_epoch") or 0) for item in quotes]
quote_dates = {
datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
for epoch in epochs if epoch
}
if len(quotes) != 3 or quote_dates != {trade_date}:
raise ValueError("腾讯三大指数日期与目标交易日不一致")
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
if max(epochs) - min(epochs) > max_skew:
raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")
code_map = {
"000001": "000001.SH",
"399001": "399001.SZ",
"399006": "399006.SZ",
}
client = self._tushare_client()
indices = []
start_date = (
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
).strftime("%Y%m%d")
for quote in quotes:
ts_code = code_map[str(quote.get("code") or "")]
history = client.query(
"index_daily",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
history.sort(key=lambda item: str(item.get("trade_date") or ""))
completed_closes = [
float(item.get("close") or 0)
for item in history
if str(item.get("trade_date") or "") < trade_date
and float(item.get("close") or 0) > 0
]
close_5d = (
completed_closes[-5]
if len(completed_closes) >= 5
else completed_closes[0] if completed_closes else 0
)
close = float(quote.get("price") or 0)
indices.append(
{
"ts_code": ts_code,
"name": quote.get("name") or ts_code,
"trade_date": trade_date,
"close": close,
"pct_chg": round(float(quote.get("change") or 0), 3),
"return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
"return_20d": 0,
"amount_billion": float(quote.get("amount_billion") or 0),
"quote_time": quote.get("quote_time") or "",
}
)
return {
"trade_date": trade_date,
"source": "+".join(
sorted({str(item.get("source") or "web_quote") for item in quotes})
+ ["tushare_index_daily"]
),
"realtime": True,
"precise": True,
"indices": indices,
"aggregate": {
"average_pct_chg": round(
sum(item["pct_chg"] for item in indices) / len(indices), 3
),
"average_return_5d": round(
sum(item["return_5d"] for item in indices) / len(indices), 3
),
"average_return_20d": 0,
},
"quote_time_skew_seconds": max(epochs) - min(epochs),
"notice": (
"指数实时行情来自腾讯行情,5日趋势来自Tushare历史指数。"
+ (f" Tushare实时指数未使用:{tushare_error}" if tushare_error else "")
),
}
def _heaven_sector_context(
self,
identifier: str,
trade_date: str,
market_mode: str = "historical",
) -> dict[str, Any] | None:
"""Return the Shenwan L2 sector context for heaven trend.
观势行业层只使用申万二级行业。外显优先使用已发布的 sw_daily,
盘中及收盘过渡期使用免费申万实时行情;内核使用数据中枢或免费
实时成分行情。不再调用无权限的 rt_sw_k / rt_k。
"""
cache_key = f"{trade_date}:{identifier.strip().lower()}"
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
cached_date = str((cached or {}).get("trade_date") or "").replace("-", "")
cached_valid = bool(
cached
and cached_date == trade_date
and cached.get("taxonomy") == "sw_l2"
and cached.get("inner_precise", cached.get("precise"))
and cached.get("outer_precise", cached.get("precise"))
and not cached.get("realtime")
and int(cached.get("schema_version") or 0) >= 6
)
cached_quotes = int((cached or {}).get("quote_count") or 0)
cached_lkg = bool(
cached
and cached_date == trade_date
and cached.get("taxonomy") == "sw_l2"
and cached_quotes > 0
and int(cached.get("schema_version") or 0) >= 6
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
return None
try:
payload = self._tushare_client().sw_sector_snapshot(
tushare_code(identifier),
trade_date,
realtime_expected=market_mode == "intraday",
allow_realtime_close=market_mode == "closed",
)
except TushareError as exc:
if cached_lkg:
delayed = dict(cached)
delayed["delayed"] = True
delayed["delay_notice"] = "主备免费行情均暂不可用,显示最近一次真实快照"
delayed["realtime"] = market_mode == "intraday"
return delayed
return {
"name": "",
"code": "",
"taxonomy": "sw_l2",
"source": "tushare",
"trade_date": trade_date,
"realtime": market_mode == "intraday",
"precise": False,
"inner_precise": False,
"outer_precise": False,
"error": f"申万二级行业数据获取失败:{exc}",
}
if int(payload.get("quote_count") or 0) > 0:
self.database.save_data_snapshot(
"heaven_sector",
cache_key,
str(payload.get("source") or "tushare"),
payload,
)
elif not payload.get("realtime") and payload.get("precise"):
self.database.save_data_snapshot(
"heaven_sector",
cache_key,
str(payload.get("source") or "tushare"),
payload,
)
return payload