233 lines
10 KiB
Python
233 lines
10 KiB
Python
"""ETF 网格策略回测总报告生成器:一次跑完基准 + 三种成交模型 + 敏感性。
|
||
|
||
用法:
|
||
py -3.14 -B analysis/etf/run.py # 用缓存日线
|
||
py -3.14 -B analysis/etf/run.py --refresh # 重新抓日线
|
||
py -3.14 -B analysis/etf/run.py --ledger # 额外打印逐笔成交
|
||
|
||
输出:
|
||
analysis/etf/results.json 全部结构化结果
|
||
analysis/etf/run_report.txt 人读的汇总表
|
||
"""
|
||
|
||
import argparse
|
||
from dataclasses import replace
|
||
from datetime import date, datetime
|
||
import json
|
||
import math
|
||
import statistics
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(HERE))
|
||
|
||
from backtest import ( # noqa: E402
|
||
MIN_CASH_RATIO, OUT, REPO_DEFAULTS, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults,
|
||
EtfSymbolConfig, analyze, fees, fetch_daily, simulate,
|
||
)
|
||
from analysis import ( # noqa: E402
|
||
entry_context, exposure, monthly, open_positions, round_trips, scenario_table,
|
||
)
|
||
|
||
|
||
def attribute(result, trips, data) -> dict:
|
||
"""按标的拆解盈亏,两种口径都成立且与权益变动对齐。
|
||
|
||
``net_at_cost``:把未了结仓位按**成本**入账(卖出额 − 全部买入额 − 佣金)。
|
||
``net_at_market``:加上未实现浮动(期末市值 − 未了结成本)。
|
||
|
||
对冲校验:Σ net_at_market = 期末权益 − 期初权益。
|
||
"""
|
||
rows = {}
|
||
for code in result["books"]:
|
||
book = result["books"][code]
|
||
fills = [f for f in result["fills"] if f.code == code]
|
||
closed_profit = sum(
|
||
(f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee
|
||
for f in fills
|
||
)
|
||
unrealized = _unrealized(book, data, code)
|
||
rows[code] = {
|
||
"rounds": sum(1 for t in trips if t.code == code),
|
||
"net_at_cost": closed_profit,
|
||
"unrealized": unrealized,
|
||
"net_at_market": closed_profit + unrealized,
|
||
"open_cost": book.avg_cost * book.volume,
|
||
"open_volume": book.volume,
|
||
}
|
||
return rows
|
||
|
||
|
||
def _unrealized(book, data, code) -> float:
|
||
"""未实现浮动:期末市值 − 未了结仓位成本。"""
|
||
if book.volume <= 0:
|
||
return 0.0
|
||
close = data[code][-1]["close"]
|
||
return close * book.volume - book.avg_cost * book.volume
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--refresh", action="store_true")
|
||
parser.add_argument("--ledger", action="store_true")
|
||
args = parser.parse_args()
|
||
|
||
data = {code: fetch_daily(code, args.refresh) for code in SYMBOLS}
|
||
defaults = REPO_DEFAULTS # 直接取仓库 _etf.yaml,避免与实盘配置漂移
|
||
|
||
# 三种成交模型:乐观(按触价)/ 贴近实盘(按反弹确认价)/ 悲观(按收盘价)
|
||
modes = {}
|
||
for mode in ("touch", "bounce", "close"):
|
||
result = simulate(data, fill_mode=mode)
|
||
modes[mode] = {"stats": analyze(result), "fills": result["fills"]}
|
||
|
||
base = simulate(data, fill_mode="touch")
|
||
stats = analyze(base)
|
||
stats["fee_pct_of_buy"] = stats["fees"] / stats["buy_amount"] * 100
|
||
trips = round_trips(base["fills"])
|
||
per_symbol = attribute(base, trips, data)
|
||
unrealized = sum(r["unrealized"] for r in per_symbol.values())
|
||
equity_delta = base["curve"][-1][1] - base["start_cash"]
|
||
|
||
report = {
|
||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||
"config": {
|
||
"symbols": {
|
||
code: SYMBOL_PARAMS[code] for code in SYMBOLS
|
||
},
|
||
"defaults": {
|
||
field: getattr(defaults, field)
|
||
for field in (
|
||
"atr_period", "min_grid_pct", "max_grid_span_pct", "channel_period",
|
||
"channel_pct", "rebound_pct", "add_pct", "max_adds", "watch_seconds",
|
||
"min_profit_pct", "inner_grids", "min_hold_days", "max_hold_days",
|
||
"commission_rate", "min_commission", "max_tick_age_seconds",
|
||
)
|
||
},
|
||
"account": {"start_cash": START_CASH, "min_cash_ratio": MIN_CASH_RATIO},
|
||
},
|
||
"period": {
|
||
"first": base["curve"][0][0].isoformat(),
|
||
"last": base["curve"][-1][0].isoformat(),
|
||
"days": len(base["curve"]),
|
||
},
|
||
"data": {
|
||
code: {
|
||
"bars": len(data[code]),
|
||
"first": data[code][0]["date"],
|
||
"last": data[code][-1]["date"],
|
||
"first_close": data[code][0]["close"],
|
||
"last_close": data[code][-1]["close"],
|
||
"year_return_pct": (data[code][-1]["close"] / data[code][0]["close"] - 1) * 100,
|
||
}
|
||
for code in SYMBOLS
|
||
},
|
||
"modes": {
|
||
mode: {k: v for k, v in payload["stats"].items() if k not in ("per_symbol", "params")}
|
||
for mode, payload in modes.items()
|
||
},
|
||
"base": stats,
|
||
"exposure": exposure(base),
|
||
"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, "shares": t.shares,
|
||
"buy": round(t.buy_amount, 2), "sell": round(t.sell_amount, 2),
|
||
"profit": round(t.profit, 2), "return_pct": round(t.return_pct, 3),
|
||
"fees": round(t.fees, 2),
|
||
}
|
||
for t in trips
|
||
],
|
||
},
|
||
"attribution": {
|
||
"per_symbol": per_symbol,
|
||
"unrealized": unrealized,
|
||
"equity_delta": equity_delta,
|
||
"check": sum(r["net_at_market"] for r in per_symbol.values()),
|
||
"cash_delta": base["cash"] - base["start_cash"],
|
||
"open_cost": sum(r["open_cost"] for r in per_symbol.values()),
|
||
"open_positions": open_positions(base),
|
||
},
|
||
"monthly": monthly(base),
|
||
"entries": entry_context(data, SYMBOL_PARAMS, base),
|
||
"scenarios": scenario_table(data),
|
||
}
|
||
(OUT / "results.json").write_text(
|
||
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
|
||
lines = []
|
||
lines.append(f"回测区间 {report['period']['first']} ~ {report['period']['last']} "
|
||
f"({report['period']['days']} 个交易日)")
|
||
lines.append("")
|
||
lines.append("== 数据 ==")
|
||
for code, row in report["data"].items():
|
||
lines.append(f" {code} bars={row['bars']} {row['first']}..{row['last']} "
|
||
f"区间涨跌={row['year_return_pct']:+.2f}%")
|
||
lines.append("")
|
||
lines.append("== 三种成交模型的组合结果 ==")
|
||
lines.append(f"{'mode':8} {'net':>10} {'ret%':>7} {'maxDD%':>7} {'base':>5} {'add':>4} "
|
||
f"{'exit':>5} {'lvl':>4} {'fees':>7} {'util%':>6} {'maxDep':>8}")
|
||
for mode, row in report["modes"].items():
|
||
adds = sum(1 for f in modes[mode]["fills"] if f.kind == "add")
|
||
levels = sum(1 for f in modes[mode]["fills"] if f.kind == "level")
|
||
lines.append(
|
||
f"{mode:8} {row['net']:10.2f} {row['return_pct']:7.3f} {row['max_dd_pct']:7.3f} "
|
||
f"{row['buy_count'] - adds:5} {adds:4} "
|
||
f"{sum(1 for f in modes[mode]['fills'] if f.kind == 'exit'):5} {levels:4} "
|
||
f"{row['fees']:7.2f} {row['avg_util_pct']:6.2f} {row['max_deployed']:8.0f}"
|
||
)
|
||
lines.append("")
|
||
lines.append("== 逐标的归因 ==")
|
||
for code, row in per_symbol.items():
|
||
lines.append(f" {code} 轮次={row['rounds']:2} 已了结+未了结成本={row['net_at_cost']:9.2f} "
|
||
f"未实现={row['unrealized']:8.2f} 按市价={row['net_at_market']:9.2f} "
|
||
f"(未了结 {row['open_volume']} 股,成本 {row['open_cost']:.2f})")
|
||
lines.append(f" 按成本口径合计={sum(r['net_at_cost'] for r in per_symbol.values()):.2f}"
|
||
f" ←→ 权益变动={equity_delta:.2f}(应相等)")
|
||
lines.append(f" 按市价口径合计={sum(r['net_at_market'] for r in per_symbol.values()):.2f}"
|
||
f" = 权益变动 {equity_delta:.2f} + 未实现 {unrealized:.2f} - 持仓成本 "
|
||
f"{sum(r['open_cost'] for r in per_symbol.values()):.2f}")
|
||
lines.append("")
|
||
lines.append("== 敏感性 ==")
|
||
lines.append(f"{'label':36} {'net':>10} {'ret%':>7} {'maxDD%':>7} {'base':>5} {'add':>4} "
|
||
f"{'exit':>5} {'lvl':>4} {'fees':>7} {'util%':>6} {'maxDep':>8}")
|
||
for row in report["scenarios"]:
|
||
lines.append(
|
||
f"{row['label']:36} {row['net']:10.2f} {row['return_pct']:7.3f} {row['max_dd_pct']:7.3f} "
|
||
f"{row['bases']:5} {row['adds']:4} {row['exits']:5} {row['levels']:4} "
|
||
f"{row['fees']:7.2f} {row['avg_util_pct']:6.2f} {row['max_deployed']:8.0f}"
|
||
)
|
||
lines.append("")
|
||
lines.append("== 完整轮次明细 ==")
|
||
for t in report["round_trips"]["detail"]:
|
||
lines.append(f" {t['opened']} → {t['closed']} {t['code']} {t['days']:3}天 "
|
||
f"档位={t['levels']} 买={t['buy']:9.2f} 卖={t['sell']:9.2f} "
|
||
f"净利={t['profit']:8.2f} 收益率={t['return_pct']:6.3f}%")
|
||
if args.ledger:
|
||
lines.append("")
|
||
lines.append("== 逐笔成交(touch 模型)==")
|
||
for f in base["fills"]:
|
||
lines.append(f" {f.day} {f.code} {f.side:4} {f.kind:4} {f.volume:6} "
|
||
f"@{f.price:.3f} fee={f.fee:5.2f} {f.note}")
|
||
|
||
(OUT / "run_report.txt").write_text("\n".join(lines), encoding="utf-8")
|
||
print("\n".join(lines))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|