Files
big-qmt/labs/analysis/etf/analysis.py
2026-09-19 19:45:43 +08:00

377 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())