optz
This commit is contained in:
476
labs/analysis/etf/backtest.py
Normal file
476
labs/analysis/etf/backtest.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user