131 lines
5.6 KiB
Python
131 lines
5.6 KiB
Python
"""ETF 网格策略 vs 同期买入持有(同一区间、同一份日线)。
|
||
|
||
用法: py -3.14 -B analysis/etf/vs_hold.py
|
||
"""
|
||
|
||
import math
|
||
import statistics
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(HERE))
|
||
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
pass
|
||
|
||
from backtest import START_CASH, SYMBOLS, analyze, fetch_daily, simulate # noqa: E402
|
||
|
||
data = {code: fetch_daily(code) for code in SYMBOLS}
|
||
result = simulate(data)
|
||
stats = analyze(result)
|
||
curve = result["curve"]
|
||
start_day = curve[0][0]
|
||
cash0 = result["start_cash"]
|
||
grid_final = stats["final_equity"]
|
||
|
||
# ---- 买入持有:在回测首日按各标的收盘价等权买入,持到期末 ----
|
||
bars_by_date = {code: {b["date"]: b for b in data[code]} for code in SYMBOLS}
|
||
first_stamp = start_day.strftime("%Y%m%d")
|
||
entry = {c: bars_by_date[c][first_stamp]["close"] for c in SYMBOLS}
|
||
last_stamp = curve[-1][0].strftime("%Y%m%d")
|
||
exit_ = {c: bars_by_date[c][last_stamp]["close"] for c in SYMBOLS}
|
||
|
||
per_symbol_cash = cash0 / len(SYMBOLS)
|
||
hold_shares = {c: per_symbol_cash / entry[c] for c in SYMBOLS} # 含零股,忽略整手限制
|
||
hold_curve = []
|
||
for day, _, _, _ in curve:
|
||
stamp = day.strftime("%Y%m%d")
|
||
value = sum(hold_shares[c] * bars_by_date[c].get(stamp, {"close": entry[c]})["close"]
|
||
for c in SYMBOLS)
|
||
hold_curve.append((day, value))
|
||
hold_final = hold_curve[-1][1]
|
||
|
||
|
||
def profile(curve_points):
|
||
"""从权益曲线算总收益、最大回撤、日波动、夏普与卡玛。"""
|
||
values = [v for _, v in curve_points]
|
||
peak, max_dd = -math.inf, 0.0
|
||
for value in values:
|
||
peak = max(peak, value)
|
||
max_dd = max(max_dd, (peak - value) / peak)
|
||
rets = [values[i] / values[i - 1] - 1 for i in range(1, len(values))]
|
||
mean = statistics.fmean(rets) if rets else 0.0
|
||
sd = statistics.pstdev(rets) if len(rets) > 1 else 0.0
|
||
total = values[-1] / values[0] - 1
|
||
days = len(values)
|
||
annual = (1 + total) ** (252 / days) - 1 if days else 0.0
|
||
return {
|
||
"total_pct": total * 100,
|
||
"annual_pct": annual * 100,
|
||
"max_dd_pct": max_dd * 100,
|
||
"daily_sd_pct": sd * 100,
|
||
"sharpe": (mean / sd * math.sqrt(252)) if sd > 0 else float("nan"),
|
||
"calmar": (total / max_dd) if max_dd > 0 else float("nan"),
|
||
}
|
||
|
||
|
||
grid_curve = [(day, equity) for day, equity, _, _ in curve]
|
||
|
||
print("=" * 108)
|
||
print(f"区间 {start_day} ~ {curve[-1][0]}({len(curve)} 个交易日),账户 {cash0:,.0f} 元")
|
||
print("=" * 108)
|
||
print(f"{'策略':26} {'期末权益':>12} {'总收益':>9} {'年化':>8} {'最大回撤':>9} "
|
||
f"{'日波动':>8} {'夏普':>7} {'卡玛':>7}")
|
||
for label, points in (("ETF 网格(现配置)", grid_curve), ("等权买入持有", hold_curve)):
|
||
p = profile(points)
|
||
print(f"{label:26} {points[-1][1]:12,.2f} {p['total_pct']:8.2f}% {p['annual_pct']:7.2f}% "
|
||
f"{p['max_dd_pct']:8.2f}% {p['daily_sd_pct']:7.3f}% {p['sharpe']:7.2f} {p['calmar']:7.2f}")
|
||
|
||
print()
|
||
print("== 逐标的买入持有(同一区间)==")
|
||
print(f"{'标的':11} {'期初':>8} {'期末':>8} {'涨跌':>9} {'期间最大回撤':>12}")
|
||
for code in SYMBOLS:
|
||
bars = [b for b in data[code] if b["date"] >= first_stamp and b["date"] <= last_stamp]
|
||
closes = [b["close"] for b in bars]
|
||
peak, dd = -math.inf, 0.0
|
||
for value in closes:
|
||
peak = max(peak, value)
|
||
dd = max(dd, (peak - value) / peak)
|
||
print(f"{code:11} {closes[0]:8.3f} {closes[-1]:8.3f} "
|
||
f"{(closes[-1] / closes[0] - 1) * 100:8.2f}% {dd * 100:11.2f}%")
|
||
|
||
print()
|
||
print("== 关键口径 ==")
|
||
print(f" 网格:平均资金占用 {stats['avg_util_pct']:.2f}%(峰值 {stats['max_deployed'] / cash0 * 100:.2f}%),"
|
||
f"买入名义 {stats['buy_amount']:,.0f}(换手 {stats['turnover_x']:.2f} 倍),"
|
||
f"佣金 {stats['fees']:.2f}")
|
||
print(f" 网格:占用部分的收益率(权益变动 ÷ 平均占用)= "
|
||
f"{(grid_final - cash0) / stats['avg_deployed'] * 100:.2f}%")
|
||
print(f" 买入持有:资金 100% 占用(从未空仓),无佣金/无交易")
|
||
print(f" 网格:完整轮次 {len([f for f in result['fills'] if f.kind == 'exit'])} 次,"
|
||
f"胜率 100%(只在盈利 ≥{stats['params']['min_profit_pct']}% 时才卖)")
|
||
|
||
# ---- 同风险口径:把网格放大到与买入持有相同回撤,比收益 ----
|
||
print()
|
||
print("== 同回撤口径(把网格单档股数放大到回撤≈买入持有)==")
|
||
from dataclasses import replace # noqa: E402
|
||
from backtest import SYMBOL_PARAMS, SYMBOLS as _SYMS # noqa: E402
|
||
|
||
hold_p = profile(hold_curve)
|
||
print(f" 目标回撤:买入持有 {hold_p['max_dd_pct']:.2f}%")
|
||
print(f"{'放大倍数':>8} {'权益变动':>12} {'总收益':>9} {'最大回撤':>9} {'平均占用':>9} {'夏普':>7} {'卡玛':>7}")
|
||
for factor in (1, 4, 10, 20, 35):
|
||
params = {
|
||
c: {**SYMBOL_PARAMS[c],
|
||
"buy_shares": max(100, int(SYMBOL_PARAMS[c]["buy_shares"] * factor)),
|
||
"max_shares": max(1000, int(SYMBOL_PARAMS[c]["max_shares"] * factor))}
|
||
for c in _SYMS
|
||
}
|
||
res = simulate(data, symbol_params=params)
|
||
st = analyze(res)
|
||
pts = [(day, eq) for day, eq, _, _ in res["curve"]]
|
||
p = profile(pts)
|
||
print(f"{factor:8}× {st['final_equity'] - cash0:12,.2f} {p['total_pct']:8.2f}% "
|
||
f"{p['max_dd_pct']:8.2f}% {st['avg_util_pct']:8.2f}% {p['sharpe']:7.2f} {p['calmar']:7.2f}")
|
||
print()
|
||
print("注:放大是外推(同一段历史、同一批机会等比放大),不是新样本的验证;")
|
||
print(" 放开股数后实际能否成交/是否滑点恶化,日线回测无法回答。")
|