"""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())