feat etf
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
139
labs/analysis/etf/REPORT-16-sectors.md
Normal file
139
labs/analysis/etf/REPORT-16-sectors.md
Normal 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-18(182 个交易日)**,日线 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
|
||||
|
||||
---
|
||||
|
||||
## 三、成交结构与资金
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | ---: |
|
||||
| 完整轮次 | 123(122 胜 1 负) |
|
||||
| 单轮平均利润 | 36.84(最好 342.33,最差 -23.57) |
|
||||
| 平均持有 / 最长 | 4.5 天 / **156 天**(512880.SH 2026-01-26~2026-07-01) |
|
||||
| 平均档位 / 最大档位 | 1.16 / 5 |
|
||||
| 有持仓天数 | 164 / 182(90.1%) |
|
||||
| 平均 / 峰值占用 | 21,174(3.53%)/ 76,080(12.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 / PCB:0 只**专属 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 组敏感性已证明无效或负优化。
|
||||
1
labs/analysis/etf/cache/588000.SH.json
vendored
1
labs/analysis/etf/cache/588000.SH.json
vendored
File diff suppressed because one or more lines are too long
80
labs/analysis/etf/check_candidates.py
Normal file
80
labs/analysis/etf/check_candidates.py
Normal 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 "存在数据不足的标的,需替换或接受其被跳过")
|
||||
55
labs/analysis/etf/check_gaps.py
Normal file
55
labs/analysis/etf/check_gaps.py
Normal 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("未发现异常跳空")
|
||||
91
labs/analysis/etf/decide_list.py
Normal file
91
labs/analysis/etf/decide_list.py
Normal 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")
|
||||
57
labs/analysis/etf/pick_alt.py
Normal file
57
labs/analysis/etf/pick_alt.py
Normal 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}")
|
||||
67
labs/analysis/etf/pick_sector.py
Normal file
67
labs/analysis/etf/pick_sector.py
Normal 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
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))
|
||||
127
labs/analysis/etf/screen_etf.py
Normal file
127
labs/analysis/etf/screen_etf.py
Normal 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())
|
||||
43
labs/analysis/etf/smoke_sectors.py
Normal file
43
labs/analysis/etf/smoke_sectors.py
Normal 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')}")
|
||||
62
labs/analysis/etf/validate_config.py
Normal file
62
labs/analysis/etf/validate_config.py
Normal 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)
|
||||
88
labs/analysis/etf/verify_picks.py
Normal file
88
labs/analysis/etf/verify_picks.py
Normal 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}")
|
||||
Reference in New Issue
Block a user