"""ETF 网格策略离线回测(只读分析,不修改策略代码)。 为什么要单独写:真实策略跑在 30 秒 tick 上(``strategy/etf/boot.py`` 的 RunOnce), 回测只有日线,必须把"入场区内反弹确认"和"限价单是否成交"用日线 OHLC 近似。 近似口径在 ``REPORT.md`` 里逐条列出并做了敏感性对照。 指标计算直接调用策略自己的 ``strategy.etf.signal.calculate``, 保证回测与实盘的 ATR/MA60/通道/格距口径完全一致。 用法: py -3.14 -B analysis/etf/backtest.py # 基准 + 敏感性 py -3.14 -B analysis/etf/backtest.py --refresh # 重新抓取日线 """ import argparse from dataclasses import dataclass, replace from datetime import date, datetime import json import math from pathlib import Path import statistics import sys # labs/analysis/etf/backtest.py -> labs/analysis/etf -> labs/analysis -> labs -> 仓库根 ROOT = Path(__file__).resolve().parents[3] PY_CLIENT = ROOT / "py-client" CACHE = Path(__file__).resolve().parent / "cache" OUT = Path(__file__).resolve().parent sys.path.insert(0, str(PY_CLIENT)) from config import EtfDefaults, EtfSymbolConfig # noqa: E402 from strategy.etf.signal import calculate # noqa: E402 DAILY_URL = "http://139.224.247.176:13499/etf/daily" # 直接从仓库配置读取,避免回测参数与实盘配置漂移。 ETF_YAML = PY_CLIENT / "etc" / "_etf.yaml" def load_repo_config() -> tuple[EtfDefaults, dict, tuple[str, ...]]: """读取 py-client/etc/_etf.yaml:全局默认、逐标的参数、白名单顺序。""" import yaml raw = yaml.safe_load(ETF_YAML.read_text(encoding="utf-8")) or {} defaults = EtfDefaults(**(raw.get("defaults") or {})) params = { code: dict(values or {}) for code, values in (raw.get("symbols") or {}).items() } return defaults, params, tuple(params) # 账户级参数(account_config;_etf.yaml 不含这两项,按账户配置写在这里)。 MIN_CASH_RATIO = 0.10 COMMISSION_RATE = 0.0003 MIN_COMMISSION = 5.0 # 起始资金:新配置三只各铺满 10 档约需 46.5 万,取 50 万作为"铺得开"的参考账户。 # 注意该策略按固定股数下单,绝对盈亏由 _etf.yaml 的股数决定,与账户规模无关(见 REPORT §10)。 START_CASH = 500_000.0 # 日线最后一根是 2026-09-18,用之后的日期做"今天",保证整段日线可用。 RUN_TODAY = date(2026, 9, 19) WARMUP = 61 # 导入时按仓库配置初始化,供按需调整的脚本直接使用。 REPO_DEFAULTS, REPO_PARAMS, SYMBOLS = load_repo_config() SYMBOL_PARAMS = REPO_PARAMS def fetch_daily(code: str, refresh: bool = False) -> list[dict]: """读取日线;默认用本地缓存,避免反复打接口。""" CACHE.mkdir(parents=True, exist_ok=True) path = CACHE / f"{code}.json" if refresh or not path.exists(): import urllib.request with urllib.request.urlopen(f"{DAILY_URL}?code={code}", timeout=30) as response: payload = json.load(response) path.write_text(json.dumps(payload), encoding="utf-8") rows = json.loads(path.read_text(encoding="utf-8")) bars: dict[str, dict] = {} for row in rows: if row.get("ts_code") != code: continue stamp = str(row.get("trade_date")) if len(stamp) != 8 or not stamp.isdigit(): continue day = datetime.strptime(stamp, "%Y%m%d").date() if day >= RUN_TODAY: continue values = {key: float(row[key]) for key in ("open", "high", "low", "close")} if not all(math.isfinite(v) and v > 0 for v in values.values()): continue if not (values["low"] <= values["open"] <= values["high"] and values["low"] <= values["close"] <= values["high"]): continue bars[stamp] = dict(date=stamp, **values) return [bars[stamp] for stamp in sorted(bars)] def fees(amount: float, rate: float, minimum: float) -> float: if amount <= 0: return 0.0 return max(minimum, amount * rate) @dataclass(slots=True) class Lot: volume: int cost: float # 含买入佣金 bought: date @dataclass(slots=True) class Fill: day: date code: str side: str # BUY / SELL kind: str # base / add / exit / level volume: int price: float fee: float note: str = "" class SymbolBook: """单标的网格状态;持仓档位是唯一跨轮存活的状态。""" def __init__(self, code: str, symbol: EtfSymbolConfig): self.code = code self.symbol = symbol self.lots: list[Lot] = [] self.anchor: float | None = None self.last_buy = 0.0 self.adds = 0 self.last_add_day: date | None = None self.peak_grid: int | None = None self.rounds = 0 # 主出口清仓次数 self.max_level = 0 # 曾经达到的档位数 @property def volume(self) -> int: return sum(lot.volume for lot in self.lots) @property def avg_cost(self) -> float: total = self.volume if total <= 0: return 0.0 return sum(lot.volume * lot.cost for lot in self.lots) / total def sellable(self, day: date) -> int: """当日可卖份额:is_t0 当日可卖,否则只算隔夜份额(T+1)。""" if self.symbol.is_t0: volume = self.volume else: volume = sum(lot.volume for lot in self.lots if lot.bought < day) return volume - volume % 100 def add(self, volume: int, price: float, fee: float, day: date) -> None: cost = (price * volume + fee) / volume if volume else price for lot in self.lots: if lot.bought == day: total = lot.volume + volume lot.cost = (lot.cost * lot.volume + cost * volume) / total lot.volume = total break else: self.lots.append(Lot(volume=volume, cost=cost, bought=day)) self.last_buy = price self.max_level = max(self.max_level, self.volume // self.symbol.buy_shares) def reduce(self, volume: int) -> float: """先进先出减仓,返回被减仓位的含费成本。""" removed = 0.0 left = volume while left > 0 and self.lots: lot = self.lots[0] take = min(lot.volume, left) removed += take * lot.cost lot.volume -= take left -= take if lot.volume <= 0: self.lots.pop(0) return removed def held_days(self, day: date) -> int: return min((day - lot.bought).days for lot in self.lots) if self.lots else 0 def precondition( bars: list[dict], symbol: EtfSymbolConfig, defaults: EtfDefaults ) -> list[tuple[dict, dict]]: """给每根日线预先算好指标。 关键:传给 ``calculate`` 的 ``today`` 必须是**该日线自己的日期**。 策略里 ``today`` 是"运行当天",而 ``calculate`` 会拒绝距今超过 15 个自然日的 日线(防停牌/缓存过期);回测必须逐日回放,否则整段历史都会被当成过期数据丢掉。 """ series = [] for index, bar in enumerate(bars): if index + 1 < WARMUP: continue day = datetime.strptime(bar["date"], "%Y%m%d").date() window = bars[max(0, index - 119): index + 1] # 与 signal.BAR_COUNT=120 一致 try: ind = calculate(window, symbol, defaults, day) except ValueError: continue series.append((bar, ind)) return series def fill_price( mode: str, trigger: float, bar: dict, side: str, rebound_pct: float = 0.5 ) -> float: """把"触发价 + 当日 OHLC"折算成成交价。 - ``touch``:限价单在触价当天按触价成交(最乐观,隐含着"盘中挂单必成交")。 - ``bounce``:跌到触发价后,等价格从当日最低点反弹 ``rebound_pct`` 才成交 (最贴近实盘 tick 语义,见 ``docs/etf.md`` §2.2 / §4.2)。 - ``close``:只在收盘时判断,并按收盘价成交(最悲观)。 """ if mode == "touch": return trigger if mode == "bounce": rebound = bar["low"] * (1 + rebound_pct / 100) if side == "BUY": return min(bar["close"], max(trigger, rebound)) return max(bar["close"], min(trigger, rebound)) return bar["close"] def _order_volume(sizer, cash: float, price: float, symbol: EtfSymbolConfig) -> int: """单档股数:默认用配置的 ``buy_shares``,给了 ``sizer`` 就按资金比例算。""" if sizer is None: return symbol.buy_shares volume = int(sizer(cash, price)) return max(0, volume - volume % 100) def simulate( data: dict[str, list[dict]], *, defaults: EtfDefaults | None = None, symbol_params: dict | None = None, fill_mode: str = "touch", trigger_fill: bool | None = None, secondary_exit: bool = True, min_hold_days: int | None = None, start_cash: float = START_CASH, commission_rate: float = COMMISSION_RATE, min_commission: float = MIN_COMMISSION, sizer=None, precomputed: dict | None = None, ) -> dict: """共享资金的多标的组合回测。 ``fill_mode``:``touch`` / ``bounce`` / ``close``,见 ``fill_price``。 ``trigger_fill``:兼容旧参数,False 等价于 ``close``。 ``secondary_exit``:是否启用单档峰值回撤副出口。 ``min_hold_days``:覆盖 min_hold_days(is_t0 标的仍不受限)。 ``sizer``:``(equity, price) -> 股数``,把固定股数换成按资金比例下单。 ``precomputed``:复用 ``precondition`` 结果加速扫描(其指标只依赖 symbol/defaults)。 """ if trigger_fill is not None: fill_mode = "touch" if trigger_fill else "close" defaults = defaults or EtfDefaults() params = symbol_params or SYMBOL_PARAMS hold_days = defaults.min_hold_days if min_hold_days is None else min_hold_days books = { code: SymbolBook(code, EtfSymbolConfig(**{**params[code]})) for code in data } if precomputed is not None: series = precomputed else: series = {code: precondition(data[code], books[code].symbol, defaults) for code in data} by_date = {code: {bar["date"]: (bar, ind) for bar, ind in series[code]} for code in data} calendar = sorted({stamp for code in data for stamp in by_date[code]}) latest_close = {code: 0.0 for code in data} cash = start_cash fills: list[Fill] = [] curve: list[tuple[date, float, float, float]] = [] # 日期, 权益, 持仓市值, 现金 reserve = start_cash * MIN_CASH_RATIO for stamp in calendar: day = datetime.strptime(stamp, "%Y%m%d").date() for code in sorted(data): # 白名单顺序即资金优先级 book = books[code] row = by_date[code].get(stamp) if row is None: continue bar, ind = row close, high, low = bar["close"], bar["high"], bar["low"] latest_close[code] = close entry, grid = ind["etf_entry"], ind["etf_grid"] symbol = book.symbol # 1. 主出口:盈亏率 ≥ min_profit_pct,整仓止盈(受 T+1/min_hold_days 约束) if book.volume > 0: avg = book.avg_cost target = avg * (1 + defaults.min_profit_pct / 100) can_sell = book.sellable(day) if (not symbol.is_t0) and hold_days > 0 and book.held_days(day) < hold_days: can_sell = 0 if high >= target and can_sell > 0: price = fill_price(fill_mode, target, bar, "SELL", defaults.rebound_pct) price = min(price, high) amount = price * can_sell fee = fees(amount, commission_rate, min_commission) cash += amount - fee book.reduce(can_sell) book.rounds += 1 book.peak_grid = None fills.append(Fill(day, code, "SELL", "exit", can_sell, price, fee, f"目标={target:.3f} 档位={book.max_level}")) if book.volume == 0: book.anchor, book.adds, book.last_buy, book.last_add_day = None, 0, 0.0, None continue # 2. 副出口:单档峰值回撤(只卖该档) if secondary_exit and book.volume > 0: avg = book.avg_cost pnl_rate = (close - avg) / avg * 100 current = math.floor(pnl_rate / symbol.inner_step) if book.peak_grid is None: book.peak_grid = current elif current > book.peak_grid: book.peak_grid = current elif current < book.peak_grid and book.peak_grid >= defaults.inner_grids: volume = min(book.sellable(day), symbol.buy_shares) if volume > 0: amount = close * volume fee = fees(amount, commission_rate, min_commission) cash += amount - fee book.reduce(volume) fills.append(Fill(day, code, "SELL", "level", volume, close, fee, f"峰值={book.peak_grid}格")) book.peak_grid = None # 3. 补仓:自上一档再跌 add_pct,当日收盘回到触发价之上 if book.volume > 0 and book.adds < defaults.max_adds and book.last_buy > 0: trigger = book.last_buy * (1 - defaults.add_pct / 100) room = symbol.max_shares - book.volume volume = min(_order_volume(sizer, cash, trigger, symbol), room - room % 100) if volume > 0 and low <= trigger and close > trigger and book.last_add_day != day: price = min(high, fill_price(fill_mode, trigger, bar, "BUY", defaults.rebound_pct)) amount = price * volume fee = fees(amount, commission_rate, min_commission) if amount + fee <= cash - reserve: cash -= amount + fee book.add(volume, price, fee, day) book.adds += 1 book.last_add_day = day drop = (book.last_buy / price - 1) * 100 if book.last_buy else 0.0 fills.append(Fill(day, code, "BUY", "add", volume, price, fee, f"触发={trigger:.3f} 跌幅={drop:.2f}%")) # 4. 建网:跌进入场门槛且当日收在门槛之上(反弹确认的日线近似) if book.volume == 0 and book.anchor is None and low <= entry and close > entry: price = min(high, fill_price(fill_mode, entry, bar, "BUY", defaults.rebound_pct)) volume = _order_volume(sizer, cash, entry, symbol) amount = price * volume fee = fees(amount, commission_rate, min_commission) if amount + fee <= cash - reserve: cash -= amount + fee book.add(volume, price, fee, day) book.anchor = price book.adds = 0 book.last_add_day = day book.peak_grid = None fills.append(Fill(day, code, "BUY", "base", volume, price, fee, f"门槛={entry:.3f} MA60={ind['etf_ma60']:.3f}")) market_value = sum(books[code].volume * (latest_close[code] or 0.0) for code in data) curve.append((day, cash + market_value, market_value, cash)) return { "fills": fills, "curve": curve, "books": books, "cash": cash, "start_cash": start_cash, "params": { "fill_mode": fill_mode, "secondary_exit": secondary_exit, "min_hold_days": hold_days, "add_pct": defaults.add_pct, "min_profit_pct": defaults.min_profit_pct, "channel_pct": defaults.channel_pct, "max_adds": defaults.max_adds, "commission_rate": commission_rate, "min_commission": min_commission, }, } def analyze(result: dict) -> dict: """把成交与权益曲线折算成指标。""" fills: list[Fill] = result["fills"] curve = result["curve"] buys = [f for f in fills if f.side == "BUY"] sells = [f for f in fills if f.side == "SELL"] buy_amount = sum(f.price * f.volume for f in buys) sell_amount = sum(f.price * f.volume for f in sells) fee_total = sum(f.fee for f in fills) net = (sell_amount - buy_amount) - fee_total equity = [point[1] for point in curve] peak, max_dd = -math.inf, 0.0 for value in equity: peak = max(peak, value) max_dd = max(max_dd, (peak - value) / peak) deployed = [point[2] for point in curve] initial, final = result["start_cash"], equity[-1] days = len(curve) per_symbol = {} for code, book in result["books"].items(): rows = [f for f in fills if f.code == code] per_symbol[code] = { "base": len([f for f in rows if f.kind == "base"]), "adds": len([f for f in rows if f.kind == "add"]), "exits": book.rounds, "levels": len([f for f in rows if f.kind == "level"]), "held_shares": book.volume, "max_level": book.max_level, "net": sum((f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee for f in rows), } return { "days": days, "net": net, "gross": sell_amount - buy_amount, "fees": fee_total, "fee_share_of_gross": fee_total / (sell_amount - buy_amount) * 100 if sell_amount > buy_amount else 0.0, "return_pct": (final - initial) / initial * 100, "max_dd_pct": max_dd * 100, "buy_count": len(buys), "sell_count": len(sells), "buy_amount": buy_amount, "turnover_x": buy_amount / initial, "avg_deployed": statistics.fmean(deployed) if deployed else 0.0, "avg_util_pct": (statistics.fmean(deployed) / initial * 100) if deployed else 0.0, "max_deployed": max(deployed) if deployed else 0.0, "final_equity": final, "cash": result["cash"], "per_symbol": per_symbol, "params": result["params"], } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--refresh", action="store_true") parser.add_argument("--ledger", action="store_true", help="打印逐笔成交") args = parser.parse_args() defaults = EtfDefaults() data = {code: fetch_daily(code, args.refresh) for code in SYMBOLS} for code, bars in data.items(): print(f"{code}: {len(bars)} bars {bars[0]['date']}..{bars[-1]['date']}") base = simulate(data) stats = analyze(base) print(json.dumps(stats, ensure_ascii=False, indent=2, default=str)) if args.ledger: for fill in base["fills"]: amount = fill.price * fill.volume print( f"{fill.day} {fill.code} {fill.side:4} {fill.kind:4} " f"{fill.volume:6} @{fill.price:.3f} amount={amount:10.2f} " f"fee={fill.fee:5.2f} {fill.note}" ) return 0 if __name__ == "__main__": raise SystemExit(main())