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

233 lines
10 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 网格策略回测总报告生成器:一次跑完基准 + 三种成交模型 + 敏感性。
用法:
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())