339 lines
13 KiB
Python
339 lines
13 KiB
Python
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.
|
||
|
||
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
|
||
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
|
||
sw_daily 入库前接受同日15:00后的 rt_sw_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
|
||
)
|
||
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_valid:
|
||
return cached
|
||
return {
|
||
"name": "",
|
||
"code": "",
|
||
"taxonomy": "sw_l2",
|
||
"source": "tushare",
|
||
"trade_date": trade_date,
|
||
"realtime": market_mode == "intraday",
|
||
"precise": False,
|
||
"inner_precise": False,
|
||
"outer_precise": False,
|
||
"coverage": 0,
|
||
"member_count": 0,
|
||
"quote_count": 0,
|
||
"error": f"申万二级行业数据获取失败:{exc}",
|
||
}
|
||
if 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
|