mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 18:04:20 +08:00
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现: 回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标 被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、 组合体检品种费率、寻优端点费率透传。 安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、 错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。 数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/ provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、 baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作) + 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。 Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、 submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。 公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。 前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、 空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。 CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、 CI 超时与缓存、spec 补 baostock 前提。 约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""涨停生态计算(本地 vipdoc .day 文件,离线快速回算连板/炸板/跌停)。
|
||
|
||
设计要点:
|
||
|
||
- **数据源**:``vipdoc/{sh,sz}/lday/*.day``(与 strength 扫描器同款读取器
|
||
:func:`easy_tdx.offline.daily_bar.read_daily_bars`),不依赖网络;数据新鲜度
|
||
取决于本机通达信客户端的数据日期,因此结果必须携带 ``data_date`` 供前端明示。
|
||
- **涨停判定**:收盘价 == 涨停价(前收 × 涨幅上限,四舍五入到分)。
|
||
涨幅上限按代码段近似:主板(60/00) 10%、创业板(30)/科创板(68) 20%。
|
||
.day 文件无证券名称,无法识别 ST——对主板额外按 5% 判定并标记 ``st=True``
|
||
(常规股票恰收在 +5.00% 整的误报率极低,前端展示名称后可自辨)。
|
||
- **炸板**:当日 high 触及涨停价但收盘未封住(close < 涨停价)。
|
||
- **连板高度(streak)**:截至最新一根 bar 的连续涨停天数(按 bar 连续计,
|
||
停牌跳日不中断,与通行口径一致)。
|
||
- 纯函数 + 文件遍历分离,便于用合成 .day 文件做单测。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from easy_tdx.offline.daily_bar import _detect_security_type, read_daily_bars
|
||
from easy_tdx.offline.paths import resolve_vipdoc
|
||
|
||
_A_STOCK_TYPES = frozenset({"SH_A_STOCK", "SZ_A_STOCK"})
|
||
|
||
__all__ = [
|
||
"LimitUpEntry",
|
||
"LimitUpEcology",
|
||
"compute_limitup_ecology",
|
||
"compute_limitup_history",
|
||
]
|
||
|
||
|
||
def _to_cents(price: float) -> int:
|
||
"""元 → 分。.day 价格本身按 ×100 存 uint,round 消除读回的浮点表示误差。"""
|
||
return int(round(price * 100))
|
||
|
||
|
||
def _limit_price_cents(prev_cents: int, pct: int) -> int:
|
||
"""交易所涨跌停价(分):前收 × (1 ± pct%),四舍五入到分(半进位)。
|
||
|
||
纯整数运算 ``(prev_cents * (100 + pct) + 50) // 100``,与交易所逐价位
|
||
对账零差异。不能用 float 乘完再 ``floor(x*100+0.5)``:乘法在半分边界
|
||
受浮点表示误差影响,±10% 档 67/318 个、±5% 档 90/884 个价位会算低
|
||
1 分(如 33.05×1.1 → 误算 36.35,交易所 36.36),导致真实涨跌停被漏判。
|
||
"""
|
||
return (prev_cents * (100 + pct) + 50) // 100
|
||
|
||
|
||
def _limit_price(prev: float, pct: int) -> float:
|
||
"""交易所涨跌停价(元):pct 为整数百分数(正=涨停档,负=跌停档)。"""
|
||
return _limit_price_cents(_to_cents(prev), pct) / 100.0
|
||
|
||
|
||
def _limit_ratio(code: str) -> float:
|
||
"""涨幅上限:创业板/科创板 20%,其余主板 10%(ST 由调用侧按 5% 二次判定)。"""
|
||
if code.startswith(("30", "68")):
|
||
return 0.20
|
||
return 0.10
|
||
|
||
|
||
@dataclass
|
||
class LimitUpEntry:
|
||
"""单只涨停/跌停/炸板股票的回算结果。"""
|
||
|
||
code: str
|
||
market: str # SH / SZ
|
||
pct: float # 最新日涨跌幅(%,按 close/prev_close-1)
|
||
streak: int = 0 # 连续涨停/跌停天数(截至最新 bar)
|
||
st: bool = False # 主板 5% 判定(疑似 ST)
|
||
blown: bool = False # 炸板(曾触及涨停未封住)
|
||
|
||
|
||
@dataclass
|
||
class LimitUpEcology:
|
||
"""全市场涨停生态快照。"""
|
||
|
||
data_date: int # 全市场最新 bar 日期 YYYYMMDD(vipdoc 新鲜度)
|
||
total: int # 参与统计的股票数
|
||
limit_up: list[LimitUpEntry] = field(default_factory=list)
|
||
limit_down: list[LimitUpEntry] = field(default_factory=list)
|
||
blown: list[LimitUpEntry] = field(default_factory=list) # 炸板(曾涨停未封住)
|
||
|
||
def summary(self) -> dict[str, object]:
|
||
heights = [e.streak for e in self.limit_up]
|
||
touched = len(self.limit_up) + len(self.blown)
|
||
return {
|
||
"data_date": self.data_date,
|
||
"total": self.total,
|
||
"limit_up_count": len(self.limit_up),
|
||
"limit_down_count": len(self.limit_down),
|
||
"blown_count": len(self.blown),
|
||
# 炸板率 = 炸板 / (封住 + 炸板),无分母时为 None
|
||
"blown_rate": round(len(self.blown) / touched * 100, 1) if touched else None,
|
||
"max_streak": max(heights) if heights else 0,
|
||
"first_board": sum(1 for h in heights if h == 1),
|
||
"second_board": sum(1 for h in heights if h == 2),
|
||
"plus3": sum(1 for h in heights if h >= 3),
|
||
}
|
||
|
||
|
||
def _entry_from_closes(
|
||
closes: list[float],
|
||
last_high: float,
|
||
market: str,
|
||
code: str,
|
||
) -> LimitUpEntry | None:
|
||
"""从收盘价序列判定最新交易日的涨停/跌停/炸板与连板高度。
|
||
|
||
Args:
|
||
closes: 最近若干根 bar 的收盘价(时间升序,最后一根 = 数据日)。
|
||
last_high: 数据日的最高价(炸板判定用)。
|
||
market: SH / SZ。
|
||
code: 6 位代码。
|
||
"""
|
||
if len(closes) < 2:
|
||
return None
|
||
prev = closes[-2]
|
||
if prev <= 0:
|
||
return None
|
||
|
||
pct = (closes[-1] / prev - 1.0) * 100.0
|
||
entry = LimitUpEntry(code=code, market=market, pct=round(pct, 2))
|
||
|
||
up_pct = 20 if _limit_ratio(code) == 0.20 else 10
|
||
# 全程分币整数比较,杜绝浮点舍入在半分边界错 1 分(漏判涨跌停)
|
||
cents = [_to_cents(c) for c in closes]
|
||
high_cents = _to_cents(last_high)
|
||
prev_c = cents[-2]
|
||
limit_up_c = _limit_price_cents(prev_c, up_pct)
|
||
limit_down_c = _limit_price_cents(prev_c, -up_pct)
|
||
# 主板 5%:疑似 ST 涨停。低价股(< 3 元)最小报价单位 0.01 占比过大,
|
||
# +5% 整的巧合概率骤增,跳过 ST 判定(宁可漏报不误报)。
|
||
st_applicable = up_pct == 10 and prev >= 3.0
|
||
st_price_c = _limit_price_cents(prev_c, 5) if st_applicable else None
|
||
st_down_price_c = _limit_price_cents(prev_c, -5) if st_applicable else None
|
||
|
||
def _is_up(i: int) -> bool:
|
||
"""第 i 根是否涨停(用第 i-1 根收盘作前收;ST/3 元门槛逐 bar 判定,
|
||
避免「按最新前收定性整段历史」在价格穿越 3 元时漏计/多计)。"""
|
||
if i < 1:
|
||
return False
|
||
p_c = cents[i - 1]
|
||
c_c = cents[i]
|
||
if c_c == _limit_price_cents(p_c, up_pct):
|
||
return True
|
||
return up_pct == 10 and p_c >= 300 and c_c == _limit_price_cents(p_c, 5)
|
||
|
||
# 连板高度(截至最后一根)
|
||
streak = 0
|
||
i = len(closes) - 1
|
||
while i >= 1 and _is_up(i):
|
||
streak += 1
|
||
i -= 1
|
||
entry.streak = streak
|
||
entry.st = bool(streak > 0 and st_price_c is not None and cents[-1] == st_price_c)
|
||
|
||
if streak > 0:
|
||
entry.blown = False
|
||
return entry
|
||
|
||
# 未封住的场合:炸板(high 触及涨停价)或跌停。
|
||
# 口径说明:炸板仅按 10%/20% 档判定——.day 文件无法识别 ST,若对主板
|
||
# 额外按 5% 判炸板,非 ST 股恰好摸到 +5.00% 的会误报,故维持漏报方向。
|
||
if high_cents == limit_up_c:
|
||
entry.blown = True
|
||
return entry
|
||
|
||
if cents[-1] == limit_down_c or (st_down_price_c is not None and cents[-1] == st_down_price_c):
|
||
down_streak = 0
|
||
j = len(closes) - 1
|
||
while j >= 1:
|
||
p_c = cents[j - 1]
|
||
c_c = cents[j]
|
||
hit = c_c == _limit_price_cents(p_c, -up_pct) or (
|
||
up_pct == 10 and p_c >= 300 and c_c == _limit_price_cents(p_c, -5)
|
||
)
|
||
if not hit:
|
||
break
|
||
down_streak += 1
|
||
j -= 1
|
||
entry.streak = down_streak
|
||
return entry
|
||
return None
|
||
|
||
|
||
def compute_limitup_ecology(
|
||
vipdoc_path: str | Path | None = None,
|
||
*,
|
||
max_files: int = 20000,
|
||
) -> LimitUpEcology:
|
||
"""扫描全市场 .day 文件,回算最新交易日的涨停生态。
|
||
|
||
Args:
|
||
vipdoc_path: vipdoc 目录,None 则自动检测。
|
||
max_files: 文件数上限(防意外巨量文件拖死扫描)。
|
||
|
||
Returns:
|
||
:class:`LimitUpEcology`;vipdoc 不可用时 total=0。
|
||
"""
|
||
eco = LimitUpEcology(data_date=0, total=0)
|
||
try:
|
||
vipdoc = resolve_vipdoc(vipdoc_path)
|
||
except Exception: # noqa: BLE001 — 路径不存在/自动检测失败:按空数据处理
|
||
return eco
|
||
if not vipdoc.is_dir():
|
||
return eco
|
||
|
||
files: list[tuple[Path, str, str]] = []
|
||
for exchange in ("sz", "sh"):
|
||
lday_dir = vipdoc / exchange / "lday"
|
||
if not lday_dir.is_dir():
|
||
continue
|
||
for filepath in sorted(lday_dir.glob("*.day")):
|
||
if _detect_security_type(filepath.name) not in _A_STOCK_TYPES:
|
||
continue
|
||
code = filepath.name.lower()[2:8]
|
||
files.append((filepath, exchange.upper(), code))
|
||
if len(files) >= max_files:
|
||
break
|
||
if len(files) >= max_files:
|
||
break
|
||
eco.total = len(files)
|
||
|
||
# 一遍读取,仅保留尾部收盘/最高价;随后按"最后一根日期 == 全市场最新交易日"
|
||
# 过滤——vipdoc 里大量文件因停牌/退市/未下载而停在历史日期,若不过滤会把
|
||
# 多年前的"涨停"当成今天的(真实教训:退市前仙股文件冒出 5 连板)。
|
||
_TAIL = 13 # 连板判定最多回看 12 根 + 判定用前收
|
||
scanned: list[tuple[int, str, str, list[float], list[float]]] = []
|
||
for filepath, market, code in files:
|
||
try:
|
||
bars = read_daily_bars(filepath)
|
||
except Exception: # noqa: BLE001 — 单文件损坏不阻塞整体
|
||
continue
|
||
if len(bars) < 2:
|
||
continue
|
||
tail = bars[-_TAIL:]
|
||
last_date = bars[-1].year * 10000 + bars[-1].month * 100 + bars[-1].day
|
||
scanned.append(
|
||
(
|
||
last_date,
|
||
market,
|
||
code,
|
||
[b.close for b in tail],
|
||
[b.high for b in tail],
|
||
)
|
||
)
|
||
if last_date > eco.data_date:
|
||
eco.data_date = last_date
|
||
|
||
for last_date, market, code, closes, highs in scanned:
|
||
if last_date != eco.data_date:
|
||
continue # 数据不新鲜(停牌/退市/未下载),不参与今日生态
|
||
entry = _entry_from_closes(closes, highs[-1], market, code)
|
||
if entry is None:
|
||
continue
|
||
if entry.blown:
|
||
eco.blown.append(entry)
|
||
elif entry.pct > 0 and entry.streak > 0:
|
||
eco.limit_up.append(entry)
|
||
elif entry.pct < 0 and entry.streak > 0:
|
||
eco.limit_down.append(entry)
|
||
|
||
eco.limit_up.sort(key=lambda e: (-e.streak, -e.pct))
|
||
eco.limit_down.sort(key=lambda e: (-e.streak, e.pct))
|
||
eco.blown.sort(key=lambda e: -e.pct)
|
||
return eco
|
||
|
||
|
||
def compute_limitup_history(
|
||
vipdoc_path: str | Path | None = None,
|
||
*,
|
||
days: int = 60,
|
||
max_files: int = 20000,
|
||
) -> list[dict[str, int]]:
|
||
"""逐日统计最近 ``days`` 个交易日的涨停/跌停家数(离线回补,无需采样积累)。
|
||
|
||
与 :func:`compute_limitup_ecology` 的"只看最新交易日"不同,本函数把每只股票
|
||
窗口内的每一根 bar 都按同一涨停判定规则计数——历史日期上它就是当时真实的
|
||
涨停家数(陈旧文件在此是合法的历史数据,无污染问题)。
|
||
|
||
Returns:
|
||
按 date 升序的 ``[{"date": YYYYMMDD, "limit_up": n, "limit_down": m}]``;
|
||
vipdoc 不可用时返回空列表。
|
||
"""
|
||
try:
|
||
vipdoc = resolve_vipdoc(vipdoc_path)
|
||
except Exception: # noqa: BLE001 — 路径不存在/自动检测失败:按空数据处理
|
||
return []
|
||
|
||
counts: dict[int, dict[str, int]] = {}
|
||
if not vipdoc.is_dir():
|
||
return []
|
||
|
||
n_files = 0
|
||
for exchange in ("sz", "sh"):
|
||
lday_dir = vipdoc / exchange / "lday"
|
||
if not lday_dir.is_dir():
|
||
continue
|
||
for filepath in sorted(lday_dir.glob("*.day")):
|
||
if _detect_security_type(filepath.name) not in _A_STOCK_TYPES:
|
||
continue
|
||
code = filepath.name.lower()[2:8]
|
||
try:
|
||
bars = read_daily_bars(filepath)
|
||
except Exception: # noqa: BLE001 — 单文件损坏不阻塞整体
|
||
continue
|
||
tail = bars[-(days + 13) :]
|
||
if len(tail) < 2:
|
||
continue
|
||
n_files += 1
|
||
if n_files >= max_files:
|
||
break
|
||
up_pct = 20 if _limit_ratio(code) == 0.20 else 10
|
||
cents = [_to_cents(b.close) for b in tail]
|
||
date_ints = [b.year * 10000 + b.month * 100 + b.day for b in tail]
|
||
for i in range(1, len(tail)):
|
||
p_c, c_c = cents[i - 1], cents[i]
|
||
if p_c <= 0:
|
||
continue
|
||
d = date_ints[i]
|
||
bucket = counts.setdefault(d, {"limit_up": 0, "limit_down": 0})
|
||
if c_c == _limit_price_cents(p_c, up_pct) or (
|
||
up_pct == 10 and p_c >= 300 and c_c == _limit_price_cents(p_c, 5)
|
||
):
|
||
bucket["limit_up"] += 1
|
||
elif c_c == _limit_price_cents(p_c, -up_pct) or (
|
||
up_pct == 10 and p_c >= 300 and c_c == _limit_price_cents(p_c, -5)
|
||
):
|
||
bucket["limit_down"] += 1
|
||
if n_files >= max_files:
|
||
break
|
||
|
||
recent = sorted(counts)[-days:] if days > 0 else []
|
||
return [
|
||
{
|
||
"date": d,
|
||
"limit_up": counts[d]["limit_up"],
|
||
"limit_down": counts[d]["limit_down"],
|
||
}
|
||
for d in recent
|
||
]
|