Files
big-qmt/labs/analysis/etf/backtest.py
2026-09-19 19:45:43 +08:00

477 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_daysis_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())