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,376 @@
"""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())