This commit is contained in:
2026-09-19 19:45:43 +08:00
parent 7183cb45f8
commit 8131b158b4
60 changed files with 7669 additions and 909 deletions

View 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_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())