mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
- fuyao 客户端新增 short-term-benchmark 端点透传 - auction_benchmark 服务: 历史按日 JSON 缓存(当日不缓存)、交易日回退、 四态降级(ok/fallback_prev/source_unavailable/no_data) - 读取时从相邻日K分区现算当日/次日收益对照, 不落缓存 - 复盘 user prompt 注入「盘前风向标(竞价)」上下文 - 11 个测试覆盖回退/缓存/富化数学/上下文
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""短线风向标服务 (fuyao 专有) — 复盘页卡片 + AI 复盘上下文。
|
|
|
|
非路由数据集: tickflow 无对应能力, 直接经 custom_sources 调 fuyao provider;
|
|
fuyao 未配置时返回 source_unavailable 状态, 前端降级提示。
|
|
|
|
数据契约 (实测 2026-08-30, 60 交易日回测 353 样本):
|
|
- 每日 5~6 只, 服务端筛选的竞价异动股, 附概念标签
|
|
- 名单当日 (开盘买→收盘卖) 均值 +0.54% vs 全市场 +0.10%, 有真实当日选股能力;
|
|
但高开≥5% 子集当日 -1.97% (追高陷阱) → 前端对高开子集标「追高风险」
|
|
- 次日无显著优势 (+0.08%), 定位为「当日观察名单」而非隔夜轮动信号
|
|
|
|
缓存策略 (与 dragon_tiger 同模式):
|
|
- 历史名单不可变 → 按日落 JSON 缓存 (data/auction_benchmark/date=YYYY-MM-DD.json),
|
|
缓存命中不触发插件注册表加载
|
|
- 收益 enrich (当日oc/全天/次日) 不落缓存 — 次日数据晚到, 读取时现算
|
|
- 当日名单不缓存 (竞价阶段名单可能变动, 以现拉为准)
|
|
- 显式日期失败 → 回退上一交易日一次 (state=fallback_prev)
|
|
|
|
日期解析: 接口对显式非交易日报 code=1002, 本层用本地 kline_daily 分区日期
|
|
把目标日回退到「≤目标日的最近交易日」, 规避报错。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import date as date_cls
|
|
from pathlib import Path
|
|
|
|
import polars as pl
|
|
|
|
from app.market_time import cn_today
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DATE_DIR_RE = re.compile(r"^date=(\d{4}-\d{2}-\d{2})$")
|
|
|
|
|
|
def _local_trading_days(data_dir: Path) -> list[date_cls]:
|
|
"""本地日K分区日期 = 已知交易日集合 (升序)。扫描失败返回空。"""
|
|
root = data_dir / "kline_daily"
|
|
out: list[date_cls] = []
|
|
try:
|
|
for d in root.iterdir():
|
|
m = _DATE_DIR_RE.match(d.name)
|
|
if d.is_dir() and m:
|
|
try:
|
|
out.append(date_cls.fromisoformat(m.group(1)))
|
|
except ValueError:
|
|
continue
|
|
except OSError:
|
|
return []
|
|
return sorted(out)
|
|
|
|
|
|
def resolve_trade_date(data_dir: Path, target: date_cls | None) -> date_cls | None:
|
|
"""目标日 → ≤目标日的最近本地交易日。None → None (由 fuyao 取当日)。"""
|
|
if target is None:
|
|
return None
|
|
days = _local_trading_days(data_dir)
|
|
if not days:
|
|
return target
|
|
candidates = [d for d in days if d <= target]
|
|
return max(candidates) if candidates else target
|
|
|
|
|
|
def _prev_trading_day(data_dir: Path, d: date_cls) -> date_cls | None:
|
|
days = _local_trading_days(data_dir)
|
|
earlier = [x for x in days if x < d]
|
|
return max(earlier) if earlier else None
|
|
|
|
|
|
def _next_trading_day(data_dir: Path, d: date_cls) -> date_cls | None:
|
|
days = _local_trading_days(data_dir)
|
|
later = [x for x in days if x > d]
|
|
return min(later) if later else None
|
|
|
|
|
|
def _provider():
|
|
from app.data_providers import custom as custom_sources
|
|
|
|
if not custom_sources.is_custom_provider("fuyao"):
|
|
return None
|
|
return custom_sources.get_provider("fuyao")
|
|
|
|
|
|
def _cache_path(data_dir: Path, d: date_cls) -> Path:
|
|
return data_dir / "auction_benchmark" / f"date={d.isoformat()}.json"
|
|
|
|
|
|
def _load_cache(path: Path) -> dict | None:
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def _store_cache(path: Path, payload: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_name(path.name + ".part")
|
|
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def _raw_items(data: dict) -> list[dict]:
|
|
items = data.get("item")
|
|
return [r for r in items if isinstance(r, dict)] if isinstance(items, list) else []
|
|
|
|
|
|
def _read_kline_closes(data_dir: Path, d: date_cls) -> dict[str, float]:
|
|
"""某交易日全市场 {symbol: close}; 无分区/读失败返回空。"""
|
|
root = data_dir / "kline_daily" / f"date={d.isoformat()}"
|
|
try:
|
|
files = sorted(root.glob("*.parquet"))
|
|
if not files:
|
|
return {}
|
|
df = pl.concat([pl.read_parquet(f, columns=["symbol", "close"]) for f in files])
|
|
return dict(zip(df["symbol"].to_list(), df["close"].to_list()))
|
|
except (OSError, pl.exceptions.PolarsError):
|
|
return {}
|
|
|
|
|
|
def _enrich(data_dir: Path, trade_date: date_cls, items: list[dict]) -> list[dict]:
|
|
"""名单叠加真实收益: day0_oc (开盘买→收盘卖) / day0_pct (全天) / d1_pct (次日)。
|
|
|
|
用相邻 kline_daily 分区现算, 不落缓存 — 次日分区晚到时先给 None, 到了自然补上。
|
|
"""
|
|
root = data_dir / "kline_daily" / f"date={trade_date.isoformat()}"
|
|
day0: dict[str, tuple[float, float]] = {} # symbol -> (open, close)
|
|
try:
|
|
files = sorted(root.glob("*.parquet"))
|
|
if files:
|
|
df = pl.concat([pl.read_parquet(f, columns=["symbol", "open", "close"]) for f in files])
|
|
day0 = dict(zip(df["symbol"].to_list(), zip(df["open"].to_list(), df["close"].to_list())))
|
|
except (OSError, pl.exceptions.PolarsError):
|
|
day0 = {}
|
|
|
|
prev = _prev_trading_day(data_dir, trade_date)
|
|
prev_close = _read_kline_closes(data_dir, prev) if prev else {}
|
|
nxt = _next_trading_day(data_dir, trade_date)
|
|
next_close = _read_kline_closes(data_dir, nxt) if nxt else {}
|
|
|
|
out: list[dict] = []
|
|
for r in items:
|
|
sym = str(r.get("thscode") or "")
|
|
oc = pct = d1 = None
|
|
if sym in day0:
|
|
o, c = day0[sym]
|
|
if o and o > 0 and c is not None:
|
|
oc = c / o - 1.0
|
|
pc = prev_close.get(sym)
|
|
if pc and pc > 0 and c is not None:
|
|
pct = c / pc - 1.0
|
|
nc = next_close.get(sym)
|
|
if c and c > 0 and nc is not None:
|
|
d1 = nc / c - 1.0
|
|
out.append({
|
|
"thscode": sym,
|
|
"ticker": r.get("ticker"),
|
|
"name": r.get("name"),
|
|
"auction_pct": r.get("auction_pct"),
|
|
"tags": [str(t) for t in (r.get("tags") or [])],
|
|
"day0_oc": oc,
|
|
"day0_pct": pct,
|
|
"d1_pct": d1,
|
|
})
|
|
return out
|
|
|
|
|
|
def _base_payload(data_dir: Path, trade_date: date_cls, data: dict) -> dict:
|
|
return {
|
|
"state": "ok",
|
|
"requested_date": trade_date.isoformat(),
|
|
"trade_date": str(data.get("date") or trade_date.isoformat()),
|
|
"count": len(_raw_items(data)),
|
|
"raw_items": _raw_items(data),
|
|
}
|
|
|
|
|
|
def get_auction_benchmark(data_dir: Path, target: date_cls | None = None) -> dict:
|
|
"""取短线风向标名单 (含当日/次日真实收益)。返回给前端的统一容器。
|
|
|
|
state: ok (正常) | fallback_prev (目标日拉取失败, 已回退上一期)
|
|
| source_unavailable (未配置 fuyao) | no_data (拉取失败)
|
|
"""
|
|
days = _local_trading_days(data_dir)
|
|
if target is None:
|
|
target = max(days) if days else None
|
|
trade_date = resolve_trade_date(data_dir, target)
|
|
today = cn_today()
|
|
|
|
# 历史日缓存优先 (纯本地, 不触发插件注册表加载)
|
|
if trade_date is not None and trade_date < today:
|
|
cached = _load_cache(_cache_path(data_dir, trade_date))
|
|
if cached is not None:
|
|
return _respond(data_dir, trade_date, cached, cached.get("state") or "ok")
|
|
|
|
provider = _provider()
|
|
if provider is None:
|
|
return {"state": "source_unavailable"}
|
|
|
|
from app.plugins.fuyao.client import FuyaoError
|
|
|
|
explicit = trade_date.isoformat() if trade_date is not None else None
|
|
try:
|
|
data = provider.short_term_benchmark(explicit)
|
|
try:
|
|
actual = date_cls.fromisoformat(str(data.get("date")))
|
|
except ValueError:
|
|
actual = trade_date
|
|
base = _base_payload(data_dir, actual, data)
|
|
# 历史日不可变 → 落缓存; 当日不缓存 (竞价阶段名单可能变动)
|
|
if actual is not None and actual < today:
|
|
with contextlib.suppress(OSError):
|
|
_store_cache(_cache_path(data_dir, actual), base)
|
|
return _respond(data_dir, actual, base, "ok")
|
|
except FuyaoError as e:
|
|
# 显式日期失败 (非交易日/边界日) → 回退上一交易日一次
|
|
if trade_date is not None:
|
|
prev = _prev_trading_day(data_dir, trade_date)
|
|
if prev is not None:
|
|
try:
|
|
cached_prev = _load_cache(_cache_path(data_dir, prev))
|
|
if cached_prev is not None:
|
|
return _respond(data_dir, prev, cached_prev, "fallback_prev",
|
|
requested=explicit)
|
|
data_prev = provider.short_term_benchmark(prev.isoformat())
|
|
base = _base_payload(data_dir, prev, data_prev)
|
|
with contextlib.suppress(OSError):
|
|
_store_cache(_cache_path(data_dir, prev), base)
|
|
return _respond(data_dir, prev, base, "fallback_prev",
|
|
requested=explicit)
|
|
except FuyaoError:
|
|
pass
|
|
logger.warning("短线风向标拉取失败: %s", e)
|
|
return {"state": "no_data", "message": str(e)}
|
|
|
|
|
|
def _respond(
|
|
data_dir: Path,
|
|
trade_date: date_cls | None,
|
|
base: dict,
|
|
state: str,
|
|
requested: str | None = None,
|
|
) -> dict:
|
|
"""缓存/现拉的原始容器 → 叠加收益 enrich 后的前端容器。"""
|
|
if trade_date is None:
|
|
return {**base, "state": state}
|
|
payload = {
|
|
"state": state,
|
|
"requested_date": requested if requested is not None else base.get("requested_date"),
|
|
"trade_date": base.get("trade_date") or trade_date.isoformat(),
|
|
"count": base.get("count") or 0,
|
|
"items": _enrich(data_dir, trade_date, base.get("raw_items") or []),
|
|
}
|
|
return payload
|
|
|
|
|
|
def build_recap_context(data_dir: Path) -> str:
|
|
"""AI 复盘的盘前风向标摘要段 (纯文本, 失败返回空串不影响复盘)。"""
|
|
try:
|
|
payload = get_auction_benchmark(data_dir, None)
|
|
if payload.get("state") not in ("ok", "fallback_prev"):
|
|
return ""
|
|
items = payload.get("items") or []
|
|
if not items:
|
|
return ""
|
|
trade_date = payload.get("trade_date") or ""
|
|
lines = [f"(数据日期: {trade_date})"]
|
|
segs = []
|
|
for i in items:
|
|
seg = (f"{i.get('name')}({i.get('thscode')}) 竞价{(i.get('auction_pct') or 0):+.2f}%"
|
|
f"[{'·'.join(i.get('tags') or [])}]")
|
|
if i.get("day0_oc") is not None:
|
|
seg += f" → 当日开盘买{i['day0_oc']*100:+.2f}%"
|
|
if i.get("d1_pct") is not None:
|
|
seg += f", 次日{i['d1_pct']*100:+.2f}%"
|
|
segs.append(seg)
|
|
lines.append("盘前风向标名单: " + "; ".join(segs))
|
|
ocs = [i["day0_oc"] for i in items if i.get("day0_oc") is not None]
|
|
if ocs:
|
|
lines.append(f"名单当日(开盘买→收盘卖)均值 {sum(ocs)/len(ocs)*100:+.2f}%")
|
|
return "\n".join(lines)
|
|
except Exception as e: # noqa: BLE001 — 摘要失败不影响复盘主流程
|
|
logger.debug("盘前风向标复盘摘要构建失败: %s", e)
|
|
return ""
|