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

131 lines
5.6 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 网格策略 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(" 放开股数后实际能否成交/是否滑点恶化,日线回测无法回答。")