optz
This commit is contained in:
376
labs/analysis/etf/analysis.py
Normal file
376
labs/analysis/etf/analysis.py
Normal 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())
|
||||
Reference in New Issue
Block a user