"""16 只板块 ETF 名单的完整回测 + 报告生成。 与 run.py 的区别:run.py 是单标的/少标的的通用报告;本脚本针对当前 16 只 板块名单,额外输出逐标的贡献、在场标的数、以及"未了结仓位拖累"的完整归因。 用法: py -3.14 -B labs/analysis/etf/run_sectors.py [起始资金] 输出: labs/analysis/etf/results.json 结构化结果(与 run.py 同格式,可被其它脚本读) labs/analysis/etf/REPORT-16-sectors.md 人读报告 """ import json import math import statistics import sys from datetime import datetime 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 analysis import entry_context, exposure, monthly, round_trips # noqa: E402 from backtest import ( # noqa: E402 MIN_CASH_RATIO, OUT, REPO_DEFAULTS, SYMBOLS, SYMBOL_PARAMS, analyze, fetch_daily, simulate, ) CASH = float(sys.argv[1]) if len(sys.argv) > 1 else 600_000.0 data = {code: fetch_daily(code) for code in SYMBOLS} base = simulate(data, start_cash=CASH, fill_mode="touch") stats = analyze(base) stats["fee_pct_of_buy"] = stats["fees"] / stats["buy_amount"] * 100 trips = round_trips(base["fills"]) expo = exposure(base) months = monthly(base) # ---- 逐标的归因(了结 + 未实现,与权益变动严格对平)---- per_symbol = {} for code in SYMBOLS: book = base["books"][code] fills = [f for f in base["fills"] if f.code == code] closed = sum((f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee for f in fills) cost = book.avg_cost * book.volume close = data[code][-1]["close"] unreal = close * book.volume - cost if book.volume else 0.0 per_symbol[code] = { "rounds": sum(1 for t in trips if t.code == code), "closed_net": closed, "open_volume": book.volume, "open_cost": cost, "unrealized": unreal, "net_at_market": closed + unreal, "buy_notional": sum(f.price * f.volume for f in fills if f.side == "BUY"), "last_close": close, } attribution_sum = sum(r["net_at_market"] for r in per_symbol.values()) equity_delta = base["curve"][-1][1] - CASH # ---- 三种成交模型 ---- modes = {} for mode in ("touch", "bounce", "close"): result = simulate(data, start_cash=CASH, fill_mode=mode) st = analyze(result) modes[mode] = { "equity_delta": st["final_equity"] - CASH, "return_pct": st["return_pct"], "max_dd_pct": st["max_dd_pct"], "bases": sum(1 for f in result["fills"] if f.kind == "base"), "adds": sum(1 for f in result["fills"] if f.kind == "add"), "exits": sum(1 for f in result["fills"] if f.kind == "exit"), "levels": sum(1 for f in result["fills"] if f.kind == "level"), "fees": st["fees"], "avg_util_pct": st["avg_deployed"] / CASH * 100, "max_util_pct": st["max_deployed"] / CASH * 100, } # ---- 白名单的规模/流动性元数据(decide_list 里抓过,这里静态写入报告)---- SIZE_TURNOVER = { "159819.SZ": ("人工智能ETF易方达", 212.77, 4.790, "AI"), "159583.SZ": ("通信ETF富国", 49.88, 6.128, "CPO(通信代理)"), "159732.SZ": ("消费电子ETF华夏", 32.18, 3.730, "PCB(消费电子代理)"), "562500.SH": ("机器人ETF华夏", 172.96, 4.446, "人形机器人"), "515790.SH": ("光伏ETF华泰柏瑞", 48.44, 1.129, "光(光伏)"), "159992.SZ": ("创新药ETF银华", 163.14, 7.296, "创新药"), "512760.SH": ("芯片ETF国泰", 109.44, 4.516, "半导体"), "588750.SH": ("科创芯片ETF汇添富", 70.51, 2.416, "存储(科创芯片代理)"), "588160.SH": ("科创新材料ETF南方", 10.34, 1.884, "半导体材料(新材料代理)"), "159745.SZ": ("建材ETF国泰", 6.68, 0.344, "玻璃基板(建材代理)"), "159326.SZ": ("电网设备ETF华夏", 188.05, 4.059, "电力"), "159227.SZ": ("航空航天ETF华夏", 44.48, 1.006, "航空航天"), "518880.SH": ("黄金ETF华安", 1081.38, 53.855, "金属"), "515220.SH": ("煤炭ETF国泰", 99.47, 6.255, "能源"), "512880.SH": ("证券ETF国泰", 601.18, 14.535, "金融"), "510300.SH": ("沪深300ETF华泰柏瑞", 1085.98, 28.775, "宽基(保留)"), } report = { "generated_at": datetime.now().isoformat(timespec="seconds"), "universe": "16 只板块 ETF(15 板块 + 510300 宽基)", "start_cash": CASH, "config": { "defaults": { f: getattr(REPO_DEFAULTS, f) for f 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", ) }, "symbols": {c: SYMBOL_PARAMS[c] for c in SYMBOLS}, "meta": { c: {"name": v[0], "size_yi": v[1], "turnover_yi": v[2], "sector": v[3]} for c, v in SIZE_TURNOVER.items() }, "account": {"min_cash_ratio": MIN_CASH_RATIO}, }, "period": { "first": base["curve"][0][0].isoformat(), "last": base["curve"][-1][0].isoformat(), "days": len(base["curve"]), }, "modes": modes, "base": stats, "exposure": expo, "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), "max_days_code": max(trips, key=lambda t: t.days).code if trips else "", "max_days_range": ( f"{max(trips, key=lambda t: t.days).opened.isoformat()}" f"~{max(trips, key=lambda t: t.days).closed.isoformat()}" ) if trips else "", "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), }, "attribution": { "per_symbol": per_symbol, "equity_delta": equity_delta, "sum_market": attribution_sum, "cash_delta": base["cash"] - CASH, "open_cost": sum(r["open_cost"] for r in per_symbol.values()), "open_unrealized": sum(r["unrealized"] for r in per_symbol.values()), }, "monthly": months, "entries": entry_context(data, SYMBOL_PARAMS, base), } (OUT / "results.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") # ============================ Markdown 报告 ============================ winners = sorted(per_symbol.items(), key=lambda kv: -kv[1]["net_at_market"]) losers = [kv for kv in reversed(winners) if kv[1]["net_at_market"] < 0] lines = [] add = lines.append add("# ETF 网格策略回测报告(16 只板块 ETF 名单)") add("") add(f"- 配置来源:`py-client/etc/_etf.yaml`(回测直接读取)") add(f"- 名单:15 个板块各一只 + 保留 `510300.SH`,共 **{len(SYMBOLS)} 只**") add(f"- 账户:**{CASH:,.0f} 元**、`min_cash_ratio={MIN_CASH_RATIO}`、佣金 `max(5, 金额×0.0003)`") add(f"- 区间:**{report['period']['first']} ~ {report['period']['last']}" f"({report['period']['days']} 个交易日)**,日线 242 根(未复权)") add(f"- 复现:`py -3.14 -B labs/analysis/etf/run_sectors.py {CASH:.0f}`") add(f"- 选择依据与硬约束见配置头注释;分析脚本见 `labs/analysis/etf/`") add("") add("---") add("") add("## 一、结论摘要") add("") add("| 指标 | 触价成交 | 反弹价成交 | 收盘价成交 |") add("| --- | ---: | ---: | ---: |") add("| 账户权益变动 | **{:,}({:+.2f}%)** | {:,}({:+.2f}%) | {:,}({:+.2f}%) |".format( round(modes["touch"]["equity_delta"]), modes["touch"]["return_pct"], round(modes["bounce"]["equity_delta"]), modes["bounce"]["return_pct"], round(modes["close"]["equity_delta"]), modes["close"]["return_pct"])) add("| 最大回撤 | {:.2f}% | {:.2f}% | {:.2f}% |".format( modes["touch"]["max_dd_pct"], modes["bounce"]["max_dd_pct"], modes["close"]["max_dd_pct"])) add("| 底仓 / 补仓 / 主出口次数 | {} / {} / {} | {} / {} / {} | {} / {} / {} |".format( modes["touch"]["bases"], modes["touch"]["adds"], modes["touch"]["exits"], modes["bounce"]["bases"], modes["bounce"]["adds"], modes["bounce"]["exits"], modes["close"]["bases"], modes["close"]["adds"], modes["close"]["exits"])) add("| 平均资金占用 | {:.2f}% | {:.2f}% | {:.2f}% |".format( modes["touch"]["avg_util_pct"], modes["bounce"]["avg_util_pct"], modes["close"]["avg_util_pct"])) add("| 佣金 | {:.2f} | {:.2f} | {:.2f} |".format( modes["touch"]["fees"], modes["bounce"]["fees"], modes["close"]["fees"])) add("") add(f"**最关键的一句**:机会数量是 3 只名单的约 4 倍(底仓 {modes['touch']['bases']} 次)," f"但**收益几乎为零**;悲观成交假设下**直接亏损**。") add("") add("| | 旧 3 只名单 | 新 16 只名单 |") add("| --- | ---: | ---: |") add("| 底仓次数 | 33 | **{}** |".format(modes["touch"]["bases"])) add("| 完整轮次 | 32 | **{}** |".format(report["round_trips"]["count"])) add("| 权益变动(同 60 万口径换算) | +6,111 | **{:,}** |".format(round(modes["touch"]["equity_delta"]))) add("| 平均资金占用 | 2.24% | **{:.2f}%** |".format(modes["touch"]["avg_util_pct"])) add("") add("---") add("") add("## 二、逐标的归因(基准触价模型)") add("") add("| 板块 | 代码 | 名称 | 轮次 | 了结净额 | 未了结股数 | 未了结成本 | 未实现 | **净额** |") add("| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |") for code in SYMBOLS: row = per_symbol[code] meta = SIZE_TURNOVER[code] add("| {} | {} | {} | {} | {:,.0f} | {} | {:,.0f} | {:,.0f} | **{:,.0f}** |".format( meta[3], code, meta[0], row["rounds"], row["closed_net"], row["open_volume"], row["open_cost"], row["unrealized"], row["net_at_market"])) add(f"| | | | | | | **{report['attribution']['open_cost']:,.0f}** | " f"**{report['attribution']['open_unrealized']:,.0f}** | " f"**{attribution_sum:,.0f}** |") add("") add(f"对平校验:") add("") add(f"- 权益变动 `{equity_delta:,.2f}` = 已了结现金流 `{report['attribution']['cash_delta']:,.2f}` " f"+ 未了结仓位市值 `{report['attribution']['open_cost']:,.2f}` " f"+ 未实现 `{report['attribution']['open_unrealized']:,.2f}`") add(f"- 逐标的净额(市价口径)合计 `{attribution_sum:,.2f}` = 已了结现金流 " f"`{report['attribution']['cash_delta']:,.2f}` + 未实现 " f"`{report['attribution']['open_unrealized']:,.2f}`") add("") add("读法提醒:`了结净额` 是**现金流**口径(未了结仓位的买入金额被算成已花掉的钱)," "所以它为负不等于亏损;真正的亏损是 `未实现` 那一列。") add("") add(f"- **盈利 {len([1 for _, r in winners if r['net_at_market'] >= 0])} 只 / 亏损 " f"{len([1 for _, r in winners if r['net_at_market'] < 0])} 只**") add("- 亏损集中在样本期内**趋势下行**的板块,而盈利集中在震荡/上行板块:") add(" - 最差:" + "、".join(f"{SIZE_TURNOVER[c][3]} {r['net_at_market']:,.0f}" for c, r in losers[:5])) add(" - 最好:" + "、".join(f"{SIZE_TURNOVER[c][3]} +{r['net_at_market']:,.0f}" for c, r in winners[:5] if r["net_at_market"] > 0)) add("") add("---") add("") add("## 三、成交结构与资金") add("") add("| 项 | 值 |") add("| --- | ---: |") add(f"| 完整轮次 | {report['round_trips']['count']}" f"({report['round_trips']['wins']} 胜 {report['round_trips']['losses']} 负) |") add(f"| 单轮平均利润 | {report['round_trips']['avg_profit']:,.2f}" f"(最好 {report['round_trips']['best']:,.2f},最差 {report['round_trips']['worst']:,.2f}) |") add(f"| 平均持有 / 最长 | {report['round_trips']['avg_days']:.1f} 天 / " f"**{report['round_trips']['max_days']} 天**" f"({report['round_trips']['max_days_code']} {report['round_trips']['max_days_range']}) |") add(f"| 平均档位 / 最大档位 | {report['round_trips']['avg_levels']:.2f} / " f"{report['round_trips']['max_levels']} |") add(f"| 有持仓天数 | {expo['days_with_position']} / {expo['days']}" f"({expo['time_in_market_pct']:.1f}%) |") add(f"| 平均 / 峰值占用 | {expo['avg_deployed']:,.0f}({expo['avg_util_pct']:.2f}%)/ " f"{expo['max_deployed']:,.0f}({expo['max_util_pct']:.2f}%) |") add(f"| 买入名义 / 佣金 | {stats['buy_amount']:,.0f} / {stats['fees']:,.2f}" f"(占名义 {stats['fee_pct_of_buy']:.3f}%) |") add(f"| 期末未了结仓位 | {sum(r['open_volume'] for r in per_symbol.values()):,} 股," f"成本 {report['attribution']['open_cost']:,.0f} |") add("") add("**未了结仓位是本期收益的主要拖累**:" f"未了结成本 {report['attribution']['open_cost']:,.0f} 元、未实现 " f"{report['attribution']['open_unrealized']:,.0f} 元;" "策略无止损,下跌中补仓的仓位只能一直持有等回本。") add("") add("### 逐月权益") add("") add("| 月份 | 权益变动 | 幅度 |") add("| --- | ---: | ---: |") for key, row in months.items(): add(f"| {key} | {row['pnl']:+,.2f} | {row['pct']:+.3f}% |") add("") add("---") add("") add("## 四、名单的流动性与品类覆盖") add("") add("| 板块 | 代码 | 名称 | 规模(亿) | 成交额(亿) |") add("| --- | --- | --- | ---: | ---: |") for code in SYMBOLS: meta = SIZE_TURNOVER[code] flag = "" if meta[1] < 10 or meta[2] < 0.3: flag = " ⚠️" add(f"| {meta[3]}{flag} | {code} | {meta[0]} | {meta[1]:,.2f} | {meta[2]:,.3f} |") add("") add("⚠️ = 规模/成交额偏小(网格成交与冲击成本风险):`159745.SZ` 建材(玻璃基板代理)" "是建材类里唯一有流动性的品种,删掉它回测反而略好,但会失去该板块覆盖。") add("") add("品类覆盖缺口(全市场 1614 只 ETF 搜索结论):") add("") add("- **存储/内存:0 只**专属 ETF → 只能用科创芯片代理") add("- **玻璃基板:0 只**专属 ETF → 建材类仅 3 只,取其中最大者") add("- **CPO / PCB:0 只**专属 ETF → 分别用通信 / 消费电子代理") add("- **半导体材料:0 只**专属 ETF → 用科创新材料代理") add("") add("---") add("") add("## 五、结论与建议") add("") add("1. **名单可以接受**:15 个板块各自已取到当期规模/成交额最大且数据可用的标的" "(半导体、通信因份额折算断层改用替代品)。") add("2. **但这份名单把策略的结构性缺陷放大了**:机会数 ×4,收益却归零甚至转负。" "原因是**没有止损**——下跌趋势里的补仓只能一直扛," f"{len([1 for _, r in winners if r['net_at_market'] < 0])} 只标的净额为负," "把震荡标的赚的钱全部吃掉。") add("3. **优先补闸门,而不是继续调参**:单标的浮亏达 N% 停止补仓 / 强制减仓," "或让 `max_hold_days` 真正生效(现在配了也只告警不平仓)。") add("4. **接口份额折算问题要处理**:`588200/159516/588170/515880/515050/588710` 等" "在 2026-06~08 有 50%~67% 的跳空(`pre_close` 已折算、价格未折算)," "落在策略 120 根窗口内会直接算错锚点。建议让接口提供复权价," "或在 `signal.calculate` 加断层检测。") add("5. **不要动** `atr_multiplier` / `add_pct` / `channel_pct`:" "旧报告 28 组敏感性已证明无效或负优化。") add("") (OUT / "REPORT-16-sectors.md").write_text("\n".join(lines), encoding="utf-8") print("\n".join(lines))