377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""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())
|