refactor: establish standalone application boundary
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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
|
||||
Reference in New Issue
Block a user