This commit is contained in:
2026-09-21 12:38:13 +08:00
parent bcacbed427
commit ed6d4ee7a5
14 changed files with 2458 additions and 1073 deletions

2
.gitignore vendored
View File

@@ -18,7 +18,7 @@
# Dependency directories (remove the comment below to include it)
# vendor/
logs/
labs/analysis/etf/cache
# Go workspace file
go.work
go.work.sum

View File

@@ -0,0 +1,139 @@
# ETF 网格策略回测报告16 只板块 ETF 名单)
- 配置来源:`py-client/etc/_etf.yaml`(回测直接读取)
- 名单15 个板块各一只 + 保留 `510300.SH`,共 **16 只**
- 账户:**600,000 元**、`min_cash_ratio=0.1`、佣金 `max(5, 金额×0.0003)`
- 区间:**2025-12-22 ~ 2026-09-18182 个交易日)**,日线 242 根(未复权)
- 复现:`py -3.14 -B labs/analysis/etf/run_sectors.py 600000`
- 选择依据与硬约束见配置头注释;分析脚本见 `labs/analysis/etf/`
---
## 一、结论摘要
| 指标 | 触价成交 | 反弹价成交 | 收盘价成交 |
| --- | ---: | ---: | ---: |
| 账户权益变动 | **75+0.01%** | 3,188+0.53% | -10,322-1.72% |
| 最大回撤 | 0.67% | 0.59% | 2.80% |
| 底仓 / 补仓 / 主出口次数 | 131 / 32 / 122 | 128 / 31 / 120 | 87 / 62 / 76 |
| 平均资金占用 | 3.53% | 3.84% | 7.35% |
| 佣金 | 1436.83 | 1396.19 | 1131.25 |
**最关键的一句**:机会数量是 3 只名单的约 4 倍(底仓 131 次),但**收益几乎为零**;悲观成交假设下**直接亏损**。
| | 旧 3 只名单 | 新 16 只名单 |
| --- | ---: | ---: |
| 底仓次数 | 33 | **131** |
| 完整轮次 | 32 | **123** |
| 权益变动(同 60 万口径换算) | +6,111 | **75** |
| 平均资金占用 | 2.24% | **3.53%** |
---
## 二、逐标的归因(基准触价模型)
| 板块 | 代码 | 名称 | 轮次 | 了结净额 | 未了结股数 | 未了结成本 | 未实现 | **净额** |
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| AI | 159819.SZ | 人工智能ETF易方达 | 11 | 298 | 0 | 0 | 0 | **298** |
| CPO通信代理 | 159583.SZ | 通信ETF富国 | 11 | 279 | 0 | 0 | 0 | **279** |
| PCB消费电子代理 | 159732.SZ | 消费电子ETF华夏 | 12 | 268 | 0 | 0 | 0 | **268** |
| 人形机器人 | 562500.SH | 机器人ETF华夏 | 9 | -3,575 | 4000 | 3,711 | 25 | **-3,550** |
| 光(光伏) | 515790.SH | 光伏ETF华泰柏瑞 | 11 | -4,683 | 6000 | 4,858 | 32 | **-4,652** |
| 创新药 | 159992.SZ | 创新药ETF银华 | 12 | 214 | 0 | 0 | 0 | **214** |
| 半导体 | 512760.SH | 芯片ETF国泰 | 2 | -3,315 | 2000 | 3,373 | -1,173 | **-4,488** |
| 存储(科创芯片代理) | 588750.SH | 科创芯片ETF汇添富 | 9 | 394 | 0 | 0 | 0 | **394** |
| 半导体材料(新材料代理) | 588160.SH | 科创新材料ETF南方 | 12 | 202 | 0 | 0 | 0 | **202** |
| 玻璃基板(建材代理) | 159745.SZ | 建材ETF国泰 | 4 | -7,993 | 14000 | 8,024 | -758 | **-8,751** |
| 电力 | 159326.SZ | 电网设备ETF华夏 | 2 | -7,853 | 4000 | 7,922 | -1,262 | **-9,115** |
| 航空航天 | 159227.SZ | 航空航天ETF华夏 | 1 | -7,303 | 6000 | 7,406 | -1,250 | **-8,553** |
| 金属 | 518880.SH | 黄金ETF华安 | 7 | 853 | 0 | 0 | 0 | **853** |
| 能源 | 515220.SH | 煤炭ETF国泰 | 4 | 27 | 0 | 0 | 0 | **27** |
| 金融 | 512880.SH | 证券ETF国泰 | 3 | -2,006 | 2000 | 2,105 | 9 | **-1,996** |
| 宽基(保留) | 510300.SH | 沪深300ETF华泰柏瑞 | 13 | -7,918 | 2000 | 9,244 | -80 | **-7,998** |
| | | | | | | **46,643** | **-4,457** | **-46,568** |
对平校验:
- 权益变动 `74.52` = 已了结现金流 `-42,111.48` + 未了结仓位市值 `46,642.66` + 未实现 `-4,456.66`
- 逐标的净额(市价口径)合计 `-46,568.14` = 已了结现金流 `-42,111.48` + 未实现 `-4,456.66`
读法提醒:`了结净额` 是**现金流**口径(未了结仓位的买入金额被算成已花掉的钱),所以它为负不等于亏损;真正的亏损是 `未实现` 那一列。
- **盈利 8 只 / 亏损 8 只**
- 亏损集中在样本期内**趋势下行**的板块,而盈利集中在震荡/上行板块:
- 最差:电力 -9,115、玻璃基板建材代理 -8,751、航空航天 -8,553、宽基保留 -7,998、光光伏 -4,652
- 最好:金属 +853、存储科创芯片代理 +394、AI +298、CPO通信代理 +279、PCB消费电子代理 +268
---
## 三、成交结构与资金
| 项 | 值 |
| --- | ---: |
| 完整轮次 | 123122 胜 1 负) |
| 单轮平均利润 | 36.84(最好 342.33,最差 -23.57 |
| 平均持有 / 最长 | 4.5 天 / **156 天**512880.SH 2026-01-26~2026-07-01 |
| 平均档位 / 最大档位 | 1.16 / 5 |
| 有持仓天数 | 164 / 18290.1% |
| 平均 / 峰值占用 | 21,1743.53%/ 76,08012.68% |
| 买入名义 / 佣金 | 565,362 / 1,436.83(占名义 0.254% |
| 期末未了结仓位 | 40,000 股,成本 46,643 |
**未了结仓位是本期收益的主要拖累**:未了结成本 46,643 元、未实现 -4,457 元;策略无止损,下跌中补仓的仓位只能一直持有等回本。
### 逐月权益
| 月份 | 权益变动 | 幅度 |
| --- | ---: | ---: |
| 2025-12 | +0.00 | +0.000% |
| 2026-01 | -8.04 | -0.001% |
| 2026-02 | +304.69 | +0.051% |
| 2026-03 | -3,178.02 | -0.529% |
| 2026-04 | +2,476.19 | +0.415% |
| 2026-05 | -162.25 | -0.027% |
| 2026-06 | +1,461.48 | +0.244% |
| 2026-07 | -2,139.62 | -0.356% |
| 2026-08 | +1,158.62 | +0.194% |
| 2026-09 | +161.46 | +0.027% |
---
## 四、名单的流动性与品类覆盖
| 板块 | 代码 | 名称 | 规模(亿) | 成交额(亿) |
| --- | --- | --- | ---: | ---: |
| AI | 159819.SZ | 人工智能ETF易方达 | 212.77 | 4.790 |
| CPO通信代理 | 159583.SZ | 通信ETF富国 | 49.88 | 6.128 |
| PCB消费电子代理 | 159732.SZ | 消费电子ETF华夏 | 32.18 | 3.730 |
| 人形机器人 | 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华安 | 1,081.38 | 53.855 |
| 能源 | 515220.SH | 煤炭ETF国泰 | 99.47 | 6.255 |
| 金融 | 512880.SH | 证券ETF国泰 | 601.18 | 14.535 |
| 宽基(保留) | 510300.SH | 沪深300ETF华泰柏瑞 | 1,085.98 | 28.775 |
⚠️ = 规模/成交额偏小(网格成交与冲击成本风险):`159745.SZ` 建材(玻璃基板代理)是建材类里唯一有流动性的品种,删掉它回测反而略好,但会失去该板块覆盖。
品类覆盖缺口(全市场 1614 只 ETF 搜索结论):
- **存储/内存0 只**专属 ETF → 只能用科创芯片代理
- **玻璃基板0 只**专属 ETF → 建材类仅 3 只,取其中最大者
- **CPO / PCB0 只**专属 ETF → 分别用通信 / 消费电子代理
- **半导体材料0 只**专属 ETF → 用科创新材料代理
---
## 五、结论与建议
1. **名单可以接受**15 个板块各自已取到当期规模/成交额最大且数据可用的标的(半导体、通信因份额折算断层改用替代品)。
2. **但这份名单把策略的结构性缺陷放大了**:机会数 ×4收益却归零甚至转负。原因是**没有止损**——下跌趋势里的补仓只能一直扛8 只标的净额为负,把震荡标的赚的钱全部吃掉。
3. **优先补闸门,而不是继续调参**:单标的浮亏达 N% 停止补仓 / 强制减仓,或让 `max_hold_days` 真正生效(现在配了也只告警不平仓)。
4. **接口份额折算问题要处理**`588200/159516/588170/515880/515050/588710` 等在 2026-06~08 有 50%~67% 的跳空(`pre_close` 已折算、价格未折算),落在策略 120 根窗口内会直接算错锚点。建议让接口提供复权价,或在 `signal.calculate` 加断层检测。
5. **不要动** `atr_multiplier` / `add_pct` / `channel_pct`:旧报告 28 组敏感性已证明无效或负优化。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,80 @@
"""校验候选 ETF 在策略数据源上有足够日线(策略要求 >=61 根,这里按最新 120 根缓存)。
用法: py -3.14 -B labs/analysis/etf/check_candidates.py
"""
import json
import sys
import urllib.request
from datetime import datetime, date
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 backtest import DAILY_URL, CACHE # noqa: E402
CANDIDATES = {
"AI": "159819.SZ",
"CPO": "515880.SH",
"PCB": "159732.SZ",
"人形机器人": "562500.SH",
"": "515790.SH",
"创新药": "159992.SZ",
"半导体": "588200.SH",
"存储": "588750.SH",
"半导体材料": "588160.SH",
"玻璃基板": "159745.SZ",
"电力": "159326.SZ",
"航空航天": "159227.SZ",
"金属": "518880.SH",
"金融": "512880.SH",
"能源": "515220.SH",
"宽基(保留)": "510300.SH",
}
def probe(code: str) -> dict:
request = urllib.request.Request(
f"{DAILY_URL}?code={code}",
headers={"User-Agent": "Mozilla/5.0"},
)
with urllib.request.urlopen(request, timeout=30) as response:
rows = json.load(response)
if not isinstance(rows, list) or not rows:
return {"bars": 0}
CACHE.mkdir(parents=True, exist_ok=True)
(CACHE / f"{code}.json").write_text(json.dumps(rows), encoding="utf-8")
bars = sorted(rows, key=lambda r: str(r["trade_date"]))
closes = [float(b["close"]) for b in bars]
return {
"bars": len(bars),
"first": str(bars[0]["trade_date"]),
"last": str(bars[-1]["trade_date"]),
"close": closes[-1],
"min": min(closes),
"max": max(closes),
}
print(f"{'板块':12} {'代码':11} {'根数':>5} {'首日':>9} {'末日':>9} {'最新价':>8} {'区间低':>8} {'区间高':>8} 判定")
ok = True
for sector, code in CANDIDATES.items():
try:
info = probe(code)
except Exception as exc:
print(f"{sector:12} {code:11} 抓取失败:{exc}")
ok = False
continue
bars = info.get("bars", 0)
verdict = "OK" if bars >= 61 else ("不足 61 根 → 策略会跳过" if bars else "无数据 → 策略会跳过")
if bars < 61:
ok = False
print(f"{sector:12} {code:11} {bars:5} {str(info.get('first','-')):>9} {str(info.get('last','-')):>9} "
f"{info.get('close', 0):8.3f} {info.get('min', 0):8.3f} {info.get('max', 0):8.3f} {verdict}")
print()
print("全部可用" if ok else "存在数据不足的标的,需替换或接受其被跳过")

View File

@@ -0,0 +1,55 @@
"""扫描候选 ETF 的日线是否存在"份额折算/拆分"造成的价格断层。
接口无复权若有拆分MA60/ATR 会被污染 → 策略会在错误价位建网。
判定:单日收盘跳空超过 15% 视为异常ETF 涨跌停一般 ±10%)。
用法: py -3.14 -B labs/analysis/etf/check_gaps.py
"""
import json
import sys
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 backtest import CACHE # noqa: E402
from check_candidates import CANDIDATES # noqa: E402
def scan(code: str) -> list[tuple[str, str, float, float, float]]:
path = CACHE / f"{code}.json"
if not path.exists():
return []
rows = sorted(json.loads(path.read_text(encoding="utf-8")), key=lambda r: str(r["trade_date"]))
out = []
for prev, cur in zip(rows, rows[1:]):
a, b = float(prev["close"]), float(cur["close"])
if a <= 0:
continue
change = (b / a - 1) * 100
if abs(change) > 15:
out.append((str(prev["trade_date"]), str(cur["trade_date"]), a, b, change))
return out
print(f"{'板块':12} {'代码':11} {'异常跳空':>8} 明细")
flagged = []
for sector, code in CANDIDATES.items():
gaps = scan(code)
detail = "; ".join(f"{a}->{b}: {x:.2f}{y:.2f} ({c:+.1f}%)" for a, b, x, y, c in gaps[:3])
print(f"{sector:12} {code:11} {len(gaps):8} {detail}")
if gaps:
flagged.append((sector, code, gaps))
print()
if flagged:
print("⚠️ 以下标的日线存在断层(多半是份额折算/拆分,接口无复权)→ 网格锚点会被污染:")
for sector, code, gaps in flagged:
print(f" {sector}({code}){len(gaps)} 处,最大 {max(abs(g[4]) for g in gaps):.1f}%")
else:
print("未发现异常跳空")

View File

@@ -0,0 +1,91 @@
"""名单决策依据:
1) 逐标的对组合的净贡献(了结 + 未实现)
2) 规模/成交额(流动性)是否够网格用
3) 是否还有更贴题的品类 ETF如真正的"存储"
4) 剔除弱标的后的组合结果对照
用法: py -3.14 -B labs/analysis/etf/decide_list.py
"""
import sys
import urllib.request
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 backtest import SYMBOLS, SYMBOL_PARAMS, analyze, fetch_daily, simulate # noqa: E402
from screen_etf import code_of, fetch_all, size_yi, turnover_yi # noqa: E402
CASH = 600_000.0
data = {code: fetch_daily(code) for code in SYMBOLS}
# ---------- 1) 逐标的贡献 ----------
result = simulate(data, start_cash=CASH, fill_mode="touch")
print("== 逐标的净贡献(了结 + 未实现,按市价;账户 60 万)==")
print(f"{'代码':11} {'了结净额':>10} {'未了结股数':>9} {'未实现':>9} {'净额':>10} {'买入名义':>10}")
contrib = {}
for code in SYMBOLS:
book = result["books"][code]
fills = [f for f in result["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
contrib[code] = closed + unreal
print(f"{code:11} {closed:10,.0f} {book.volume:9} {unreal:9,.0f} {closed + unreal:10,.0f} "
f"{sum(f.price * f.volume for f in fills if f.side == 'BUY'):10,.0f}")
# ---------- 2) 规模/成交额 ----------
rows = fetch_all()
info = {code_of(r): r for r in rows}
print()
print("== 流动性与规模 ==")
print(f"{'代码':11} {'规模(亿)':>9} {'成交额(亿)':>10} {'名称':26} 评价")
for code in SYMBOLS:
row = info.get(code)
if row is None:
print(f"{code:11} {'?':>9} {'?':>10} 未在东财列表找到")
continue
size, turn = size_yi(row), turnover_yi(row)
if size < 10 or turn < 0.3:
verdict = "⚠️ 规模/成交偏小,网格成交与冲击成本风险高"
elif size < 30 or turn < 1.0:
verdict = "△ 中等,可接受但优先级靠后"
else:
verdict = "✓ 流动性充足"
print(f"{code:11} {size:9.2f} {turn:10.3f} {str(row.get('f14')):26} {verdict}")
# ---------- 3) 剔除弱标的后的对照 ----------
weak = [c for c in SYMBOLS
if c in info and (size_yi(info[c]) < 10 or turnover_yi(info[c]) < 0.3)]
if weak:
print()
print(f"== 剔除弱流动性标的 {weak} 后的组合 ==")
kept = {c: data[c] for c in SYMBOLS if c not in weak}
for label, universe in (("全部 16 只", data), (f"剔除 {len(weak)} 只 → {len(kept)}", kept)):
res = simulate(universe, start_cash=CASH, fill_mode="touch")
st = analyze(res)
print(f" {label:24} 权益变动 {st['final_equity'] - CASH:10,.2f} "
f"收益率 {st['return_pct']:6.2f}% 回撤 {st['max_dd_pct']:5.2f}% "
f"底仓 {sum(1 for f in res['fills'] if f.kind == 'base'):4} "
f"补仓 {sum(1 for f in res['fills'] if f.kind == 'add'):3} "
f"佣金 {st['fees']:8,.2f}")
else:
print()
print("== 没有规模/成交额低于阈值的标的 ==")
# ---------- 4) 更贴题的品类 ETF存储 ----------
print()
print("== 存储/内存相关 ETF 全量搜索 ==")
hits = [r for r in rows if any(k in str(r.get("f14") or "") for k in ("存储", "内存", "存储器"))]
hits.sort(key=lambda r: -size_yi(r))
for row in hits[:10]:
print(f" {code_of(row):11} {str(row.get('f14')):26} 规模={size_yi(row):8.2f}亿 "
f"成交额={turnover_yi(row):7.3f}亿")
print(f"{len(hits)} 只名称含存储/内存的 ETF")

View File

@@ -0,0 +1,57 @@
"""为存在价格断层的板块挑选替补:先抓日线,再做断层扫描 + 规模/成交额提示。
用法: py -3.14 -B labs/analysis/etf/pick_alt.py
"""
import json
import sys
import urllib.request
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 backtest import CACHE, DAILY_URL # noqa: E402
# 板块 -> 需要考察的替补(按规模顺序)
ALTS = {
"CPO/通信": ["515050.SH", "159583.SZ", "159994.SZ", "159695.SZ"],
"半导体": ["159516.SZ", "588170.SH", "159995.SZ", "159558.SZ", "512480.SH"],
}
def fetch(code: str) -> list[dict]:
request = urllib.request.Request(f"{DAILY_URL}?code={code}", headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=30) as response:
rows = json.load(response)
CACHE.mkdir(parents=True, exist_ok=True)
(CACHE / f"{code}.json").write_text(json.dumps(rows), encoding="utf-8")
return sorted(rows, key=lambda r: str(r["trade_date"]))
def gaps(rows: list[dict]) -> list[tuple[str, float]]:
out = []
for prev, cur in zip(rows, rows[1:]):
a, b = float(prev["close"]), float(cur["close"])
if a > 0 and abs(b / a - 1) * 100 > 15:
out.append((str(cur["trade_date"]), (b / a - 1) * 100))
return out
for sector, codes in ALTS.items():
print(f"\n== {sector} 替补 ==")
for code in codes:
try:
rows = fetch(code)
except Exception as exc:
print(f" {code}: 抓取失败 {exc}")
continue
closes = [float(r["close"]) for r in rows]
bad = gaps(rows)
flag = "OK" if not bad else f"断层 {len(bad)} 处(最大 {max(abs(g[1]) for g in bad):.1f}%)"
print(f" {code:11} 根数={len(rows):4} 最新={closes[-1]:7.3f} "
f"区间={min(closes):6.3f}~{max(closes):6.3f} {flag}")

View File

@@ -0,0 +1,67 @@
"""在指定关键词范围内,按"最近 120 根无价格断层 + 规模/成交额"挑选 ETF。
用法:
py -3.14 -B labs/analysis/etf/pick_sector.py 通信
py -3.14 -B labs/analysis/etf/pick_sector.py 半导体 芯片
"""
import json
import sys
import time
import urllib.parse
import urllib.request
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 backtest import CACHE, DAILY_URL # noqa: E402
from screen_etf import fetch_all, code_of, size_yi, turnover_yi # noqa: E402
def window_clean(code: str) -> tuple[bool, float, str]:
"""最近 120 根内最大单日跳空;<=15% 视为干净。"""
path = CACHE / f"{code}.json"
try:
if path.exists():
rows = json.loads(path.read_text(encoding="utf-8"))
else:
request = urllib.request.Request(f"{DAILY_URL}?code={code}",
headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=30) as response:
rows = json.load(response)
CACHE.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(rows), encoding="utf-8")
except Exception as exc:
return False, 999.0, f"抓取失败:{exc}"
rows = sorted(rows, key=lambda r: str(r["trade_date"]))[-120:]
worst, day = 0.0, ""
for prev, cur in zip(rows, rows[1:]):
a, b = float(prev["close"]), float(cur["close"])
if a > 0 and abs(b / a - 1) * 100 > worst:
worst, day = abs(b / a - 1) * 100, str(cur["trade_date"])
return worst <= 15, worst, day
def main() -> int:
keywords = sys.argv[1:] or ["通信"]
wanted = {c: r for c, r in ((code_of(r), r) for r in fetch_all())
if any(k in str(r.get("f14") or "") for k in keywords)}
rows = sorted(wanted.values(), key=lambda r: -size_yi(r))
print(f"关键词 {keywords}:命中 {len(rows)} 只,按规模前 12 只做断层检查")
print(f"{'代码':11} {'名称':26} {'规模(亿)':>9} {'成交额(亿)':>10} {'窗口跳空':>9} 判定")
for row in rows[:12]:
code = code_of(row)
clean, worst, day = window_clean(code)
time.sleep(0.2)
print(f"{code:11} {str(row.get('f14')):26} {size_yi(row):9.2f} {turnover_yi(row):10.3f} "
f"{worst:8.1f}% {'可用' if clean else '有断层(' + day + ')'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View 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 只板块 ETF15 板块 + 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 / PCB0 只**专属 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))

View File

@@ -0,0 +1,127 @@
"""抓取全市场 ETF 列表(规模 + 成交额),用于挑选各热门板块的代表性标的。
数据源:东方财富行情接口(公开 JSON无需登录
字段f12=代码 f13=市场(0深/1沪) f14=名称 f2=最新价 f3=涨跌幅 f6=成交额 f20=总市值 f21=流通市值
用法: py -3.14 -B labs/analysis/etf/screen_etf.py [关键词...]
不带关键词时按板块关键词分组输出候选。
"""
import json
import sys
import time
import urllib.parse
import urllib.request
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
BASE_HOSTS = ("82.push2.eastmoney.com", "push2delay.eastmoney.com", "push2.eastmoney.com")
FS = "b:MK0021,b:MK0022,b:MK0023,b:MK0024" # 沪深 ETF/LOF 集合
FIELDS = "f12,f13,f14,f2,f3,f6,f20,f21"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Referer": "https://fund.eastmoney.com/",
}
# 每个板块的候选关键词(用于在名称里匹配);越靠前优先级越高
SECTORS = {
"AI": ["人工智能", "AIETF", "AIETF", "科创AI", "智能"],
"CPO": ["光通信", "通信ETF", "通信设备", "5G通信", "通信"],
"PCB": ["PCB", "电子ETF", "消费电子", "电子50", "电子"],
"人形机器人": ["机器人", "智能制造", "工业母机"],
"": ["光伏", "新能源", "电池"],
"创新药": ["创新药", "医药", "生物医药", "医疗"],
"半导体": ["半导体", "芯片", "集成电路", "科创芯片"],
"存储": ["存储", "集成电路", "科创芯片"],
"半导体材料": ["半导体材料", "材料ETF", "新材料"],
"玻璃基板": ["玻璃", "建材", "新材料"],
"电力": ["电力", "公用事业", "电网", "绿电"],
"航空航天": ["航空", "航天", "军工", "国防"],
"金属": ["有色", "金属", "黄金", "稀土"],
"金融": ["证券", "银行", "保险", "金融"],
"能源": ["能源", "煤炭", "石油", "油气"],
}
def fetch_page(page: int, size: int = 100, retries: int = 4) -> tuple[list[dict], int]:
query = urllib.parse.urlencode({
"pn": page, "pz": size, "po": 1, "np": 1, "fltt": 2, "invt": 2,
"fid": "f3", "fs": FS, "fields": FIELDS,
"ut": "bd1d9ddb04089700cf9c27f6f7426281",
})
last_error = None
for attempt in range(retries):
# 主站 push2 容易被限流返回 502轮换到镜像域名
host = BASE_HOSTS[attempt % len(BASE_HOSTS)]
request = urllib.request.Request(f"http://{host}/api/qt/clist/get?{query}", headers=HEADERS)
try:
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.load(response)
data = payload.get("data") or {}
return data.get("diff") or [], int(data.get("total") or 0)
except Exception as exc: # 502/超时都换域名重试
last_error = exc
time.sleep(1.0 * (attempt + 1))
raise RuntimeError(f"{page} 页抓取失败:{last_error}")
def fetch_all() -> list[dict]:
rows, total = fetch_page(1)
page = 2
while len(rows) < total:
try:
more, _ = fetch_page(page)
except RuntimeError as exc:
print(f"警告:{exc},已抓 {len(rows)}/{total}", file=sys.stderr)
break
if not more:
break
rows.extend(more)
page += 1
time.sleep(0.6) # 轻量限速,避免被限流
return rows
def code_of(row: dict) -> str:
suffix = "SH" if str(row.get("f13")) == "1" else "SZ"
return f"{row['f12']}.{suffix}"
def size_yi(row: dict) -> float:
"""规模(亿元):接口给的是元。"""
value = row.get("f20") or row.get("f21") or 0
return float(value) / 1e8
def turnover_yi(row: dict) -> float:
"""成交额(亿元)。"""
return float(row.get("f6") or 0) / 1e8
def main() -> int:
keywords = sys.argv[1:]
rows = fetch_all()
print(f"抓取到 {len(rows)} 只 ETF/LOF")
if keywords:
for row in rows:
name = str(row.get("f14") or "")
if any(k in name for k in keywords):
print(f"{code_of(row):12} {name:24} 规模={size_yi(row):8.2f}亿 "
f"成交额={turnover_yi(row):7.3f}亿 涨跌={row.get('f3')}%")
return 0
for sector, words in SECTORS.items():
hits = [r for r in rows if any(w in str(r.get("f14") or "") for w in words)]
hits.sort(key=lambda r: -size_yi(r))
print(f"\n== {sector} 候选 {len(hits)} 只(按规模降序,取前 6==")
for row in hits[:6]:
print(f" {code_of(row):12} {str(row.get('f14')):26} "
f"规模={size_yi(row):8.2f}亿 成交额={turnover_yi(row):7.3f}亿")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,43 @@
"""新白名单16 只板块 ETF的快速回测冒烟只打印不覆盖既有 results.json。
用法: py -3.14 -B labs/analysis/etf/smoke_sectors.py [起始资金]
"""
import sys
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 backtest import REPO_DEFAULTS, START_CASH, SYMBOLS, SYMBOL_PARAMS, analyze, fetch_daily, simulate # noqa: E402
CASH = float(sys.argv[1]) if len(sys.argv) > 1 else 600_000.0
data = {code: fetch_daily(code) for code in SYMBOLS}
print(f"标的 {len(SYMBOLS)} 只,账户 {CASH:,.0f} 元,参数取 py-client/etc/_etf.yaml")
print(f"{'成交模型':10} {'权益变动':>11} {'收益率':>8} {'最大回撤':>9} {'平均占用':>9} "
f"{'峰值占用':>9} {'底仓':>5} {'补仓':>5} {'主出口':>6} {'佣金':>8}")
for mode in ("touch", "bounce", "close"):
result = simulate(data, start_cash=CASH, fill_mode=mode)
stats = analyze(result)
fills = result["fills"]
print(f"{mode:10} {stats['final_equity'] - CASH:11,.2f} {stats['return_pct']:7.2f}% "
f"{stats['max_dd_pct']:8.2f}% {stats['avg_deployed'] / CASH * 100:8.2f}% "
f"{stats['max_deployed'] / CASH * 100:8.2f}% "
f"{sum(1 for f in fills if f.kind == 'base'):5} "
f"{sum(1 for f in fills if f.kind == 'add'):5} "
f"{sum(1 for f in fills if f.kind == 'exit'):6} {stats['fees']:8.2f}")
# 逐标的参与度:新白名单里谁真的被交易到
print()
print("逐标的成交touch 模型)")
result = simulate(data, start_cash=CASH, fill_mode="touch")
for code in SYMBOLS:
rows = [f for f in result["fills"] if f.code == code]
buys = sum(f.price * f.volume for f in rows if f.side == "BUY")
print(f" {code:11} 成交 {len(rows):3} 笔 买入名义 {buys:10,.0f} "
f"底仓 {sum(1 for f in rows if f.kind == 'base')} 补仓 {sum(1 for f in rows if f.kind == 'add')}")

View File

@@ -0,0 +1,62 @@
"""校验 py-client/etc/_etf.yaml用策略自己的 config.load 解析,并核对每只标的的数据可用性。
用法: py -3.14 -B labs/analysis/etf/validate_config.py
"""
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[2]
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(REPO / "py-client"))
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import yaml # noqa: E402
import config # noqa: E402
from backtest import CACHE, fetch_daily # noqa: E402
ETC = REPO / "py-client" / "etc"
# 1) 用策略自身的校验逻辑加载(除了 _global/account 这两层,直接调 _etf_config
cfg = config._etf_config(ETC / "_etf.yaml")
if cfg is None:
print("FAIL: _etf_config 返回 None文件不存在")
raise SystemExit(1)
print(f"配置解析通过:{len(cfg.symbols)} 只标的,顺序 = 资金优先级")
print(f"{'#':>3} {'代码':11} {'每档':>6} {'上限':>7} {'T+0':>5} {'atr×':>5} {'inner':>6} 数据")
ok = True
for index, code in enumerate(cfg.codes, 1):
symbol = cfg.symbols[code]
try:
bars = fetch_daily(code)
first, last = bars[0]["date"], bars[-1]["date"]
data = f"{len(bars)}{first}..{last}"
if len(bars) < 61:
data += " ← 不足 61 根,策略会跳过"
ok = False
except Exception as exc:
data = f"抓取失败:{exc}"
ok = False
print(f"{index:3} {code:11} {symbol.buy_shares:6} {symbol.max_shares:7} "
f"{str(symbol.is_t0):>5} {symbol.atr_multiplier:5.1f} {symbol.inner_step:6.1f} {data}")
# 2) 断言inner_grids × inner_step < min_profit_pct否则副出口不可能触发
grids, step = cfg.defaults.inner_grids, min(s.inner_step for s in cfg.symbols.values())
profit = cfg.defaults.min_profit_pct
print()
print(f"副出口可达性inner_grids({grids}) × inner_step({step}) = {grids * step:.2f}% "
f"vs min_profit_pct {profit}% → {'可达' if grids * step < profit else '不可达(会被主出口压制)'}")
# 3) 估算资金需求
need_one = sum(cfg.symbols[c].buy_shares * fetch_daily(c)[-1]["close"] for c in cfg.codes)
print(f"资金需求16 只各铺 1 档 ≈ {need_one:,.0f} 元;各铺满 10 档 ≈ {need_one * 10:,.0f}")
print()
print("校验结果:", "通过" if ok else "存在问题(见上)")
raise SystemExit(0 if ok else 1)

View File

@@ -0,0 +1,88 @@
"""最终挑选校验:对每个板块的候选做"策略实际窗口(最近 120 根)内是否有价格断层"检查。
接口的 OHLC 不做拆分/份额折算复原(``pre_close`` 已折算、价格未折算),
若断层落在最近 120 根内MA60/ATR 会被污染,网格锚点会算错 → 必须避开。
用法: py -3.14 -B labs/analysis/etf/verify_picks.py
"""
import json
import sys
import urllib.request
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 backtest import CACHE, DAILY_URL, WARMUP # noqa: E402
# 板块 -> (首选, [替补...])
PLAN = {
"AI": ("159819.SZ", ["515070.SH", "159363.SZ"]),
"CPO/通信": ("515880.SH", ["515050.SH", "159994.SZ", "159583.SZ", "159695.SZ"]),
"PCB": ("159732.SZ", ["562950.SH", "159997.SZ", "561100.SH"]),
"人形机器人": ("562500.SH", ["159530.SZ", "159770.SZ", "159272.SZ"]),
"光/光伏": ("515790.SH", ["159755.SZ", "516160.SH", "561910.SH"]),
"创新药": ("159992.SZ", ["159570.SZ", "515120.SH", "512010.SH"]),
"半导体": ("588200.SH", ["159516.SZ", "588170.SH", "159995.SZ", "512480.SH"]),
"存储": ("588750.SH", ["588290.SH", "589130.SH", "588890.SH"]),
"半导体材料": ("588160.SH", ["588010.SH", "589510.SH", "159761.SZ"]),
"玻璃基板": ("159745.SZ", ["588010.SH", "159763.SZ"]),
"电力": ("159326.SZ", ["159611.SZ", "159625.SZ", "561560.SH"]),
"航空航天": ("159227.SZ", ["512660.SH", "159267.SZ", "512710.SH"]),
"金属": ("518880.SH", ["512400.SH", "159934.SZ"]),
"金融": ("512880.SH", ["512070.SH", "512800.SH", "159841.SZ"]),
"能源": ("515220.SH", ["561360.SH", "159518.SZ"]),
"宽基(保留)": ("510300.SH", []),
}
def load(code: str) -> list[dict]:
path = CACHE / f"{code}.json"
if path.exists():
rows = json.loads(path.read_text(encoding="utf-8"))
else:
request = urllib.request.Request(f"{DAILY_URL}?code={code}",
headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=30) as response:
rows = json.load(response)
CACHE.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(rows), encoding="utf-8")
return sorted(rows, key=lambda r: str(r["trade_date"]))
def report(code: str) -> tuple[bool, str]:
"""返回 (窗口内是否干净, 说明)。窗口 = 最近 120 根(策略指标只用这段)。"""
rows = load(code)
window = rows[-120:] if len(rows) >= 120 else rows
worst, worst_day = 0.0, ""
for prev, cur in zip(window, window[1:]):
a, b = float(prev["close"]), float(cur["close"])
if a > 0:
change = abs(b / a - 1) * 100
if change > worst:
worst, worst_day = change, str(cur["trade_date"])
clean = worst <= 15
note = "干净" if clean else f"窗口内{worst_day}跳空 {worst:.1f}%"
closes = [float(r["close"]) for r in window]
return clean, f"{note} 最近120根 {min(closes):.3f}~{max(closes):.3f} 最新 {closes[-1]:.3f}"
print(f"{'板块':12} {'首选':11} {'窗口干净':>8} 说明")
picks = {}
for sector, (first, alts) in PLAN.items():
for code in [first, *alts]:
clean, note = report(code)
tag = "★首选" if code == first else " 替补"
print(f"{sector:12} {code:11} {str(clean):>8} {tag} {note}")
if code == first:
picks[sector] = (code, clean)
print()
print("== 首选中有断层的板块 ==")
for sector, (code, clean) in picks.items():
if not clean:
print(f" {sector}: {code}")