mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
- 摘要行扩为净买/净卖/机构净买/机构净卖各 Top5, 四行药丸左缘固定槽对齐 - fmtVolume 负数按绝对值分级补万/亿单位 (净卖席位原显示裸数字) - 个股名称后复用 boardTag 加创/科/北板块徽章 (表格/药丸/游资chips) - 三榜表格数值列头可点击排序 (升降切换, 空值恒垫底, 切tab重置) - 游资席位金额去掉「净买」前缀 - AI 复盘提示词龙虎榜摘要四段统一 Top5
228 lines
9.1 KiB
Python
228 lines
9.1 KiB
Python
"""龙虎榜数据服务 (fuyao 专有) — 复盘页卡片 + AI 复盘上下文。
|
|
|
|
非路由数据集: tickflow 无对应能力, 直接经 custom_sources 调 fuyao provider;
|
|
fuyao 未配置时返回 source_unavailable 状态, 前端降级提示。
|
|
|
|
数据契约 (实测 2026-08, 文档的 limit_reason/amount 实际不返回):
|
|
- 三榜一次取齐: all/org 为股票表 (org 额外带机构 4 字段), hot_money 为席位表
|
|
- 历史榜单不可变 → 按日落 JSON 缓存 (data/dragon_tiger/date=YYYY-MM-DD.json)
|
|
- 当日榜单盘中未发布 → 自动回退上一交易日并标记 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
|
|
|
|
from app.market_time import cn_today
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_BOARDS = ("all", "org", "hot_money")
|
|
_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 默认)。
|
|
|
|
目标日早于全部本地分区 (极老的历史复盘) → 原样返回, 由调用方试显式日期,
|
|
失败如实报 no_data, 不静默给错日期的数据。
|
|
"""
|
|
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 _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 _fetch_boards(provider, d: date_cls) -> dict:
|
|
"""三榜取齐。任一榜失败抛 FuyaoError (整日失败, 不留半套缓存)。"""
|
|
return {
|
|
bt: _boards_of(provider, bt, d.isoformat()) for bt in _BOARDS
|
|
}
|
|
|
|
|
|
def _boards_of(provider, board_type: str, iso: str | None) -> dict:
|
|
data = provider.dragon_tiger(board_type, iso)
|
|
return {
|
|
"trade_date": data.get("trade_date"),
|
|
"stock_count": data.get("stock_count"),
|
|
"count": data.get("count"),
|
|
"stock_items": data.get("stock_items") or [],
|
|
"hot_money_items": data.get("hot_money_items") or [],
|
|
}
|
|
|
|
|
|
def _cache_path(data_dir: Path, d: date_cls) -> Path:
|
|
return data_dir / "dragon_tiger" / 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 get_dragon_tiger(data_dir: Path, target: date_cls | None = None) -> dict:
|
|
"""取龙虎榜 (三榜)。返回给前端的统一容器。
|
|
|
|
state: ok (正常) | fallback_prev (当日未发布, 已回退上一期并带上一期数据)
|
|
| source_unavailable (未配置 fuyao) | no_data (拉取失败)
|
|
"""
|
|
# 无目标日 → 最近本地交易日 (走缓存判定; 本地无任何分区才让 fuyao 自取默认)
|
|
if target is None:
|
|
days = _local_trading_days(data_dir)
|
|
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 cached
|
|
|
|
provider = _provider()
|
|
if provider is None:
|
|
return {"state": "source_unavailable"}
|
|
|
|
from app.plugins.fuyao.client import FuyaoError
|
|
|
|
# 日期未知 (target=None / 本地无分区) → 省略 date 让 fuyao 取最近已发布交易日
|
|
explicit = trade_date.isoformat() if trade_date is not None else None
|
|
try:
|
|
raw = {bt: _boards_of(provider, bt, explicit) for bt in _BOARDS}
|
|
try:
|
|
actual = date_cls.fromisoformat(str(raw["all"].get("trade_date")))
|
|
except ValueError:
|
|
actual = None
|
|
payload = {
|
|
"state": "ok",
|
|
"requested_date": explicit,
|
|
"trade_date": raw["all"].get("trade_date"),
|
|
**raw,
|
|
}
|
|
# 历史日不可变 → 落缓存; 当日不缓存 (盘中 fallback / 盘后补充都以现拉为准)
|
|
if actual is not None and actual < today:
|
|
with contextlib.suppress(OSError):
|
|
_store_cache(_cache_path(data_dir, actual), payload)
|
|
return payload
|
|
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:
|
|
base = dict(cached_prev)
|
|
base.pop("state", None)
|
|
return {**base, "state": "fallback_prev",
|
|
"requested_date": explicit}
|
|
raw_prev = _fetch_boards(provider, prev)
|
|
base = {
|
|
"requested_date": prev.isoformat(),
|
|
"trade_date": prev.isoformat(),
|
|
**raw_prev,
|
|
}
|
|
with contextlib.suppress(OSError):
|
|
_store_cache(_cache_path(data_dir, prev), {**base, "state": "ok"})
|
|
return {**base, "state": "fallback_prev",
|
|
"requested_date": explicit}
|
|
except FuyaoError:
|
|
pass
|
|
logger.warning("龙虎榜拉取失败: %s", e)
|
|
return {"state": "no_data", "message": str(e)}
|
|
|
|
|
|
def build_recap_context(data_dir: Path) -> str:
|
|
"""AI 复盘的龙虎榜摘要段 (纯文本, 失败返回空串不影响复盘)。"""
|
|
try:
|
|
payload = get_dragon_tiger(data_dir, None)
|
|
if payload.get("state") not in ("ok", "fallback_prev"):
|
|
return ""
|
|
items = payload.get("all", {}).get("stock_items") or []
|
|
org_items = payload.get("org", {}).get("stock_items") or []
|
|
hm_items = payload.get("hot_money", {}).get("hot_money_items") or []
|
|
trade_date = payload.get("trade_date") or ""
|
|
lines = [f"(数据日期: {trade_date})"]
|
|
|
|
top_buy = sorted(
|
|
[i for i in items if (i.get("net_value") or 0) > 0],
|
|
key=lambda x: x.get("net_value") or 0, reverse=True,
|
|
)[:5]
|
|
if top_buy:
|
|
lines.append("净买入居前: " + "; ".join(
|
|
f"{i.get('name')}({i.get('thscode')}) 净买{float(i.get('net_value') or 0)/1e8:.2f}亿"
|
|
f" 涨跌{(i.get('change') or 0)*100:.1f}%"
|
|
+ (" [3日榜]" if i.get("range_days") == 3 else "")
|
|
for i in top_buy))
|
|
top_sell = sorted(
|
|
[i for i in items if (i.get("net_value") or 0) < 0],
|
|
key=lambda x: x.get("net_value") or 0,
|
|
)[:5]
|
|
if top_sell:
|
|
lines.append("净卖出居前: " + "; ".join(
|
|
f"{i.get('name')} 净卖{abs(float(i.get('net_value') or 0))/1e8:.2f}亿"
|
|
for i in top_sell))
|
|
if org_items:
|
|
lines.append("机构净买居前: " + "; ".join(
|
|
f"{i.get('name')} 机构净买{float(i.get('org_net_value') or 0)/1e8:.2f}亿"
|
|
for i in sorted(org_items, key=lambda x: x.get("org_net_value") or 0, reverse=True)[:5]))
|
|
if hm_items:
|
|
lines.append("活跃游资: " + "; ".join(
|
|
f"{h.get('name')} 净买{float(h.get('buying') or 0)/1e8:.2f}亿"
|
|
for h in sorted(hm_items, key=lambda x: x.get('buying') or 0, reverse=True)[:5]))
|
|
return "\n".join(lines)
|
|
except Exception as e: # noqa: BLE001 — 摘要失败不影响复盘主流程
|
|
logger.debug("龙虎榜复盘摘要构建失败: %s", e)
|
|
return ""
|