feat etf
This commit is contained in:
325
labs/analysis/etf/run_sectors.py
Normal file
325
labs/analysis/etf/run_sectors.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user