"""ETF 网格策略回测分析:轮次统计、资金占用、逐月分布、参数敏感性。 直接调用 ``backtest.py`` 的模拟内核,不复制策略逻辑。 用法: py -3.14 -B analysis/etf/analysis.py """ from dataclasses import dataclass, replace from datetime import date, datetime import json import math from pathlib import Path import statistics import sys HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) from backtest import ( # noqa: E402 CACHE, OUT, REPO_DEFAULTS, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults, EtfSymbolConfig, analyze, fetch_daily, simulate, ) @dataclass(slots=True) class RoundTrip: """一次"建网 → 整仓清空"的完整轮次。""" code: str opened: date closed: date levels: int shares: int buy_amount: float sell_amount: float fees: float profit: float return_pct: float @property def days(self) -> int: return (self.closed - self.opened).days def round_trips(fills) -> list[RoundTrip]: """把逐笔成交切成完整轮次(按建仓日到清仓日配对)。""" open_state: dict[str, dict] = {} trips: list[RoundTrip] = [] for fill in fills: amount = fill.price * fill.volume if fill.side == "BUY": state = open_state.setdefault( fill.code, dict(opened=fill.day, buys=0.0, sells=0.0, fees=0.0, volume=0, buys_volume=0, levels=0), ) state["buys"] += amount state["fees"] += fill.fee state["volume"] += fill.volume state["buys_volume"] += fill.volume if fill.kind == "base": state["opened"] = fill.day state["levels"] = 1 else: state["levels"] += 1 else: state = open_state.get(fill.code) if state is None: continue state["sells"] += amount state["fees"] += fill.fee state["volume"] -= fill.volume if state["volume"] <= 0: profit = state["sells"] - state["buys"] - state["fees"] trips.append( RoundTrip( code=fill.code, opened=state["opened"], closed=fill.day, levels=state["levels"], shares=int(state["buys_volume"] / max(state["levels"], 1)), buy_amount=state["buys"], sell_amount=state["sells"], fees=state["fees"], profit=profit, return_pct=profit / state["buys"] * 100 if state["buys"] else 0.0, ) ) open_state.pop(fill.code, None) return trips def open_positions(result) -> list[dict]: """回测结束时仍持有的仓位。""" rows = [] for code, book in result["books"].items(): if book.volume <= 0: continue rows.append( { "code": code, "volume": book.volume, "avg_cost": book.avg_cost, "anchor": book.anchor, "max_level": book.max_level, } ) return rows def monthly(result) -> dict[str, float]: """按自然月统计净现金流与期末权益变化。""" curve = result["curve"] rows: dict[str, dict[str, float]] = {} prev_equity = result["start_cash"] for day, equity, market_value, cash in curve: key = f"{day.year}-{day.month:02d}" row = rows.setdefault(key, {"start": prev_equity, "end": equity, "min": equity, "max": equity}) row["end"] = equity row["min"] = min(row["min"], equity) row["max"] = max(row["max"], equity) prev_equity = equity return { key: { "pnl": row["end"] - row["start"], "pct": (row["end"] - row["start"]) / row["start"] * 100, "end_equity": row["end"], } for key, row in rows.items() } def exposure(result) -> dict: """资金占用与在场时间。""" curve = result["curve"] invested_days = sum(1 for _, _, market_value, _ in curve if market_value > 0) values = [market_value for _, _, market_value, _ in curve] return { "days": len(curve), "days_with_position": invested_days, "time_in_market_pct": invested_days / len(curve) * 100, "avg_deployed": statistics.fmean(values), "avg_util_pct": statistics.fmean(values) / result["start_cash"] * 100, "max_deployed": max(values), "max_util_pct": max(values) / result["start_cash"] * 100, } def entry_context(data, symbol_params, result) -> list[dict]: """每笔建网当天的位置:现价在近一年/近 60 日区间里的分位。""" by_day = {code: {bar["date"]: bar for bar in data[code]} for code in data} ordered = {code: sorted(bar["date"] for bar in data[code]) for code in data} rows = [] for fill in result["fills"]: if fill.kind != "base": continue stamp = fill.day.strftime("%Y%m%d") dates = ordered[fill.code] index = dates.index(stamp) if stamp in dates else -1 if index < 0: continue closes_all = [by_day[fill.code][d]["close"] for d in dates[: index + 1]] window60 = closes_all[-60:] price = fill.price rows.append( { "code": fill.code, "day": fill.day.isoformat(), "price": price, "pct_in_year": sum(1 for c in closes_all if c <= price) / len(closes_all) * 100, "pct_in_60d": sum(1 for c in window60 if c <= price) / len(window60) * 100, } ) return rows def grid_span(result) -> list[dict]: """每个标的的格距与阶梯跨度(对照 docs/etf.md §3.4)。""" rows = [] for code, book in result["books"].items(): symbol = book.symbol entries = [ (fill.day, fill.price) for fill in result["fills"] if fill.code == code and fill.kind == "base" ] rows.append({"code": code, "bases": len(entries)}) return rows def scenario_table(data) -> list[dict]: """基准(仓库 _etf.yaml)+ 单变量敏感性。""" base = REPO_DEFAULTS runs: list[tuple[str, dict]] = [] runs.append(("基准(当前 _etf.yaml)", {})) for value in (2.0, 4.0, 5.0): runs.append((f"add_pct={value}", {"defaults": replace(base, add_pct=value)})) for value in (0.5, 0.8, 1.5, 2.0): runs.append((f"min_profit_pct={value}", {"defaults": replace(base, min_profit_pct=value)})) for value in (10.0, 20.0, 30.0): runs.append((f"channel_pct={value}", {"defaults": replace(base, channel_pct=value)})) for value in (3, 5, 15): runs.append((f"max_adds={value}", {"defaults": replace(base, max_adds=value)})) for value in (0.0, 0.0003, 0.001): runs.append((f"佣金率={value}", { "defaults": replace(base, commission_rate=value), "commission_rate": value, })) runs.append(("无副出口", {"secondary_exit": False})) runs.append(("成交=反弹确认价(贴近实盘)", {"fill_mode": "bounce"})) runs.append(("成交=当日收盘价(悲观)", {"fill_mode": "close"})) runs.append(("无 T+1 限制(min_hold_days=0)", {"min_hold_days": 0})) # 副出口可达性:inner_step 必须小于 min_profit_pct(见 REPORT §5.2) for step in (0.2, 0.4): runs.append((f"inner_step={step}(副出口可达)", { "symbol_params": {code: {**SYMBOL_PARAMS[code], "inner_step": step} for code in SYMBOLS}, })) # 仓位规模:逐标的每档股数同乘一个系数(保持 10 档容量) for factor in (0.25, 0.5, 2.0): runs.append((f"buy_shares×{factor}", { "symbol_params": { code: {**SYMBOL_PARAMS[code], "buy_shares": max(100, int(SYMBOL_PARAMS[code]["buy_shares"] * factor)), "max_shares": max(1000, int(SYMBOL_PARAMS[code]["max_shares"] * factor))} for code in SYMBOLS }, })) # ATR 倍数整体缩放(逐标的同乘):只影响格距与跨度,不影响任何触发价位 for factor in (0.5, 2.0): runs.append((f"atr_multiplier×{factor}(仅格距)", { "symbol_params": { code: {**SYMBOL_PARAMS[code], "atr_multiplier": SYMBOL_PARAMS[code]["atr_multiplier"] * factor} for code in SYMBOLS }, })) runs.append(("channel_pct=1(贴近区间下沿)", {"defaults": replace(base, channel_pct=1.0)})) rows = [] for label, kwargs in runs: defaults = kwargs.pop("defaults", base) result = simulate(data, defaults=defaults, **kwargs) stats = analyze(result) rows.append( { "label": label, "net": stats["net"], "equity_delta": stats["final_equity"] - result["start_cash"], "return_pct": stats["return_pct"], "max_dd_pct": stats["max_dd_pct"], "bases": stats["buy_count"] - sum( 1 for f in result["fills"] if f.kind == "add" ), "adds": sum(1 for f in result["fills"] if f.kind == "add"), "exits": sum(1 for f in result["fills"] if f.kind == "exit"), "levels": sum(1 for f in result["fills"] if f.kind == "level"), "fees": stats["fees"], "avg_util_pct": stats["avg_util_pct"], "max_deployed": stats["max_deployed"], } ) return rows def main() -> int: data = {code: fetch_daily(code) for code in SYMBOLS} result = simulate(data) stats = analyze(result) stats["fee_pct_of_buy"] = stats["fees"] / stats["buy_amount"] * 100 if stats["buy_amount"] else 0.0 trips = round_trips(result["fills"]) expo = exposure(result) months = monthly(result) entries = entry_context(data, SYMBOL_PARAMS, result) scenarios = scenario_table(data) # 未实现盈亏:主出口只兑现盈利,亏损全部留在持仓里。 open_rows = [] for code, book in result["books"].items(): if book.volume <= 0: continue close = data[code][-1]["close"] market = close * book.volume cost = book.avg_cost * book.volume open_rows.append( { "code": code, "volume": book.volume, "avg_cost": book.avg_cost, "last_close": close, "cost_amount": cost, "market_amount": market, "unrealized": market - cost, "unrealized_pct": (close / book.avg_cost - 1) * 100, "max_level": book.max_level, "anchor": book.anchor, } ) unrealized = sum(row["unrealized"] for row in open_rows) open_cost = sum(row["cost_amount"] for row in open_rows) realized = stats["net"] # 轮次口径:只统计"建网 → 整仓清空"的完整轮次,不含仍在持仓里的仓位。 all_in_net = realized - open_cost per_symbol_trips = {} for code in SYMBOLS: rows = [t for t in trips if t.code == code] book = result["books"][code] held_cost = book.avg_cost * book.volume per_symbol_trips[code] = { "rounds": len(rows), "wins": sum(1 for t in rows if t.profit > 0), "net_realized_closed": sum(t.profit for t in rows), "held_cost": held_cost, "net_incl_open": sum(t.profit for t in rows) - held_cost, "avg_days": statistics.fmean([t.days for t in rows]) if rows else 0.0, "max_days": max([t.days for t in rows], default=0), "worst": min([t.profit for t in rows], default=0.0), } report = { "period": { "first": result["curve"][0][0].isoformat(), "last": result["curve"][-1][0].isoformat(), "days": len(result["curve"]), }, "base": stats, "exposure": expo, "pnl_bridge": { "realized_net": realized, "unrealized_net": unrealized, "total": realized + unrealized, "total_pct": (realized + unrealized) / result["start_cash"] * 100, "open_positions": open_rows, }, "per_symbol_rounds": per_symbol_trips, "round_trips": { "count": len(trips), "wins": sum(1 for t in trips if t.profit > 0), "losses": sum(1 for t in trips if t.profit <= 0), "avg_days": statistics.fmean([t.days for t in trips]) if trips else 0.0, "max_days": max([t.days for t in trips], default=0), "avg_levels": statistics.fmean([t.levels for t in trips]) if trips else 0.0, "max_levels": max([t.levels for t in trips], default=0), "avg_profit": statistics.fmean([t.profit for t in trips]) if trips else 0.0, "best": max([t.profit for t in trips], default=0.0), "worst": min([t.profit for t in trips], default=0.0), "detail": [ { "code": t.code, "opened": t.opened.isoformat(), "closed": t.closed.isoformat(), "days": t.days, "levels": t.levels, "buy": round(t.buy_amount, 2), "sell": round(t.sell_amount, 2), "profit": round(t.profit, 2), "return_pct": round(t.return_pct, 3), } for t in trips ], }, "open_positions": open_positions(result), "monthly": months, "entries": entries, "scenarios": scenarios, } (OUT / "results.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())