This commit is contained in:
2026-09-19 19:45:43 +08:00
parent 7183cb45f8
commit 8131b158b4
60 changed files with 7669 additions and 909 deletions

232
labs/analysis/etf/run.py Normal file
View File

@@ -0,0 +1,232 @@
"""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())