From 8131b158b48d9a3cb025b01d1f56c6c874a8a63c Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sat, 19 Sep 2026 19:45:43 +0800 Subject: [PATCH] optz --- README.md | 37 + docs/etf.md | 656 ++++++++ grpc/qmt_grpc_new.py | 61 - grpc/qmt_service.proto | 19 - labs/__init__.py | 17 + labs/analysis/etf/REPORT-new-config.md | 354 +++++ labs/analysis/etf/REPORT.md | 435 ++++++ labs/analysis/etf/analysis.py | 376 +++++ labs/analysis/etf/backtest.py | 476 ++++++ labs/analysis/etf/cache/510300.SH.json | 1 + labs/analysis/etf/cache/518880.SH.json | 1 + labs/analysis/etf/cache/588000.SH.json | 1 + labs/analysis/etf/capital.py | 41 + labs/analysis/etf/compare.py | 104 ++ labs/analysis/etf/drawdown.py | 120 ++ labs/analysis/etf/entries.py | 40 + labs/analysis/etf/results.json | 1370 +++++++++++++++++ labs/analysis/etf/run.py | 232 +++ labs/analysis/etf/run_report.txt | 85 + labs/analysis/etf/sweep.py | 144 ++ labs/analysis/etf/universe.py | 53 + labs/analysis/etf/vs_hold.py | 130 ++ labs/benchmarks/hotpaths.py | 5 +- labs/run_tests.py | 47 + labs/tests/__init__.py | 6 + .../test_deal_model.cpython-314.pyc | Bin 0 -> 7575 bytes .../__pycache__/test_etf.cpython-314.pyc | Bin 0 -> 54451 bytes .../test_etf_signal.cpython-314.pyc | Bin 0 -> 31063 bytes .../test_etf_trade.cpython-314.pyc | Bin 0 -> 29774 bytes .../__pycache__/test_ipo.cpython-314.pyc | Bin 0 -> 24873 bytes .../test_orderbook.cpython-314.pyc | Bin 0 -> 7234 bytes ...test_python314_performance.cpython-314.pyc | Bin 0 -> 10684 bytes .../test_trend_collector.cpython-314.pyc | Bin 0 -> 10550 bytes .../__pycache__/test_zt_boot.cpython-314.pyc | Bin 0 -> 42369 bytes .../test_zt_config.cpython-314.pyc | Bin 0 -> 9840 bytes .../test_zt_ownership.cpython-314.pyc | Bin 0 -> 16325 bytes .../test_zt_rounds.cpython-314.pyc | Bin 0 -> 31850 bytes .../__pycache__/test_zt_rules.cpython-314.pyc | Bin 0 -> 10767 bytes .../__pycache__/zt_harness.cpython-314.pyc | Bin 0 -> 8452 bytes labs/tests/test_etf.py | 514 +++++-- labs/tests/test_etf_config.py | 147 ++ labs/tests/test_etf_signal.py | 324 ++++ labs/tests/test_etf_trade.py | 358 +++++ labs/tests/test_trend_collector.py | 61 +- py-client/README.md | 15 +- py-client/config/__init__.py | 371 ++++- py-client/etc/_etf.yaml | 65 +- py-client/libs/runtime.py | 9 +- py-client/main.py | 40 +- py-client/strategy/etf/README.md | 131 -- py-client/strategy/etf/__init__.py | 1 - py-client/strategy/etf/boot.py | 238 ++- py-client/strategy/etf/config.py | 59 - py-client/strategy/etf/data.py | 55 - py-client/strategy/etf/engine.py | 202 --- py-client/strategy/etf/indicators.py | 55 - py-client/strategy/etf/open.py | 250 +++ py-client/strategy/etf/positions.py | 490 ++++++ py-client/strategy/etf/signal.py | 317 ++++ py-client/strategy/etf/state.py | 65 - 60 files changed, 7669 insertions(+), 909 deletions(-) create mode 100644 docs/etf.md delete mode 100644 grpc/qmt_grpc_new.py delete mode 100644 grpc/qmt_service.proto create mode 100644 labs/__init__.py create mode 100644 labs/analysis/etf/REPORT-new-config.md create mode 100644 labs/analysis/etf/REPORT.md create mode 100644 labs/analysis/etf/analysis.py create mode 100644 labs/analysis/etf/backtest.py create mode 100644 labs/analysis/etf/cache/510300.SH.json create mode 100644 labs/analysis/etf/cache/518880.SH.json create mode 100644 labs/analysis/etf/cache/588000.SH.json create mode 100644 labs/analysis/etf/capital.py create mode 100644 labs/analysis/etf/compare.py create mode 100644 labs/analysis/etf/drawdown.py create mode 100644 labs/analysis/etf/entries.py create mode 100644 labs/analysis/etf/results.json create mode 100644 labs/analysis/etf/run.py create mode 100644 labs/analysis/etf/run_report.txt create mode 100644 labs/analysis/etf/sweep.py create mode 100644 labs/analysis/etf/universe.py create mode 100644 labs/analysis/etf/vs_hold.py create mode 100644 labs/run_tests.py create mode 100644 labs/tests/__init__.py create mode 100644 labs/tests/__pycache__/test_deal_model.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_etf.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_etf_signal.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_etf_trade.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_ipo.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_orderbook.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_python314_performance.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_trend_collector.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_zt_boot.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_zt_config.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_zt_ownership.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_zt_rounds.cpython-314.pyc create mode 100644 labs/tests/__pycache__/test_zt_rules.cpython-314.pyc create mode 100644 labs/tests/__pycache__/zt_harness.cpython-314.pyc create mode 100644 labs/tests/test_etf_config.py create mode 100644 labs/tests/test_etf_signal.py create mode 100644 labs/tests/test_etf_trade.py delete mode 100644 py-client/strategy/etf/README.md delete mode 100644 py-client/strategy/etf/__init__.py delete mode 100644 py-client/strategy/etf/config.py delete mode 100644 py-client/strategy/etf/data.py delete mode 100644 py-client/strategy/etf/engine.py delete mode 100644 py-client/strategy/etf/indicators.py create mode 100644 py-client/strategy/etf/open.py create mode 100644 py-client/strategy/etf/positions.py create mode 100644 py-client/strategy/etf/signal.py delete mode 100644 py-client/strategy/etf/state.py diff --git a/README.md b/README.md index 76b2406..24aab78 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,39 @@ # big-qmt +量化交易工程:`py-client`(QMT 策略客户端,运行时) + `api` / `grpc` / `launcher` / `scripts`(服务端与工具)。 + +## labs:所有试验与测试代码 + +`labs/` 统一存放**非运行时**的试验、回测、审计与测试代码,运行时代码仍然只在 +`py-client` 及各服务目录内。 + +``` +labs/ +├── run_tests.py 统一测试入口(把 py-client 挂上 sys.path 后跑 labs/tests) +├── tests/ 离线单测(原 py-client/tests) +├── benchmarks/ 微基准(原 py-client/benchmarks) +└── analysis/ 回测与审计分析 + └── etf/ ETF 网格策略回测(报告、参数扫描、对照脚本、日线缓存) +``` + +```powershell +# 离线测试(仓库任意位置执行) +py -3.14 -B labs/run_tests.py # 全部 +py -3.14 -B labs/run_tests.py -v # 详细 +py -3.14 -B labs/run_tests.py test_etf_signal # 只跑某个模块 + +# 微基准 +py -3.14 -B labs/benchmarks/hotpaths.py + +# ETF 网格策略回测与报告 +py -3.14 -B labs/analysis/etf/run.py # 基准回测 → results.json / run_report.txt +py -3.14 -B labs/analysis/etf/compare.py # 旧 vs 新配置 +py -3.14 -B labs/analysis/etf/vs_hold.py # 网格 vs 买入持有 +``` + +报告:`labs/analysis/etf/REPORT-new-config.md`(当前配置)、 +`labs/analysis/etf/REPORT.md`(旧配置,含机会频率与参数敏感性专题)。 + +> 测试模块内部使用 `from tests.zt_harness import ...` 这类绝对导入,因此**必须**走 +> `labs/run_tests.py`;直接用 `python -m unittest discover` 无法同时满足 +> "`labs` 作为顶层包 + `py-client` 在 sys.path 上"这两个条件。 diff --git a/docs/etf.md b/docs/etf.md new file mode 100644 index 0000000..7bf0a5e --- /dev/null +++ b/docs/etf.md @@ -0,0 +1,656 @@ +# A 股 ETF 自适应网格策略 v2 + +--- + +## 1. 策略概述 + +### 1.1 一句话 + +在 ETF 的低位区间**越跌越买**,用 ATR 决定的格距把仓位铺在 10 个价位上(底仓 1 档 + 补仓 9 档); +等**全部持仓的平均成本**涨够 `min_profit_pct`(默认 1%)后**整仓卖出**,赚这一段反弹。 + +### 1.2 赚什么、不赚什么 + +| | 说明 | +| --- | --- | +| **赚什么** | 区间内的反复波动。单轮收益 = 持仓规模 × `min_profit_pct`,所以**越跌越买反而放大了单轮收益** | +| **不赚什么** | 单边趋势。单边下跌时深档被动持仓,单边上涨时过早卖空仓位 | +| **不做什么** | 不预测方向、不做择时、不做行业轮动、**不设置止损** | + +### 1.3 四个设计要点 + +1. **ATR 自适应格距** —— 格距随标的波动率自动伸缩,不用手工设价格区间。 +2. **底仓 + 9 档补仓** —— 共 10 个价位,每档 1000 股,固定不缩放。 +3. **反弹确认入场** —— 进入低位区后不立刻买,等从低点反弹 0.5% 才建网(防接飞刀)。 +4. **整仓止盈为主、单档兜底为辅** —— 主出口吃整段反弹,副出口防止浅档利润被反复吐回。 + +### 1.4 标的(示例) + +| 代码 | 名称 | 现价 | 近 127 日区间 | 振幅 | 结算 | +| --- | --- | --- | --- | --- | --- | +| 588000.SH | 华夏上证科创板 50 成份 ETF | 1.744 | 1.316 ~ 2.390 | 81.6% | T+1 | +| 510300.SH | 沪深 300ETF 华泰柏瑞 | 4.582 | 4.405 ~ 5.095 | 15.7% | T+1 | +| 518880.SH | 黄金 ETF | 9.009 | 8.224 ~ 10.345 | 25.8% | T+0 | + +> 价格为 2026-09-18 收盘,来自 `http://139.224.247.176:13499/etf/daily`(127 根日线)。 +> 人工选择标的时要求:规模大、成交额充足、行为以震荡为主、无清盘风险。 + +### 1.5 指标 + +只用截至前一交易日的已收盘日线,禁止未来数据。 + +| 指标 | 定义 | +| --- | --- | +| `M`(MA60) | 最近 60 根收盘价均值,作为**不可突破的网顶** | +| `ATR`(默认 14 日) | `TR = max(最高−最低, abs(最高−前收盘), abs(最低−前收盘))`;前 14 个 TR 取均值作初值,之后 Wilder 平滑 `ATR = (前ATR×13 + 当日TR) / 14` | +| 区间通道 | 近 20 日的 `[最低价, 最高价]` | + +--- + +## 2. 观察规则 + +**只在空网格(无持仓)时观察。** 观察的目的是确认"跌势是否还在继续",避免在下跌途中接刀。 + +### 2.1 入场门槛 + +``` +通道门槛 = 区间下沿 + (区间上沿 − 区间下沿) × 15% +入场门槛 = min(通道门槛, MA60) +``` + +`min()` 同时表达两件事:**不在均线上方建网**,且**处在近 20 日区间最低的 15% 以内**。 + +> MA60 只作上限、不作低位判定。实测把门槛设在均线附近会让成交发生在下跌趋势的 +> 中途而非区间低位,每笔净收益从 86.8 元降到 63.2 元。 + +### 2.2 观察状态机(复用 `libs/watch.py` 的 `DipWatch`) + +| 情形 | 行为 | +| --- | --- | +| `现价 > 入场门槛` | 价格在入场区之上,**清除观察状态** | +| `现价 ≤ 入场门槛` 且无观察点 | 记下现价作为观察低点,开始观察 | +| 后续价格更低 | **刷新观察低点**,并重置有效期 | +| `(现价 − 观察低点) / 观察低点 ≥ 0.5%` | **确认反弹 → 可以建网**,观察状态随之清除 | +| 观察超过 600 秒未触发 | 作废,重新开始观察 | + +``` +watch = DipWatch(expire_seconds=600, rebound_threshold=0.5) +watch.triggered(tag, code, price, now) -> bool # True 即确认 +watch.forget(code) # 价格回到入场区上方时必须调用 +``` + +- 观察键就是**证券代码**,天然逐标的隔离,不会互相污染低点。 +- 库的 `triggered` 已处理"首次观察 / 刷新低点 / 过期重开 / 达标触发并清除"四个分支, + 调用方**不要重复实现**,只负责在价格离开入场区时 `forget`。 + +### 2.3 反弹阈值取值 + +约束:**必须小于该标的的格距百分比**(1.35% ~ 1.78%),否则确认价已越过下一档, +锚点上移会削弱后续格子的覆盖。 + +| 取值 | 效果 | 风险 | +| --- | --- | --- | +| 0.3% | 几乎不放过任何入场 | 单边下跌中连续接刀,等于没有防护 | +| **0.5%(默认)** | 确认成本约为最小格距的三分之一 | 极端快跌中仍会建网 | +| 1.5% | 只在明确反转后建网 | 超过多数标的格距,系统性错过 V 型反转 | + +--- + +## 3. 买入规则 + +### 3.1 锚点 + +**只有"反弹确认"能产生锚点**,锚点就是确认那一刻的现价: + +``` +锚点 = 反弹确认时的现价 +``` + +受 `锚点 ≤ 入场门槛` 约束(因为只有进入入场区才会开始观察)。 + +> 持仓期间**绝不重设锚点**。不允许因为价格变动把锚点平移到当前价—— +> 那等于跳过防接飞的确认,正是 §2 要避免的行为。 + +### 3.2 格距 + +``` +格距 G = max( ATR14 × atr_multiplier, MA60 × 0.5%, 0.001 ) +``` + +- 向上取整到 **0.001 元**(ETF 最小报价单位)。 +- 百分比下限防止低波动期格距被 ATR 压到无意义;0.001 的硬下限保证格距恒为正。 +- **`atr_multiplier` 逐标的标定**,见 §3.4。 + +### 3.3 底仓 + +建网时只买**一档底仓**,即 **1000 股**,按锚点价成交(§3.5)。 +后续的 9 档由 §4 的**百分比补仓**逐档建立,**不预挂价位**。 + +因此建网时**不需要算出一串价位**,只需要确定锚点与格距 `G`(§3.2): + +- **锚点**是底仓成本与后续"自上一档跌幅"的起点; +- **格距 `G`** 不参与补仓触发,只用于两件事:§3.4 的跨度健康度检查、 + §5.2 副出口的内层格距标定。 + +### 3.4 ATR 倍数必须逐标的标定 + +格距本身不触发交易,但它决定**整条阶梯铺多宽**(`9 × G`),所以仍必须逐标的标定。 + +| 标的 | 现价 | MA60 | ATR14 | ATR 倍数 | 格距 | 格距% | 阶梯跨度(9G) | 跨度/现价 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 588000.SH | 1.744 | 1.856 | 0.0603 | **0.5** | 0.031 | 1.78% | 0.279 | **16.0%** | +| 510300.SH | 4.582 | 4.712 | 0.0613 | 1.0 | 0.062 | 1.35% | 0.558 | 12.2% | +| 518880.SH | 9.009 | 8.799 | 0.1376 | 1.0 | 0.138 | 1.53% | 1.242 | 13.8% | + +**588000 必须用 0.5 倍**:它 127 日振幅 81.6%,ATR 相对价格的比例远高于另外两只, +按 1.0 倍算出的阶梯跨度是现价的 31.5%(格距 0.061),深档基本不可达。降到 0.5 倍后跨度回到 16.0%, +与另外两只同量级。 + +标定要同时兼顾**两个不同的失效方向**: + +| 失效方向 | 判据 | 后果 | 修法 | +| --- | --- | --- | --- | +| **跨度轴**(波动率主导) | `9 × G / 锚点` 过大 | 深档不可达、资金长期闲置 | 下调该标的 ATR 倍数 | +| **佣金轴**(价格水平主导) | 单档金额过小 | 每轮收益被固定佣金吃掉 | 提高每格股数,或剔除低价标的 | + +**判据**:跨度落在 10% ~ 20%、单档金额让佣金占比 ≤ 0.3%。标定后三只在 12.2% ~ 16.0%。 + +> 注意标定后**高波动标的的百分比格距反而最小**,所以不能按"波动大就放宽格距"的直觉调参。 + +### 3.5 底仓挂单 + +锚点确定后,**底仓(档位 0)按锚点价挂限价单**,保证首笔立即成交。 +若连底仓都挂不出去(资金或可用份额不足),**撤销锚点**,下一轮重新触发—— +不允许留下挂不出单的"死锚点"。 + +--- + +## 4. 补仓规则 + +**按百分比补仓,不按预设价位挂单。** 口径与 `strategy/trend` 的 `handle_loss` 一致 +(`strategy/trend/positions.py`):用**亏损百分比**判断触发,用 `add_watch` 做**反弹确认**, +确认后才买入。 + +### 4.1 触发条件 + +``` +自上一档跌幅 = (上一档成交价 − 现价) / 上一档成交价 × 100 + +触发补仓: 自上一档跌幅 ≥ add_pct (默认 3.0%) + 且 add_watch.triggered(...) == True (反弹确认,防接飞刀) +``` + +| 项 | 规则 | +| --- | --- | +| 上一档 | 首次是**底仓成交价**,之后是**最近一次补仓的成交价** | +| 补仓价 | **反弹确认时的现价**(不是预设价位),因此每次补仓都买在反弹确认点 | +| 每档数量 | **1000 股(10 手)**,固定,不按资金缩放 | +| 补仓次数 | 最多 **9 次**(底仓 1 + 补仓 9 = 10 档) | +| 单标的上限 | **10000 股** | +| 重复 | 同一时刻该标的只允许一笔在途买单 | + +**为什么按百分比而不是预设价位**: + +- 百分比自动适配价格水平,不需要为每只标的预先算出一串价位; +- **阶梯随下跌自动收窄**——平均成本被不断拉低,所以"再跌 3%"对应的绝对距离会变短; + 而固定价位阶梯的间距是恒定的,深档会越拉越远; +- 与 `trend` 同源,两个策略的补仓语义一致,便于统一维护。 + +### 4.2 防接飞刀:反弹确认 + +补仓同样使用 `libs/watch.py` 的 `DipWatch`(与 §2 建仓观察同一套机制,但**独立的观察实例**): + +``` +add_watch = DipWatch(expire_seconds=watch_seconds, rebound_threshold=rebound_pct) +add_watch.triggered("补仓", code, price, now) -> bool +add_watch.forget(code) # 补仓成功或标的离开观察后调用 +``` + +| 情形 | 行为 | +| --- | --- | +| 跌幅未达 `add_pct` | 不观察,等待 | +| 跌幅达到 `add_pct`,且无观察点 | 以现价启动观察(记观察低点) | +| 观察中价格继续下跌 | 刷新观察低点 | +| 从观察低点反弹 ≥ `rebound_pct` | **确认补仓**,买入 1000 股,并 `forget` | + +**参数关系必须满足**: + +``` +rebound_pct < add_pct < 格距% × N + 0.5% 3.0% 1.35% ~ 1.78%(×1.7 ~ 2.2) +``` + +- `rebound_pct` 必须**小于** `add_pct`,否则反弹确认价会回到上一档之上,条件自相矛盾。 +- `add_pct` 建议取 **1.5 ~ 2.5 个格距**,让每次补仓跨越一格以上,避免同一区域反复成交。 + +### 4.3 add_pct 取值取舍 + +3% 下每次补仓距上一档的绝对距离(以格距计价): + +| 标的 | 格距% | `add_pct=2%` | `add_pct=3%` | +| --- | --- | --- | --- | +| 588000.SH | 1.78% | 1.1 G | 1.7 G | +| 510300.SH | 1.35% | 1.5 G | 2.2 G | +| 518880.SH | 1.53% | 1.3 G | 2.0 G | + +`add_pct=3%` 对应 1.7 ~ 2.2 个格距,且积累了足够跌幅空间;代价是 10 档打满需要 +**累计回撤约 24%**(见 §4.4)。若希望阶梯更浅,可下调到 2%(打满约 −16.6%)。 + +### 4.4 阶梯与资金 + +按 `add_pct = 3%`、每档 1000 股展开(基准 = 入场门槛): + +| 标的 | 档 | 补仓价 | 累计回撤 | 累计股数 | 平均成本 | 出场价(+1%) | 净利 | 占用资金 | 占用回报 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 588000.SH | 1 | 1.632 | 0.0% | 1000 | 1.632 | 1.648 | 6 元 | 1,632 元 | 0.37% | +| 588000.SH | 3 | 1.536 | −5.9% | 3000 | 1.584 | 1.600 | 29 元 | 4,751 元 | 0.61% | +| 588000.SH | 5 | 1.445 | −11.5% | 5000 | 1.537 | 1.553 | 49 元 | 7,686 元 | 0.64% | +| 588000.SH | 7 | 1.360 | −16.7% | 7000 | 1.493 | 1.507 | 61 元 | 10,448 元 | 0.58% | +| 588000.SH | 10 | 1.241 | **−24.0%** | 10000 | 1.429 | 1.443 | 88 元 | 14,287 元 | 0.62% | +| 510300.SH | 1 | 4.518 | 0.0% | 1000 | 4.518 | 4.563 | 35 元 | 4,518 元 | 0.77% | +| 510300.SH | 3 | 4.251 | −5.9% | 3000 | 4.384 | 4.428 | 113 元 | 13,151 元 | 0.86% | +| 510300.SH | 5 | 3.999 | −11.5% | 5000 | 4.255 | 4.297 | 181 元 | 21,273 元 | 0.85% | +| 510300.SH | 7 | 3.763 | −16.7% | 7000 | 4.131 | 4.172 | 245 元 | 28,915 元 | 0.85% | +| 510300.SH | 10 | 3.434 | **−24.0%** | 10000 | 3.954 | 3.993 | 329 元 | 39,539 元 | 0.83% | +| 518880.SH | 1 | 8.799 | 0.0% | 1000 | 8.799 | 8.887 | 78 元 | 8,799 元 | 0.89% | +| 518880.SH | 3 | 8.279 | −5.9% | 3000 | 8.538 | 8.623 | 233 元 | 25,613 元 | 0.91% | +| 518880.SH | 5 | 7.790 | −11.5% | 5000 | 8.287 | 8.370 | 378 元 | 41,434 元 | 0.91% | +| 518880.SH | 7 | 7.329 | −16.7% | 7000 | 8.046 | 8.126 | 511 元 | 56,319 元 | 0.91% | +| 518880.SH | 10 | 6.689 | **−24.0%** | 10000 | 7.701 | 7.778 | 694 元 | 77,013 元 | 0.90% | + +**满网占用(10 档打满)**:588000 = 14,287 元、510300 = 39,539 元、518880 = 77,013 元, +**三只合计 130,839 元**。 + +> 本表是**规模参考**,不是准入条件。实盘资金约束由 §4.6 的共享预算处理。 +> 占用回报稳定在 **0.37% ~ 0.91%**,随建仓加深略微上升——因为平均成本被摊低, +> 同样 1% 的盈亏率对应的绝对涨幅变小。 + +### 4.5 不预挂补仓单 + +**补仓单不预先挂在下方价位上**,只在反弹确认后按现价买入。理由: + +- 百分比补仓的触发条件是"从上一档跌足 `add_pct`",这是一个**状态条件**,不是一个价位; +- 预挂低价单会在下跌途中被连续打穿,等于自动接刀,与 §4.2 的确认机制冲突。 + +因此补仓的执行顺序固定为:**判断跌幅 → 启动/刷新观察 → 反弹确认 → 按现价买入**。 + +### 4.6 资金 + +``` +可用预算 = 券商可用资金 − 账户总额 × 现金安全线 − 所有标的的待确认买单预留 +``` + +- 必须**先扣除所有标的**的未确认买单,不能遍历到后面的标的才发现钱不够。 +- 一轮内多标的按配置顺序消耗同一预算。 + +### 4.7 打满后的规则 + +补仓次数达到 9 次(共 10 档、10,000 股)后: + +| 情形 | 规则 | +| --- | --- | +| 已满 10 档 | **停止补仓**。不再补仓、不再重设基准,只等主出口清仓 | +| 副出口卖掉某一档 | 允许按同一百分比规则重新补回该档,阶梯恢复循环 | +| 主出口成交(整仓清空) | 阶梯解除;价格再次进入入场区并完成反弹确认时,以新基准重建 | + +**理由**:允许打满后继续重建基准,等于在单边下跌中不断向趋势加仓,风险无上限。 +停止补仓把最坏持仓锁定在 10,000 股。 + +--- + +## 5. 卖出规则 + +**双出口**:整仓主出口 + 单档副出口。 + +| 出口 | 触发条件 | 卖出范围 | 角色 | +| --- | --- | --- | --- | +| **主出口** | 盈亏率 ≥ `min_profit_pct` | **全部持仓** | 主力收益来源 | +| **副出口** | 某档盈利从峰值回落,且峰值已抬到 `inner_grids` 格 | **只卖该档** | 兜住"反弹只够一档"的小波动 | + +### 5.1 主出口:按盈亏率整仓止盈 + +**按盈亏率判断,不用绝对价位。** 与 `strategy/trend` 的止盈口径一致 +(`libs/calc.py` 的 `calculate_min_profit_rate` + `GridTrailingTracker`): + +``` +盈亏率 = (现价 − 平均成本) / 平均成本 × 100 # 百分点 +平均成本 = Σ(该档成本 × 该档股数) / Σ(该档股数) + +触发整仓卖出: 盈亏率 ≥ min_profit_pct (默认 1.0%) +``` + +- 卖出**全部持仓**,一次清空网格;之后网格解除,价格重新进入入场区时以新锚点重建。 +- **目标价只是盈亏率的换算结果**,不参与决策: + `出场价 = 平均成本 × (1 + min_profit_pct / 100)`。 +- 盈亏率的分母是**平均成本**(会随加仓下降),不是锚点。所以出场价天然跟着成本走, + 不需要额外加任何锚点偏移。 + +**为什么不用"平均成本 + N × G"**:那个写法在只成交底仓时会算出 +`锚点 + 2G`(比锚点还高 2 个格距)——底仓买在锚点,此时还没有任何摊薄, +"+2G"就只能等价格涨回锚点上方。按盈亏率算就自然得多:底仓只要涨 1% 即可了结 +(换算成格距只有 0.52G ~ 0.73G,仍在锚点附近,可达)。 + +**为什么不用趋势的固定档位表**:`calculate_min_profit_rate` 按价格分档 +(≥300 元 3%、≥200 元 5%、≥100 元 7%、其余 9%)。我们的 ETF 价格都在 10 元以下, +会全部落到 9% 档——对均值回归的网格太苛刻。ETF 的合理下限由**佣金**决定,见下。 + +**保底约束:盈亏率必须高于双边佣金率。** 单档 1000 股时: + +| 标的 | 单档金额 | 双边佣金率 | `min_profit_pct = 1%` 够吗 | +| --- | --- | --- | --- | +| 588000.SH | 1,744 元 | **0.573%** | 够(毛利 17.4 元,净 7.4 元) | +| 510300.SH | 4,582 元 | 0.218% | 够(毛利 45.8 元,净 35.8 元) | +| 518880.SH | 9,009 元 | 0.111% | 够(毛利 90.1 元,净 80.1 元) | + +`min_profit_pct = 1%` 高于三只标的最小仓位的佣金率,所以**任何成交档数下净利都为正**。 +仓位越大佣金率越低,约束越松: + +``` +双边佣金率 = 2 × max(5, 金额 × 佣金率) / 金额 +1 档(1000股) → 0.573% / 0.218% / 0.111% +10 档(10000股) → 0.060% / 0.060% / 0.060% +``` + +> **不要为了迁就资金而调低 `min_profit_pct`**:低于单档佣金率就会产生"赚了差价亏了手续费"的 +> 无效交易。佣金率为 0.573% 的 588000 是最紧的一只。 + +**出场价与收益**(`min_profit_pct = 1%`,按底部 10 档逐级成交): + +| 标的 | 已成交档数 | 持仓 | 平均成本 | 出场价 | 出场价相对锚点 | 净利 | 占用资金 | 占用回报 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 588000.SH | 1 | 1000 | 1.632 | 1.648 | +0.52 G | 6 元 | 1,632 元 | 0.37% | +| 588000.SH | 2 | 2000 | 1.616 | 1.633 | +0.03 G | 18 元 | 3,233 元 | 0.56% | +| 588000.SH | 6 | 6000 | 1.554 | 1.570 | −2.00 G | 58 元 | 9,327 元 | 0.62% | +| 588000.SH | 10 | 10000 | 1.493 | 1.507 | **−4.03 G** | 90 元 | 14,925 元 | 0.60% | +| 510300.SH | 1 | 1000 | 4.518 | 4.563 | +0.73 G | 35 元 | 4,518 元 | 0.77% | +| 510300.SH | 2 | 2000 | 4.487 | 4.532 | +0.23 G | 75 元 | 8,974 元 | 0.84% | +| 510300.SH | 6 | 6000 | 4.363 | 4.407 | −1.79 G | 226 元 | 26,178 元 | 0.86% | +| 510300.SH | 10 | 10000 | 4.239 | 4.281 | **−3.82 G** | 357 元 | 42,390 元 | 0.84% | +| 518880.SH | 1 | 1000 | 8.799 | 8.887 | +0.64 G | 78 元 | 8,799 元 | 0.89% | +| 518880.SH | 2 | 2000 | 8.730 | 8.817 | +0.13 G | 159 元 | 17,460 元 | 0.91% | +| 518880.SH | 6 | 6000 | 8.454 | 8.539 | −1.88 G | 465 元 | 50,724 元 | 0.92% | +| 518880.SH | 10 | 10000 | 8.178 | 8.260 | **−3.91 G** | 745 元 | 81,780 元 | 0.91% | + +**三条性质**: + +1. **只在底仓时出场价最低不过锚点上方 0.52 ~ 0.73 个格距**——基本就在建网位置附近, + 不会被推到锚点上方一大截。 +2. **建仓越深,出场价越低**(满仓时已在锚点下方约 4 个格距),因为加仓不断拉低平均成本。 +3. **占用回报稳定在 0.37% ~ 0.92%**,且随建仓加深略微上升。 + +> **主出口不用峰值回撤**:把整个持仓当观察键、用 `GridTrailingTracker` 驱动整仓出手 +> 是可行的,但实测没有收益、还拖慢周转——固定目标 3 轮 / 均持仓 3.8 日, +> 峰值回撤 2 轮 / 均持仓 8.5 日,期末还压着 3,000 股。整仓层面用盈亏率阈值更干脆。 + +> **主出口不用峰值回撤**:把整个持仓当观察键、用 `GridTrailingTracker` 驱动整仓出手 +> 是可行的,但实测没有收益、还拖慢周转——固定目标 3 轮 / 均持仓 3.8 日, +> 峰值回撤 2 轮 / 均持仓 8.5 日,期末还压着 3,000 股。整仓层面用固定目标更干脆。 + +### 5.2 副出口:单档峰值回撤 + +复用 `libs/grid_take_profit.py` 的 `GridTrailingTracker`: + +``` +tracker = GridTrailingTracker(step=inner_step) # step = 该标的的内层格距(百分点) +观察键 = f"etf:{code}:level:{level}" # 每个档位一个独立观察键 + +每轮对该档调用 observe(键, 该档盈亏率%): + ARMED / RAISED / STEADY → 不动 + RETREAT → 若 peak_grid >= inner_grids,则卖出该档 +``` + +- `inner_step` 是**盈亏率的百分点跨度**,独立于外层格距,**必须小于或接近标的的格距百分比**, + 否则峰值永远抬不起来,副出口形同虚设。 +- `inner_grids`(默认 2)表示"要求峰值至少抬到第几格"才允许回撤卖出。 +- 卖出**只卖该档**(`min(该档持仓, 券商可卖份额)`,向下取整到 100 股)。 +- **峰值必须在卖出成功后才 `clear(键)`**;下单失败或撤单时必须保留峰值。 +- 该档清空后 `clear`,价格回落到该档价位时重新挂买单;重新建仓从 `ARMED` 重新开始。 +- **它不替代主出口**,只处理"整仓目标还没到、但某一档已明显获利"的情况。实测在当前参数下 + 很少触发(三个标的各 3 轮里触发 0~1 次),所以它是**保险而非收益来源**。 + +**内层格距取值**:`inner_step = clamp(0.5 × 格距%, 0.5, 2.0)`,逐标的配置: + +| 标的 | 格距% | 内层格距 | +| --- | --- | --- | +| 588000.SH | 1.78% | 0.9%(精算 0.89) | +| 510300.SH | 1.35% | 0.7%(精算 0.68) | +| 518880.SH | 1.53% | 0.8%(精算 0.77) | + +**收益区间**:峰值抬到第 2 格意味着盈亏率曾达到 `[2×内层格距, 3×内层格距)`, +回撤到第 1 格时落在 `[1×内层格距, 2×内层格距)`,所以**实际落袋在 1~2 个内层格距之间**: + +| 标的 | 单档金额 | 内层格距 | 保底(1 格)净利 | 占用回报 | 上界(2 格)净利 | 占用回报 | +| --- | --- | --- | --- | --- | --- | --- | +| 588000.SH | 1,744 元 | 0.9% | 5.7 元 | **0.33%** | 21.4 元 | 1.23% | +| 510300.SH | 4,582 元 | 0.7% | 22.1 元 | **0.48%** | 54.1 元 | 1.18% | +| 518880.SH | 9,009 元 | 0.8% | 62.1 元 | **0.69%** | 134.1 元 | 1.49% | + +**保底那一列才是下限**,双边佣金固定 10 元,三只都为正。若内层格距取到 0.5 及以下, +588000 的保底净利就会接近零,所以下限取 0.5 而非更低。 + +### 5.3 结算制度 + +由**每标的的 `is_t0` 字段**决定,必填、无隐式默认: + +- `is_t0: false`(T+1):当日买入的份额当日不可卖。 +- `is_t0: true`(T+0):当日买入的份额当日可卖。 + +- **`min_hold_days`(默认 1)仅对 `is_t0: false` 的标的生效**:买入日当天不挂卖单, + 最早次一交易日卖出。**`is_t0: true` 的标的跳过本条**,否则 T+0 的当日回转能力 + 会被这条规则彻底抵消,配置 `is_t0` 也就失去意义。 +- **主出口受 T+1 约束**:若部分持仓是当日买入的,当日不能整仓卖出; + 此时按"当日可卖份额"上限卖出,剩余部分次日再用同一目标价尝试。 +- 两种情形下**券商可用份额都是唯一权威**,本地状态不得放宽。 + +> 上线前须与券商逐一确认。`518880.SH`(黄金 ETF)等商品、债券、货币、跨境 ETF 通常支持 +> T+0;股票型 ETF 为 T+1。声明错误会导致柜台拒单。 + +### 5.4 费用 + +``` +单边佣金 = max(最低佣金, 金额 × 佣金率) # 默认 max(5, 金额×0.0003) +``` + +主出口一次卖出覆盖整个持仓,佣金不是约束: + +| 标的 | 持仓档数 | 毛利 | 佣金合计 | 净利 | 佣金占毛利 | +| --- | --- | --- | --- | --- | --- | +| 588000.SH | 2 | 123 元 | 15 元 | 108 元 | 12% | +| 510300.SH | 2 | 248 元 | 15 元 | 233 元 | 6% | +| 518880.SH | 2 | 552 元 | 15 元 | 537 元 | 3% | + +即使只成交 2 档(占用 2,675 元),双边佣金 15 元也只占毛利的 12%。 +对照 100 股:588000 单档仅 174 元,一次往返佣金就占 5.73%,会把副出口的利润整个吃掉。 + +--- + +## 6. 配置表 + +三层:**全局默认 → 标的白名单(每标的覆盖)→ 状态**。 + +```yaml +# ---------- 全局默认 ---------- +defaults: + atr_period: 14 + min_grid_pct: 0.5 + max_grid_span_pct: 40 # 跨度健康度告警线(非闸门) + channel_period: 20 + channel_pct: 15 + rebound_pct: 0.5 # 反弹确认阈值(建仓与补仓共用) + add_pct: 3.0 # 补仓:自上一档再跌该百分比即触发(需配合反弹确认) + max_adds: 9 # 补仓次数上限(底仓另计,共 10 档) + watch_seconds: 600 + min_profit_pct: 1.0 # 主出口:盈亏率 >= 该值即整仓清掉 + inner_grids: 2.0 # 副出口:峰值至少抬到第 N 格才允许回撤卖出 + min_hold_days: 1 + max_hold_days: 0 # 0 = 不止损 + commission_rate: 0.0003 + min_commission: 5.0 + max_tick_age_seconds: 90 + +# ---------- 标的白名单(键即标的,顺序即资金优先级)---------- +# 键必须与接口返回的 ts_code 完全一致(588000.SH / 159915.SZ) +symbols: + "588000.SH": + is_t0: false + buy_shares: 1000 + max_shares: 10000 # 10 个价位 × 1000 股 + atr_multiplier: 0.5 # 该标的 ATR 相对价格偏高,必须收窄 + inner_step: 0.9 + + "510300.SH": + is_t0: false + buy_shares: 1000 + max_shares: 10000 + atr_multiplier: 1.0 + inner_step: 0.7 + + "518880.SH": + is_t0: true # 黄金 ETF 支持当日回转 + buy_shares: 1000 + max_shares: 10000 + atr_multiplier: 1.0 + inner_step: 0.8 +``` + +**全局参数** + +| 参数 | 默认 | 说明 | +| --- | --- | --- | +| `add_pct` | 3.0 | 补仓触发:自上一档成交价再跌该百分比(%),须大于 `rebound_pct` | +| `max_adds` | 9 | 补仓次数上限。总档数 = `max_adds + 1`(含底仓),允许 0 ~ 9 | +| `atr_period` | 14 | ATR 周期 | +| `min_grid_pct` | 0.5 | 格距百分比下限 | +| `max_grid_span_pct` | 40 | 跨度健康度告警线。`9×G/锚点` 落在 10% ~ 20% 为佳;超线只告警并建议下调 `atr_multiplier`,**不阻止建网** | +| `channel_period` | 20 | 区间通道回看天数 | +| `channel_pct` | 15 | 通道低位门槛:距区间下沿的百分比以内 | +| `rebound_pct` | 0.5 | 反弹确认阈值,**须小于该标的格距百分比** | +| `watch_seconds` | 600 | 观察有效期 | +| `min_profit_pct` | 1.0 | 主出口:盈亏率 ≥ 该值即整仓清掉(必须是百分点) | +| `inner_grids` | 2.0 | 副出口:峰值至少抬到第 N 格 | +| `min_hold_days` | 1 | 买入日当天不挂卖单;`is_t0: true` 的标跳过 | +| `max_hold_days` | 0 | 0 = 不止损 | +| `commission_rate` / `min_commission` | 0.0003 / 5.0 | 费用模型(账户级) | +| `max_tick_age_seconds` | 90 | 行情有效期,超时或非当天不交易 | + +**逐标的参数** + +| 参数 | 必填 | 默认 | 说明 | +| --- | --- | --- | --- | +| `is_t0` | **是** | — | `true` = 当日买入当日可卖;`false` = T+1 | +| `buy_shares` | **是** | — | 每档每次买入股数,100 的整数倍 | +| `atr_multiplier` | **是** | — | 格距的 ATR 倍数,必须逐标的标定(§3.4) | +| `inner_step` | **是** | — | 内层格距(盈亏率百分点),决定副出口的利润刻度 | +| `max_shares` | 否 | 10000 | 单标的总持仓上限,须 ≥ `buy_shares × 10` | +| `inner_grids` | 否 | 继承 | 覆盖副出口的峰值格要求 | +| `max_grid_span_pct` | 否 | 继承 | 覆盖跨度告警线 | +| `rebound_pct` | 否 | 继承 | 覆盖反弹阈值 | +| `max_hold_days` | 否 | 继承 | 覆盖时间退出开关 | + +**配置校验** + +- 四项**必须显式给出**(`is_t0`、`buy_shares`、`atr_multiplier`、`inner_step`),缺失即报错, + 不得回落到全局默认。 +- `max_adds` ∈ [0,9](0 = 只有底仓);`add_pct`、`min_profit_pct`、`inner_grids`、`rebound_pct`、`min_grid_pct`、 + `channel_period`、`channel_pct` 均 > 0;`max_grid_span_pct` ∈ (0,100]。 +- `buy_shares` 为 100 的整数倍且 > 0;`max_shares ≥ buy_shares × 10`,否则启动告警。 +- `rebound_pct` 必须**小于该标的建网时的格距百分比**,否则告警。 +- 标的段只允许覆盖上表列出的键;试图覆盖 `add_pct`、`commission_rate` + 一类账户级参数一律报错。 +- 未知配置键(含 `defaults` 与任一标的段)一律报错。 + +**为什么 `atr_multiplier` 与 `inner_step` 必填**:它们各自决定一个失效方向—— +前者定跨度(跨度轴),后者定副出口能否被触发。两者都没有安全的全局默认值。 + +**为什么 `min_profit_pct` 用 1%**:它必须高于单档 1000 股的双边佣金率(最高的是 588000 的 0.573%), +否则会产生"赚了差价亏了手续费"的无效交易。1% 留了约一倍的余量,同时远小于格距(1.35% ~ 1.78%), +不会把出场推到下一个档位之外。 + +--- + +## 7. 文件规划 + +### 7.1 目录结构 + +``` +py-client/ +├── etc/ +│ └── etf.yaml # 本策略的独立配置(§6) +└── strategy/etf/ # 本策略实现 + ├── __init__.py + ├── boot.py # 入口装配(入口名 etf) + ├── config.py # 配置读取与校验(§6) + ├── data.py # 历史日线接口适配 + ├── indicators.py # MA60 / ATR / 区间通道 / 格距 + ├── engine.py # 决策主循环:观察→建网→补仓→卖出 + └── state.py # 状态持久化:锚点、冻结格距、档位、委托 +``` + +### 7.2 复用约定 + +| 库 | 类 | 承担的行为 | 对应 | +| --- | --- | --- | --- | +| `libs/watch.py` | `DipWatch` | 观察与反弹确认 | §2.2 | +| `libs/grid_take_profit.py` | `GridTrailingTracker` | 副出口的单档峰值回撤 | §5.2 | + +**不得修改这两个共享库。** 它们同时被 `strategy/trend` 与 `strategy/zt` 使用, +改动会波及那两个策略。ETF 侧若需要额外行为,**在 ETF 内包装,不动库本身**。 + +### 7.3 状态文件 + +``` +{qmt_data_dir}/etf/{账户SHA256}/state.json +``` + +- 保存内容:每标的的**锚点**、**底仓成本**、各档持仓(数量、成本、买入日)、 + **上一档成交价**(§4.1 的百分比补仓基准),以及**至多一笔未确认买单**。 +- **补仓是逐笔串行的**(同一时刻该标的只允许一笔在途买单),因此 pending 只需要一个槽位, + 不需要按档位建表——这是改成百分比补仓后的简化。 +- **副出口的峰值格只存活在内存**,不落盘。`GridTrailingTracker` 是进程内对象, + 重启后峰值从 `ARMED` 重新建立(即当前盈亏率所在格),**不会立刻触发卖出**。 + 这是可接受的:副出口只是保险,晚一轮触发不影响结果。 +- **主出口完全无状态**:出场价由已核对的档位实时算出,重启后行为与重启前一致。 +- **先保存意图,再发起请求**。请求成功、超时或失败都不自动解锁;必须收到终态回报 + **且**券商持仓与累计成交量相符,才能继续。 +- 部分成交按实际数量核对;**买入必须取得实际成交均价**才能计入平均成本, + 否则主出口的目标价会算错(未核对的成交不参与计算)。 +- 状态损坏时**拒绝启动并报错**,不允许静默重建为空状态。 +- 券商持仓与本地档位合计不一致时,**以券商快照为准**重建:清仓则重置, + 否则整体接管为锚点档,保证账实相符。 +- 状态结构带**版本号**。旧版本迁移时按券商快照重建档位表,**不做任何自动卖出**。 + +### 7.4 数据来源 + +``` +GET http://139.224.247.176:13499/etf/daily?code=<证券代码> +``` + +- 只接受**单个** `code`;逗号分隔的多代码查询返回非 JSON 错误页。 +- 实际返回**一维数组**,按 `trade_date` 倒序,字段含 + `open / high / low / close / pre_close / trade_date / ts_code`。 +- 旧版 `{code, message, details}` 包装对象已不再返回,适配层对两种形式都兼容。 +- 接口**未声明复权口径**,按原始价格计算。 +- 每标的每日取一次并缓存,截取最近 120 根;取数失败至少间隔 5 分钟再重试。 + +**数据校验**——任一不满足即**放弃该标的当轮交易**,不得降级、不得改用其他证券数据: + +| 校验项 | 规则 | +| --- | --- | +| 证券归属 | 每行 `ts_code` 与请求代码完全一致 | +| 未来数据 | `trade_date >= 今天` 的行一律剔除,盘中不得混入未收盘日线 | +| 重复日期 | 同一日期出现两次即放弃整批 | +| OHLC 有效性 | 四价均为有限正数,且 `low ≤ open ≤ high`、`low ≤ close ≤ high` | +| 样本量 | 至少 60 根(ATR14 需 61 根) | +| 新鲜度 | 最后日线距今超过 15 个自然日即放弃 | + +**实时行情**:下单前必须确认最新价为有限正数,且行情时间戳为当天、距今不超过 90 秒。 + +### 7.5 待办 + +| # | 事项 | 说明 | +| --- | --- | --- | +| 1 | `config.py` 代码校验放宽 | 现有正则 `5[0-9]{5}\.SH` **不含 `56xxxx` / `58xxxx`**,会拒掉 `588000.SH`,须改为 `5[0-9]{5}` | +| 2 | 用 3~5 年日线重跑 | 现接口只给 127 根,主出口只有 3 轮完整往返,**期望收益未经证明** | +| 3 | 与券商确认 | 逐标的确认 T+0 / T+1 与最低佣金实收标准 | +| 4 | 分红除息方案 | 接口无复权价。优先推动接口改造;否则检测除息缺口后整体下移锚点与各档成本,当日不交易;无法确认分红金额则移除该标的 | +| 5 | 模拟盘联调 | 跑通"观察 → 建网 → 逐档成交 → 整仓止盈 → 重建"完整循环 | diff --git a/grpc/qmt_grpc_new.py b/grpc/qmt_grpc_new.py deleted file mode 100644 index 2a69d5e..0000000 --- a/grpc/qmt_grpc_new.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: gbk -*- -import grpc -import qmt_service_pb2 -import qmt_service_pb2_grpc -import time - -class QmtServiceServicer(qmt_service_pb2_grpc.QmtServiceServicer): - """实现QMT服务(单线程版本)""" - - def GetAsset(self, request, context): - """实现GetAsset方法""" - print(f"收到查询请求,账户: {request.account_id}") - - # 这里是你调用大QMT API获取数据的逻辑 - # 实际使用时,请替换为真实的xt_trader查询代码 - # 参考: asset = xt_trader.query_stock_asset(acc) - - # 模拟数据 - total = 1000000.0 - cash = 500000.0 - market_val = 500000.0 - - # 模拟一些耗时操作(如查询数据库) - # time.sleep(0.1) # 如果需要可以取消注释 - - # 返回响应 - return qmt_service_pb2.AssetResponse( - total_asset=total, - cash=cash, - market_value=market_val - ) - -def serve(): - """启动gRPC服务(单线程)""" - # 使用单线程服务器,通过设置maximum_concurrent_rpcs参数限制并发 - # 或者使用同步服务器,直接处理请求 - server = grpc.server() - - # 注册服务 - qmt_service_pb2_grpc.add_QmtServiceServicer_to_server( - QmtServiceServicer(), - server - ) - - # 监听端口 - server.add_insecure_port('[::]:58051') - - # 启动服务器 - server.start() - print("QMT gRPC 服务已启动(单线程模式),监听端口 58051...") - print("所有请求将串行处理,不会并发执行") - - # 保持服务运行 - try: - server.wait_for_termination() - except KeyboardInterrupt: - print("\n服务已停止") - server.stop(0) - -if __name__ == '__main__': - serve() \ No newline at end of file diff --git a/grpc/qmt_service.proto b/grpc/qmt_service.proto deleted file mode 100644 index 82defe8..0000000 --- a/grpc/qmt_service.proto +++ /dev/null @@ -1,19 +0,0 @@ -syntax = "proto3"; - -// 定义服务 -service QmtService { - // 查询账户资产 - rpc GetAsset (AssetRequest) returns (AssetResponse) {} -} - -// 请求消息 -message AssetRequest { - string account_id = 1; // 账户ID -} - -// 响应消息 -message AssetResponse { - double total_asset = 1; // 总资产 - double cash = 2; // 可用资金 - double market_value = 3; // 持仓市值 -} \ No newline at end of file diff --git a/labs/__init__.py b/labs/__init__.py new file mode 100644 index 0000000..5bd2a6b --- /dev/null +++ b/labs/__init__.py @@ -0,0 +1,17 @@ +"""labs:所有试验与测试代码。 + +本目录**不属于运行时代码**,只放回测/审计/基准/测试。里面的脚本需要 +``py-client`` 在 ``sys.path`` 上才能 import ``config`` / ``libs`` / ``sdk`` / ``strategy``, +这里统一补一次,保证从仓库根目录也能直接跑: + + py -3.14 -B -m unittest discover -s labs/tests + py -3.14 -B labs/analysis/etf/run.py + py -3.14 -B labs/benchmarks/hotpaths.py +""" + +import sys +from pathlib import Path + +PY_CLIENT = Path(__file__).resolve().parents[1] / "py-client" +if PY_CLIENT.is_dir() and str(PY_CLIENT) not in sys.path: + sys.path.insert(0, str(PY_CLIENT)) diff --git a/labs/analysis/etf/REPORT-new-config.md b/labs/analysis/etf/REPORT-new-config.md new file mode 100644 index 0000000..c1dc192 --- /dev/null +++ b/labs/analysis/etf/REPORT-new-config.md @@ -0,0 +1,354 @@ +# ETF 自适应网格策略回测报告(新配置) + +- 配置来源:`py-client/etc/_etf.yaml`(**回测直接读取该文件,不再维护副本**) +- 回测区间:**2025-12-22 ~ 2026-09-18(182 个交易日)** +- 标的:`588000.SH`、`510300.SH`、`518880.SH`;日线 242 根(2025-09-19 ~ 2026-09-18,**未复权**) +- 账户:起始 **50 万**、`min_cash_ratio=0.10`、佣金 `max(5, 金额×0.0003)` +- 复现:`py -3.14 -B labs/analysis/etf/run.py`(结果落 `results.json` / `run_report.txt`) +- 对照脚本:`compare.py`(旧 vs 新)、`capital.py`(资金需求)、`sweep.py`(参数)、`drawdown.py`(浮亏轨迹) +- 本报告只做分析,**未修改任何策略代码**。 + +## 本次配置变化 + +| 标的 | 旧 `buy_shares` | 新 `buy_shares` | 新 `max_shares` | +| --- | ---: | ---: | ---: | +| 588000.SH | 1,000 | **10,000** | 100,000 | +| 510300.SH | 1,000 | **4,000** | 20,000 | +| 518880.SH | 1,000 | **2,000** | 10,000 | + +`defaults` 段(add_pct 3.0 / min_profit_pct 1.0 / channel_pct 15 / max_adds 9 等)**未改**。 + +--- + +## 一、结论摘要 + +| 指标 | 基准(触价成交) | 贴近实盘(反弹价) | 悲观(收盘价) | +| --- | ---: | ---: | ---: | +| **账户权益变动** | **+6,110.84(+1.22%)** | +8,426.54(+1.69%) | **−2,551.34(−0.51%)** | +| 最大回撤(权益口径) | 0.48% | 0.56% | 1.68% | +| 完整轮次 / 胜率 | 32 / **32 胜 0 负** | 27 / 27 胜 | 13 / 13 胜 | +| 单轮平均已了结利润 | **+195.81** | — | — | +| 平均持有 / 最大持有 | **3.7 天 / 23 天** | — | — | +| 平均档位 / 最大档位 | 1.16 / 4 | — | — | +| 佣金合计 | 401.79 | 343.75 | 197.34 | +| 平均资金占用 / 峰值 | **2.24% / 17.41%** | 3.40% / 17.85% | 6.71% / 24.83% | + +**和旧配置比,收益放大了 4 倍,但收益率只从 0.31% 提到 1.22%(同一 50 万账户)——因为放大的是仓位,不是机会。** + +| | 旧配置 | 新配置 | 倍数 | +| --- | ---: | ---: | ---: | +| 买入名义额 | 176,668 | **664,705** | **3.76×** | +| 权益变动(50 万账户) | +1,534.14 | **+6,110.84** | **3.98×** | +| 收益率 | 0.31% | **1.22%** | 3.98× | +| 最大回撤 | 0.25% | 0.48% | 1.9× | +| 平均资金占用 | 0.67% | **2.24%** | 3.3× | +| 占用 ROI(权益/平均占用) | 45.68% | **54.48%** | 1.19× | +| 完整轮次 | 28 | 32 | 1.14× | +| 单轮平均利润 | 55.98 | **195.81** | 3.5× | + +**四个关键读数** + +1. **放大股数 ≈ 等比例放大盈亏**:名义额 ×3.76、盈亏 ×3.98,几乎 1:1。这是"加杠杆", + 不是"改进了策略";风险(回撤)同步从 0.25% 升到 0.48%。 +2. **入场机会没有变多**:轮次 28 → 32(+14%)。机会频率仍由市场决定(一年 59 个信号,见 §四)。 +3. **资金利用率仍然极低**:平均只投出 **2.24%** 的钱,峰值 17.41%。 + **绝对盈亏与账户规模无关**——把资金从 20 万加到 120 万,盈亏始终是 +6,110.84, + 收益率从 3.06% 被摊薄到 0.51%(`compare.py` §C)。这说明**50 万并没有被用满**。 +4. **悲观成交假设下会亏钱**(−2,551.34,−0.51%)。新配置把仓位放大 4 倍后, + 结论对成交假设的敏感度也放大了:`touch` +6,111 / `bounce` +8,427 / `close` −2,551。 + +--- + +## 二、逐标的归因(基准) + +| 标的 | 完整轮次 | 已了结净额 | 了结时仍持有 | 按成本净额 | 未实现 | 按市价净额 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 588000.SH | 12 | +2,011.94 | 0 | +2,011.94 | 0 | +2,011.94 | +| 510300.SH | 13 | +2,521.97 | **4,000 股** | **−15,961.17** | −155.14 | **−16,116.31** | +| 518880.SH | 7 | +1,732.07 | 0 | +1,732.07 | 0 | +1,732.07 | +| 合计 | 32 | **+6,265.98** | — | **−12,217.16** | −155.14 | **−12,372.31** | + +对平关系(已用脚本验证):`权益变动 +6,110.84 = 已了结净额(含未了结成本)−12,217.16 + 未了结成本 18,483.14 − 未实现 155.14`。 +`已了结净额` 是**现金流口径**(把还在手上的仓位当成已花掉的钱),**不是亏损**:32 个了结轮次合计 **+6,265.98**。 + +**⚠️ 最大的问题在这里**:`510300.SH` 的 13 个轮次全部盈利(合计 +2,521.97),但**最终一笔未了结的 +4,000 股(成本 18,483)** 让它的净额变成 −15,961。一只标的的未了结仓位,吃掉了三只标的 +全部已了结利润(+6,266)的两倍半。旧配置里这个数字是 −4,035,**放大 4 倍仓位后变成 −15,961**。 + +--- + +## 三、成交结构与资金 + +### 3.1 完整轮次 + +| 项 | 值 | +| --- | ---: | +| 完整轮次 | 32(32 胜 0 负) | +| 单轮平均利润 | +195.81(最好 +684.48,最差 +139.36) | +| 平均持有 | 3.7 天(最长 23 天) | +| **持有 ≤1 天就了结** | **22 / 32(69%)** | +| 平均档位 / 最大档位 | 1.16 / 4 | +| 底仓 / 补仓次数 | 33 / 5 | + +**69% 的轮次在 1 天内完成**:新配置把单档金额放大到 1.8~14 万后,1% 的止盈目标对应的绝对金额 +变成 18~140 元/档,达到速度没变,但**单轮利润的绝对值被放大**。 +同时 **平均只用 1.16 档**(补仓 5 次),阶梯依然远未铺开。 + +### 3.2 资金占用 + +| 项 | 值 | +| --- | ---: | +| 有持仓天数 | 73 / 182(40.1%) | +| 平均占用 | 11,216 元 = **2.24%** | +| 峰值占用 | 87,052 元 = **17.41%** | +| 买入名义 / 换手 | 664,705 元 / **1.33 倍** | +| 资金需求(按各标的区间最低价估) | 三只各 1 档 ≈ **46,518**;铺满 10 档 ≈ **465,180** | + +- 配置现在的**理论满载需求是 46.5 万**(三只各铺满 10 档),50 万账户刚好能承载。 +- 但实际峰值只用到 8.7 万(17.4%)、平均 1.1 万(2.24%)——**账户有 80% 以上的钱全年闲置**。 + +### 3.3 佣金(新配置下更不是瓶颈) + +| 项 | 值 | +| --- | ---: | +| 佣金合计 | 401.79 | +| 占买入名义 | **0.06%**(旧配置 0.184%) | +| 佣金率 0 → 0.0003 → 0.001 | +6,149.52 → +6,110.84 → +5,643.61 | + +单档金额变大后几乎全部按 `0.0003` 计费(不再吃 5 元最低佣金),**佣金成本降到可忽略**。 +`min_profit_pct=1%` 的绝对余量非常大(每档毛利润 ≈ 18~140 元 vs 双边佣金 10 元)。 + +### 3.4 逐月权益 + +| 月份 | 权益变动 | 幅度 | +| --- | ---: | ---: | +| 2025-12 | 0.00 | 0.000% | +| 2026-01 | +260.08 | +0.052% | +| 2026-02 | +105.42 | +0.021% | +| 2026-03 | **−773.15** | −0.155% | +| 2026-04 | +2,234.72 | +0.447% | +| 2026-05 | +456.96 | +0.091% | +| 2026-06 | +925.85 | +0.184% | +| 2026-07 | +1,530.46 | +0.304% | +| 2026-08 | +767.21 | +0.152% | +| 2026-09 | +603.29 | +0.119% | + +9 个月里 1 个月亏损、8 个月盈利,但**全部落在 ±0.45% 以内**——典型的"小仓位、稳定、无感"。 + +--- + +## 四、为什么收益率还是上不去(新配置下的定量结论) + +### 4.1 根因没变:机会数量由市场决定 + +| 标的 | 可回放日 | 最低价跌破门槛 | **入场信号** | 跌破门槛天数占比 | +| --- | ---: | ---: | ---: | ---: | +| 588000.SH | 182 | 44 | 20 | 24.2% | +| 510300.SH | 182 | 47 | 29 | 25.8% | +| 518880.SH | 182 | 30 | 10 | 16.5% | +| 合计 | 546 | 121 | **59** | — | + +一年 59 个信号(≈ 每天 0.32 次),本次实际建网 33 次、了结 32 轮。**机会已被用尽**。 +33 次建网里,现价在过去 60 日收盘的分位中位数 **6.7%**,19/33 落在 10% 分位以下——**入场门槛执行到位**。 + +### 4.2 单档股数放大后,绝对盈亏与账户无关 + +| 起始资金 | 权益变动 | 收益率 | 平均占用 | 峰值占用 | +| ---: | ---: | ---: | ---: | ---: | +| 200,000 | +6,110.84 | **3.06%** | 5.61% | 43.53% | +| 300,000 | +6,110.84 | 2.04% | 3.74% | 29.02% | +| **500,000** | **+6,110.84** | **1.22%** | 2.24% | 17.41% | +| 800,000 | +6,110.84 | 0.76% | 1.40% | 10.88% | +| 1,200,000 | +6,110.84 | 0.51% | 0.93% | 7.25% | + +**同一份交易、同一个盈亏,只因为分母变大,收益率就摊薄 6 倍。** +所以"收益率低"要拆成两件事:①策略**绝对赚钱能力**(这 9 个月 +6,111 元); +②**账户里有多少钱在真正干活**(平均 2.24%)。 + +### 4.3 继续放大股数:收益线性涨、风险同步涨 + +| 场景(50 万账户) | 权益变动 | 收益率 | 最大回撤 | 平均占用 | 占用 ROI | 收益/回撤 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `buy_shares`×0.25 | +1,418.64 | 0.28% | 0.12% | 0.58% | 48.8% | 2.30 | +| `buy_shares`×0.5 | +3,077.19 | 0.62% | 0.24% | 1.11% | 55.4% | 2.54 | +| **现配置** | **+6,110.84** | **1.22%** | **0.48%** | **2.24%** | **54.5%** | **2.56** | +| `buy_shares`×2 | +12,226.40 | 2.45% | 0.95% | 4.49% | 54.5% | 2.57 | +| `buy_shares`×4 | +24,452.81 | 4.89% | 1.89% | 8.97% | 54.5% | 2.59 | + +**占用 ROI 恒定在 48.8%~54.5%,收益/回撤恒定在 2.3~2.6** —— 说明在这段样本里, +放大股数**既不改善也不恶化风险调整后收益,只是等比放大**。 +既然风险调整后收益不变,放大到多大就取决于账户能承受的回撤:若接受 1.9% 回撤, +`×4` 可把 9 个月收益做到 +2.4 万(4.89%)。 + +--- + +## 五、参数敏感性(28 组,基准=当前 `_etf.yaml`) + +| 场景 | 权益变动 | 收益率 | 最大回撤 | 底仓 | 补仓 | 主出口 | 副出口 | 佣金 | 平均占用 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| **基准(当前配置)** | **+6,110.84** | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% | +| add_pct=2.0 | +5,856.05 | 1.171% | 0.362% | 32 | 4 | 31 | 0 | 378.00 | 2.78% | +| add_pct=4.0 | +5,794.31 | 1.159% | 0.356% | 33 | 3 | 32 | 0 | 382.15 | 2.37% | +| add_pct=5.0 | +5,104.25 | 1.021% | 0.412% | 31 | 1 | 30 | 0 | 339.29 | 2.71% | +| min_profit_pct=0.5 | +3,475.39 | 0.695% | 0.498% | 41 | 4 | 40 | 0 | 477.22 | 1.56% | +| min_profit_pct=0.8 | +5,220.81 | 1.044% | 0.486% | 37 | 4 | 36 | 0 | 432.96 | 1.80% | +| **min_profit_pct=1.5** | **+6,560.59** | **1.312%** | 0.438% | 23 | 4 | 22 | 0 | 284.56 | 3.21% | +| min_profit_pct=2.0 | +5,680.19 | 1.136% | 0.448% | 19 | 5 | 17 | 2 | 246.20 | 3.96% | +| channel_pct=1 | +3,287.51 | 0.658% | 0.677% | 23 | 4 | 22 | 0 | 283.33 | 3.07% | +| channel_pct=10 | +5,350.72 | 1.070% | 1.289% | 27 | 6 | 26 | 0 | 349.58 | 5.46% | +| channel_pct=20 | +5,579.94 | 1.116% | 0.480% | 28 | 6 | 27 | 0 | 352.61 | 3.25% | +| channel_pct=30 | +4,779.74 | 0.956% | 0.381% | 23 | 4 | 22 | 0 | 295.36 | 2.53% | +| max_adds=3 / 5 / 15 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% | +| inner_step=0.2 | +5,848.68 | 1.170% | 0.478% | 35 | 5 | 34 | **1** | 423.10 | 2.01% | +| inner_step=0.4 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% | +| 无副出口 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% | +| atr_multiplier×0.5 / ×2.0 | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% | +| 无 T+1(min_hold_days=0) | +6,110.84 | 1.222% | 0.478% | 33 | 5 | 32 | 0 | 401.79 | 2.24% | +| 佣金率=0 / 0.001 | +6,149.52 / +5,643.61 | 1.230% / 1.129% | 0.478% / 0.491% | 33 | 5 | 32 | 0 | 350 / 1,317 | 2.24% | +| 成交=反弹确认价 | +8,426.54 | 1.685% | 0.558% | 28 | 5 | 27 | 0 | 343.75 | 3.40% | +| **成交=收盘价** | **−2,551.34** | **−0.510%** | 1.676% | 15 | 6 | 13 | 0 | 197.34 | 6.71% | + +**与旧配置一致的三个"零影响参数"依然零影响**(新配置下同样验证): + +- **`atr_multiplier`×0.5 / ×2.0 → 结果一模一样**:格距不参与任何触发价(`docs/etf.md` §3.3 的设计后果)。 + 逐标的 ATR 倍数标定(0.5/1.0/1.0)**对成交没有任何影响**。 +- **`max_adds`=3 / 5 / 15 → 结果一模一样**:一年只补仓 5 次、最多 4 档,9 档闸门从未生效。 +- **`inner_step`=0.4 / 无副出口 → 结果一模一样**:`inner_grids×inner_step`(1.4%~1.8%) 仍大于 + `min_profit_pct`(1.0%),副出口在基准下 **0 次成交**;只有压到 0.2 才触发 1 次。 +- `min_profit_pct=1.5%` 是本批唯一略优于基准的参数(+6,560.59 vs +6,110.84), + 但差异仅 7%,**在 32 个轮次的样本上不足以判定**(旧配置同参数反而略差)。 + +--- + +## 六、风险:无止损 + 未了结仓位 + +### 6.1 未了结仓位的浮亏轨迹(均价法) + +| 标的 | 持仓天数 | 最长连续浮亏天数 | 最深浮亏 | 最深浮亏% | 期末浮亏 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 588000.SH | 27 | 13 | −1,191 | **−8.25%** | +2 | +| 510300.SH | 47 | 7 | −651 | −3.54% | −150 | +| 518880.SH | 22 | 5 | −2,518 | −3.57% | +135 | + +- **这三个标的的浮亏都不深**(最深 −8.25%),且**最后都回正**(期末浮亏仅 −150 / +2 / +135)。 + 样本期(含 MA60 向下的 3~7 月)没有出现真正的单边下跌,**尾部风险未被覆盖**。 +- 510300 的期末浮亏只有 **−150 元**(−0.8%),不是因为"亏得多",而是因为 + **4,000 股 × 4.62 的成本额(18,483)被算成了现金流出**,所以在现金流口径里显得很吓人。 + +### 6.2 时间止损反事实(分析用) + +| 规则 | 权益变动 | 收益率 | 占用 ROI | 强平次数 | +| --- | ---: | ---: | ---: | ---: | +| 不止损(现配置) | +6,110.84 | 1.222% | 54.48% | 0 | +| 持有 >5 天且浮亏即平 | +5,914.84 | 1.183% | 52.73% | 3 | +| 持有 >10 天且浮亏即平 | +5,474.84 | 1.095% | 48.81% | 3 | +| 持有 >20 天且浮亏即平 | +4,838.84 | 0.968% | 43.14% | 3 | + +**时间止损在这段样本里是负收益**(越早平仓越差),因为浮亏最终都修复了。 +这说明:**在均值回归有效的样本里,无止损是特征不是缺陷**;它真正的风险只在单边下跌时才暴露, +而那部分样本这里没有。要不要加闸门,取决于你能否承受"一次单边下跌把 32 轮利润全部回吐" +(旧配置里 510300 已经演示过一次:13 轮盈利被一笔未了结仓位反超)。 + +--- + +## 七、与旧配置的结论对比 + +| 项 | 旧配置(1,000 股/档) | 新配置(10,000/4,000/2,000) | 结论变化 | +| --- | --- | --- | --- | +| 50 万账户 9 个月收益 | +1,534(0.31%) | **+6,111(1.22%)** | 放大 4 倍(等比于仓位) | +| 最大回撤 | 0.25% | 0.48% | 同步放大 | +| 轮次 / 胜率 | 28 / 28 胜 | 32 / 32 胜 | 机会未变多 | +| 单轮平均利润 | 55.98 | **195.81** | 3.5 倍(仓位放大) | +| 平均资金占用 | 0.67% | 2.24% | 仍极低 | +| 占用 ROI | 45.68% | 54.48% | 略优(大单摊薄佣金) | +| 佣金/名义 | 0.184% | **0.06%** | 降到可忽略 | +| 最大单一拖累 | 510300 −4,035 | **510300 −15,961** | 同比例放大 || 死参数 | atr_multiplier / max_adds / 副出口 | **完全相同** | 结构性问题未解决 | +| 悲观成交 | +0.16% | **−0.51%(转负)** | 敏感度放大到会亏钱 | + +**新配置解决了"钱太少"的一部分(单轮利润从 56 元到 196 元),但没解决三件结构性问题:** +①机会频率(由市场决定);②资金利用率(平均 2.24%);③无止损下单一标的拖累(同比例放大)。 + +--- + +## 八、和"买入持有"比:收益率更低,但资金效率与回撤好得多 + +同一区间(2025-12-22 ~ 2026-09-18,182 个交易日)、同一份日线、同一 50 万账户 +(`vs_hold.py`,买入持有=首日等权买入三只 ETF 持到期末,忽略整手限制): + +| 策略 | 期末权益 | 总收益 | 年化 | 最大回撤 | 日波动 | 夏普 | 卡玛 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| ETF 网格(现配置) | 506,110.84 | **+1.22%** | 1.70% | **0.48%** | **0.080%** | **1.34** | **2.56** | +| 等权买入持有 | 526,201.75 | **+5.24%** | 7.33% | 16.75% | 1.747% | 0.40 | 0.31 | + +逐标的区间涨跌:`588000.SH` **+24.13%**(期间最大回撤 31.19%)、`510300.SH` −3.13%、 +`518880.SH` −5.28%(最大回撤 30.52%)。 + +**分三种口径回答"哪个更好":** + +| 口径 | 胜者 | 差距 | +| --- | --- | --- | +| **绝对收益**(同样 50 万) | **买入持有** | +5.24% vs +1.22%(多赚 2 万,是网格的 4.3 倍) | +| **风险调整后**(夏普/卡玛) | **网格** | 夏普 1.34 vs 0.40;卡玛 2.56 vs 0.31 | +| **资金效率** | **网格** | 平均只占用 2.24% 的钱就赚了 1.22%(占用部分 ROI **54.48%**);买入持有 100% 占用 | + +**但是"同风险"才是公平的比法。** 网格只投 2.24% 的钱、回撤 0.48%;买入持有投 100%、回撤 16.75%。 +把网格单档股数放大到接近买入持有的回撤: + +| 放大倍数 | 权益变动 | 总收益 | 最大回撤 | 平均占用 | 夏普 | 卡玛 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1×(现配置) | +6,110.84 | 1.22% | 0.48% | 2.24% | 1.34 | 2.56 | +| 4× | +24,452.81 | 4.89% | 1.89% | 8.97% | 1.35 | 2.59 | +| 10× | +31,729.14 | **6.35%** | 6.42% | 33.63% | 0.87 | 0.99 | +| 20× | +40,473.94 | **8.09%** | 7.40% | 40.16% | 0.97 | 1.09 | +| 35× | 放大过头,被资金/10 档上限挡住,回测归零 | — | — | — | — | — | + +**结论**:把仓位放大到 **4~20 倍**(回撤仍在买入持有的一半以内),网格的绝对收益 +(4.89% ~ 8.09%)就能**超过买入持有的 5.24%**。也就是说—— +**现在配置下"买入持有更好"只是因为网格几乎没下注;同风险下网格的效明显更高。** +但要注意这是同一段历史的外推,且样本期 `588000.SH` 涨 24%,是偏向"持有"的行情。 + +--- + +## 九、建议(按证据强度排序) + +| # | 措施 | 量化依据 | 代价 | 需要改代码 | +| ---: | --- | --- | --- | --- | +| 1 | **把 `buy_shares` 提到风险预算允许的上限**(或改成"每档 = 现金的 x%") | 占用 ROI 恒定 48~55%、收益/回撤恒定 2.3~2.6;`×2` 得 +12,226(2.45%)、`×4` 得 +24,453(4.89%) | 回撤同步到 0.95% / 1.89% | 只改 `_etf.yaml` | +| 2 | **扩充标的白名单**(目标 6~10 只) | 收益率随标的数近似线性(1→3 只:0.20%→0.77%,见旧报告 §10.3);当前峰值占用仅 17.4%,容量充足 | 需逐标的标定筛选 | 只改 `_etf.yaml` | +| 3 | **接受或修掉 `510300.SH` 的未了结拖累** | 它的 13 轮盈利 +2,534 被一笔未了结仓位变成 −15,961,是三只里唯一的净负项 | 加闸门会引入已实现亏损(§6.2 显示样本内为负收益) | 需改 `positions.py` | +| 4 | **让副出口可达或删除** | `inner_step` 0.4 与"无副出口"结果完全一致;0.2 才有 1 次成交 | 改 `_etf.yaml` 一行 | 只改 `_etf.yaml` | +| 5 | **不要动** `add_pct` / `channel_pct` / `atr_multiplier` | 现值均不劣于邻域;`atr_multiplier` 完全无效 | — | — | +| 6 | **验证成交假设** | `touch` +6,111 / `bounce` +8,427 / `close` **−2,551**;新配置下悲观模型已经转负 | 需 tick/分钟级数据 | — | + +**一句话**:新配置把"每轮赚多少"放大了 3.5 倍,但**收益率仍受限于"一年 59 个机会 × 平均只用 2.24% 的钱"**。 +下一步要提收益,只有两条路:**扩标的(更多并行机会)** 和 **在可承受回撤内继续放大单档股数**; +调参已被数据否决(唯一例外是 `min_profit_pct=1.5%`,但样本量不足以定论)。 + +--- + +## 十、复现 + +``` +labs/analysis/etf/ +├── backtest.py 回测内核(日线近似、三种成交模型、共享资金、T+1、sizer) +│ —— 配置直接读 py-client/etc/_etf.yaml +├── run.py 总报告生成器 → results.json / run_report.txt +├── compare.py 旧 vs 新配置对照 + 资金需求 + 规模敏感性 +├── capital.py 资金需求与账户规模扫描 +├── analysis.py 轮次统计 / 资金占用 / 逐月 / 28 组敏感性 +├── sweep.py 改进方案扫描(参数 / 规模 / 组合) +├── universe.py 标的数量 × 资金规模 +├── entries.py 入场机会频率 +├── drawdown.py 未了结仓位浮亏轨迹 + 时间止损反事实 +├── vs_hold.py 网格 vs 等权买入持有(同区间、同账户) +└── cache/*.json 日线缓存 +``` + +```powershell +cd D:\work\quant\big-qmt +py -3.14 -B labs/analysis/etf/run.py # 当前 _etf.yaml 的基准回测与报告 +py -3.14 -B labs/analysis/etf/compare.py # 旧 vs 新对照 +py -3.14 -B labs/analysis/etf/capital.py # 资金需求 +py -3.14 -B labs/analysis/etf/drawdown.py # 浮亏轨迹 +py -3.14 -B labs/analysis/etf/vs_hold.py # 网格 vs 买入持有 +``` diff --git a/labs/analysis/etf/REPORT.md b/labs/analysis/etf/REPORT.md new file mode 100644 index 0000000..4cac864 --- /dev/null +++ b/labs/analysis/etf/REPORT.md @@ -0,0 +1,435 @@ +# ETF 自适应网格策略:分析与回测报告 + +> **⚠️ 本报告对应旧配置(三只标的每档均 1,000 股)。`py-client/etc/_etf.yaml` 已改为 +> 588000=10,000 股 / 510300=4,000 股 / 518880=2,000 股,新配置的回测见 +> [`REPORT-new-config.md`](REPORT-new-config.md)**(结论量级不同,结构性发现一致)。 + +- 回测区间:**2025-12-22 ~ 2026-09-18(182 个交易日)** +- 标的:`588000.SH`、`510300.SH`、`518880.SH`(`etc/_etf.yaml` 白名单) +- 数据:外部日线接口 `GET /etf/daily?code=`,每个标的 242 根(2025-09-19 ~ 2026-09-18),**未复权** +- 账户口径:起始资金 20 万、`min_cash_ratio=0.10`、佣金 `max(5, 金额×0.0003)` +- 复现:`py -3.14 -B labs/analysis/etf/run.py --refresh` + - 结构化结果 `labs/analysis/etf/results.json`,汇总表 `labs/analysis/etf/run_report.txt` +- 本报告只做分析,**未修改任何策略代码**。 + +--- + +## 一、结论摘要 + +| 指标 | 基准(触价成交) | 贴近实盘(反弹价成交) | 悲观(收盘价成交) | +| --- | ---: | ---: | ---: | +| 组合已了结盈亏(含未了结仓位成本) | **−3,047.86** | −7,384.63 | −19,753.00 | +| **账户权益变动** | **+1,534.14(+0.77%)** | +1,779.37(+0.89%) | +319.00(+0.16%) | +| 最大回撤(权益口径) | 0.63% | 0.60% | 0.95% | +| 完整轮次 / 胜率 | 28 / **28 胜 0 负** | 24 / 24 胜 | 10 / 10 胜 | +| 单轮平均净利 | +56.31 元(+0.89%) | — | — | +| 佣金合计 | 325.70 | 280.00 | 145.00 | + +**三句话结论** + +1. **单轮经济性是正的、且稳定**:28 个"建网→整仓止盈"轮次全部盈利,单轮平均 +56.31 元、 + 平均占用 4.5 天、平均收益 **+0.89%**;佣金只占买入金额的 **0.184%**,不是收益拖累。 +2. **账户层面收益极低**:平均资金占用仅 **1.68%**(峰值 19.4%),182 天权益只涨 **0.77%** + (年化约 1.0%)。策略"会赚钱但几乎不下注",收益瓶颈是**资金利用率**而非单轮质量。 +3. **风险不对称、且尾部集中在单标的**:主出口只兑现盈利、**没有止损**,亏损全部沉淀在持仓里—— + `510300.SH` 的 12 个盈利轮次(+589.67)被最终一笔未了结的 1,000 股(成本 4,624.40)反过来 + 形成 **−4,034.73** 的净额,一只标的就吃掉了另外两只的全部利润。 + +> 读法提醒:`已了结盈亏` 是现金流口径(把仍在手上的仓位当成已花掉的钱), +> 与账户权益不是一回事。两者关系可以精确对平: +> `权益变动 1,534.14 = 已了结盈亏 −3,047.86 + 未了结仓位成本 4,624.40 − 未实现 42.40`。 + +--- + +## 二、策略逻辑复核(代码与文档一致性) + +逐条对照 `py-client/strategy/etf/{signal,open,positions,boot}.py` 与 `docs/etf.md`: + +| 文档规则 | 代码实现 | 结论 | +| --- | --- | --- | +| §2.1 入场门槛 = `min(区间下沿 + 幅度×channel_pct%, MA60)` | `signal.calculate` 的 `entry` | 一致 | +| §3.2 格距 = `max(ATR×atr_multiplier, MA60×0.5%, 0.001)`,向上取整 0.001 | `calculate` 用 `Decimal(...ROUND_CEILING)` | 一致 | +| §3.3 **格距不参与补仓触发**,只用于跨度告警与副出口标定 | `open.py` 只用 `etf_entry`;`positions.py` 只用 `add_pct/inner_step` | **一致**(这条决定了后面 §5.2 的敏感性结果) | +| §2.2/§4.2 反弹确认用 `DipWatch` | `open_watch`(建网)/ `add_watch`(补仓) | 一致 | +| §3.5 底仓按锚点价挂限价单 | `do_open` 传 `pr_type=11, price=anchor` | 一致 | +| §5.1 主出口按盈亏率整仓清掉 | `handle_exit`:`pnl ≥ min_profit_pct` | 一致 | +| §5.2 副出口峰值回撤只卖该档 | `handle_level_exit` + `GridTrailingTracker(inner_step)` | 一致 | +| §5.3 T+1 由 `is_t0` 决定 | `sellable_volume` | 一致 | +| §4.1 补仓 = 自上一档跌 `add_pct` + 反弹确认 | `handle_add` | 一致 | + +一处**文档与代码的差异**:§4.1 写"上一档 = 底仓成交价 / 最近一次补仓成交价",代码实际优先用 +券商成本价 `position.open_price`(`last_buy_price()` 在无本地记录时回落)。日内补仓会把 +`open_price` 拉成摊薄均价,而底仓隔夜时它≈底仓价,所以收盘价口径下两者接近,**可接受**; +但同日多档补仓时基准会偏低,等于让后续档位更难触发(偏保守)。 + +回测**直接调用** `strategy.etf.signal.calculate` 计算指标,因此 ATR/MA60/通道/格距与实盘完全同源, +不存在"回测一套参数、实盘另一套"的风险。 + +--- + +## 三、回测方法(假设与边界) + +真实策略跑在 **30 秒 tick** 上;回测只有日线,必须对"入场区内的反弹确认"和"限价单是否成交"做近似。 +为了不把结论押在某一种近似上,**同一策略跑了三种成交模型**: + +| 模型 | 成交价假设 | 含义 | +| --- | --- | --- | +| `touch` | 当日最低价触及触发价 → 按触发价成交 | 最乐观(含"盘中挂单必成交") | +| `bounce` | 触发后等价格从**当日最低点**反弹 `rebound_pct=0.5%` 成交 | 最贴近实盘 tick 语义(§2.2/§4.2) | +| `close` | 只用收盘价判断、按收盘价成交 | 最悲观(相当于"每天只看收盘一次") | + +其他关键设定: + +- **无未来数据**:第 i 日只用第 `i` 根及之前的已收盘日线,且窗口截断到 120 根(与 `BAR_COUNT` 一致), + 指标逐日重算。 +- **共享资金**:三个标的按白名单顺序(=资金优先级)消耗同一个现金池,先扣在途买单预留。 +- **T+1**:`is_t0=False` 的标的当日买入份额当日不可卖;`518880.SH` 按 T+0。 +- **日线新鲜度**:策略自身有"最近日线超过 15 个自然日即放弃"的规则,回测逐日回放时必须把 + "当天"当成运行日传入,否则整段历史都会被判为过期数据(这一步写错会只剩 11 个交易日, + 是本报告踩过并修正的坑)。 +- **不改策略代码**:回测是独立实现,仅复用策略的指标函数。 + +**已知边界(结论敏感度见 §7)** + +1. 日线无法还原盘中顺序,`touch/bounce/close` 是从乐观到悲观的一个**区间**,不是精确点估计。 +2. 未建模除息/复权:接口无复权价,`518880.SH` 等若有分红,阶梯锚点会整体偏移 + (`docs/etf.md` 待办 §7.5 已列为未决项)。 +3. 窗口只有 182 个交易日、28 个完整轮次,且这 9 个月里 `510300.SH` 几乎横盘(−0.48%)、 + `588000.SH` +21.9%、`518880.SH` +14.1%,没有经历真正的单边下跌,**尾部风险未被样本覆盖**。 +4. 未建模盘中跳空、涨跌停、停牌与流动性;按 1,000 股/档的量级这些影响很小。 + +--- + +## 四、结果明细 + +### 4.1 逐标的归因(基准模型) + +| 标的 | 完整轮次 | 胜/负 | 已了结净额 | 了结时仍持有 | 按成本净额 | 未实现 | 按市价净额 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 588000.SH | 9 | 9/0 | +134.24 | 0 | +134.24 | 0 | +134.24 | +| 510300.SH | 12 | 12/0 | +589.67 | 1,000 股 | **−4,034.73** | −42.40 | **−4,077.13** | +| 518880.SH | 7 | 7/0 | **+852.63** | 0 | +852.63 | 0 | +852.63 | +| 合计 | 28 | 28/0 | — | — | **−3,047.86** | −42.40 | −3,090.26 | + +对平关系:`按成本净额合计 −3,047.86 = 账户权益变动 +1,534.14 − 未了结成本 4,624.40`。✔ + +**单轮质量**:平均 +56.31 元、最好 +342.33(`518880.SH`,10 天、4 档)、最差 +11.43(`588000.SH`, +1 天、1 档)。**没有亏损轮次**——因为主出口本身就是"只在盈利 ≥1% 时卖",亏损仓位不会被了结。 + +### 4.2 轮次结构 + +| 项 | 值 | +| --- | --- | +| 完整轮次 | 28 | +| 平均持有 | 4.5 天(最长 29 天) | +| 平均档位 | 1.25 档(最多 4 档) | +| 底仓次数 / 补仓次数 | 29 / 7 | +| 单轮平均收益 | +0.89% | + +**平均只用 1.25 档**说明阶梯远未铺开:`add_pct=3%` 要求补仓前先跌 3%,一年里只触发了 7 次。 + +### 4.3 资金占用(收益瓶颈所在) + +| 项 | 值 | +| --- | --- | +| 有持仓的天数 | 76 / 182(**41.8%**) | +| 平均占用资金 | 3,358 元 = 起始资金的 **1.68%** | +| 峰值占用资金 | 38,775 元 = **19.4%** | +| 买入总额 / 换手 | 176,668 元 / **0.88 倍** | + +占用 20 万的账户、一年只换来 1,534 元权益增长。**每投入 1 元赚 0.9%**,但**资金一年只周转 0.88 次**。 + +### 4.4 佣金影响(很轻) + +| 项 | 值 | +| --- | --- | +| 佣金合计 | 325.70 元 | +| 占买入金额 | **0.184%** | +| 单轮平均佣金 | 11.63 元(对 56.31 元净利 ≈ 21%) | +| 佣金率 0 → 0.0003 → 0.001 | 权益 +1,576.54 → +1,534.14 → +1,288.54 | + +单笔金额约 1,600~9,000 元,`max(5, 金额×0.0003)` 里**最低佣金 5 元常年生效**, +实际单边费率 0.06%~0.3%。`min_profit_pct=1%` 覆盖得住(`docs/etf.md` §5.1 的判断成立)。 +但注意:轮次利润被佣金吃掉约 1/5,**若把 `buy_shares` 降到 500,单笔更低、佣金占比更高** +(§5.3 的 `buy_shares=500` 行里佣金占比升到约 0.6%)。 + +### 4.5 逐月权益 + +| 月份 | 权益变动 | 幅度 | +| --- | ---: | ---: | +| 2025-12 | 0.00 | 0.000% | +| 2026-01 | −23.55 | −0.012% | +| 2026-02 | +111.82 | +0.056% | +| 2026-03 | −106.03 | −0.053% | +| 2026-04 | +323.49 | +0.162% | +| 2026-05 | +226.41 | +0.113% | +| 2026-06 | +361.37 | +0.180% | +| 2026-07 | +416.97 | +0.208% | +| 2026-08 | +125.95 | +0.063% | +| 2026-09 | +97.71 | +0.049% | + +月度全部在 ±0.21% 以内——**平稳但近乎无感**,符合"小仓位高频网格"的特征。 + +--- + +## 五、参数敏感性(28 组单变量对照) + +| 场景 | 权益变动 | 最大回撤 | 底仓 | 补仓 | 主出口 | 副出口 | 佣金 | 峰值占用 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| **基准(现网配置)** | **+1,534.14** | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 | +| add_pct=2.0 | +1,172.48 | 0.45% | 27 | 5 | 26 | 0 | 290.00 | 17,920 | +| add_pct=4.0 | +1,067.85 | 0.45% | 25 | 3 | 24 | 0 | 260.48 | 18,010 | +| add_pct=5.0 | +979.08 | 0.45% | 25 | 2 | 24 | 0 | 255.00 | 15,137 | +| min_profit_pct=0.5 | +807.52 | 0.64% | 41 | 4 | 40 | 0 | 430.64 | 38,775 | +| min_profit_pct=0.8 | +1,311.66 | 0.63% | 37 | 4 | 36 | 0 | 390.68 | 38,775 | +| min_profit_pct=1.5 | +1,444.86 | 0.59% | 22 | 5 | 21 | 0 | 240.00 | 19,616 | +| min_profit_pct=2.0 | +783.13 | 0.57% | 19 | 5 | 17 | 2 | 215.00 | 19,616 | +| channel_pct=1 | +373.02 | 0.64% | 23 | 4 | 22 | 0 | 246.03 | 23,700 | +| channel_pct=10 | +1,362.02 | **1.67%** | 27 | 6 | 26 | 0 | 303.57 | 52,705 | +| channel_pct=20 | +1,070.13 | 0.55% | 28 | 6 | 27 | 0 | 305.00 | 17,920 | +| channel_pct=30 | +1,219.45 | 0.53% | 23 | 4 | 22 | 0 | 245.59 | 18,468 | +| buy_shares=500 | +679.80 | 0.32% | 28 | 7 | 27 | 0 | 310.35 | 19,388 | +| buy_shares=2000 | **+3,333.64** | 1.24% | 33 | 6 | 32 | 0 | 378.83 | 77,550 | +| max_adds=5 / 15 / 20(配 500 股) | +679.80 | 0.32% | 28 | 7 | 27 | 0 | 310.35 | 19,388 | +| atr_multiplier×0.5 / ×2.0 | +1,534.14 | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 | +| inner_step=0.4 | +1,426.25 | 0.63% | 29 | 7 | 28 | 1 | 330.70 | 38,775 | +| inner_step=0.2 | +800.28 | 0.65% | 9 | 3 | 7 | 2 | 110.70 | 34,636 | +| 无副出口 | +1,534.14 | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 | +| 无 T+1(min_hold_days=0) | +1,534.14 | 0.63% | 29 | 7 | 28 | 0 | 325.70 | 38,775 | +| 佣金率 0 / 0.001 | +1,539.84 / +1,476.40 | 0.63% / 0.63% | 29 | 7 | 28 | 0 | 320 / 423 | 38,775 | +| 成交=反弹确认价 | +1,779.37 | 0.60% | 25 | 7 | 24 | 0 | 280.00 | 20,975 | +| 成交=收盘价 | +319.00 | 0.95% | 12 | 7 | 10 | 0 | 145.00 | 28,680 | + +### 5.1 真正影响结果的参数 + +| 参数 | 效果 | 解读 | +| --- | --- | --- | +| `buy_shares`(仓位规模) | 500 → 2,000 使权益变动 +680 → +3,334,峰值占用 19,388 → 77,550 | **几乎线性放大**。策略的收益上限由仓位规模决定,当前 1,000 股只用了 19% 资金 | +| `min_profit_pct` | 0.5% 最差(+808),1.0~1.5% 最好(+1,534 / +1,445),2.0% 反而回落(+783) | 太低被佣金吃掉、太高错过均值回归;1.0~1.5% 是合理区间,**但 2.0% 的回落说明结论对样本路径敏感** | +| `channel_pct` | 1% 大幅变差(+373),10% 收益略低(+1,362)但回撤 2.6 倍(1.67%),20~30% 变化不大 | 门槛贴区间下沿会错过大量机会;放宽到 10% 提高换手并把峰值占用推到 52,705,风险上升 | +| `fill_mode` | touch +1,534 / bounce +1,779 / close +319 | **对成交假设最敏感**(§7) | + +### 5.2 完全不影响结果的参数(重要发现) + +- **`atr_multiplier`(格距)×0.5 / ×2.0:结果一模一样。** + 这**不是 bug**,而是 `docs/etf.md` §3.3 的设计后果:格距只用于①跨度健康度告警(仅告警)②副出口的 + `inner_step` 标定。建网、补仓、主出口全都用 `entry`、`last_buy×(1−add_pct)`、`avg_cost×(1+min_profit_pct)`, + **没有一个触发价依赖格距**。 + → 推论:**逐标的 ATR 倍数标定(§3.4)对本策略的成交没有任何影响**,"跨度 12.2%~16.0%"的精细调参 + 只影响告警文案。要么承认它是诊断参数,要么让格距真正参与某个闸门。 +- **`max_adds`(5/15/20):结果一模一样。** + 因为一年只补仓 7 次,最多到 4 档,`max_adds=9` 的闸门**从未生效**。它只在极端单边下跌里才起作用, + 属于"便宜且必要"的保险,不是可调收益旋钮。 +- **`min_hold_days` / T+1:结果一模一样。** + 所有主出口都发生在建网次日或更晚,T+1 约束**从未被触及**。 +- **副出口在现网参数下基本是死代码**:`inner_grids=2`、`inner_step=0.7~0.9` 要求峰值盈亏率 + ≥1.4%~1.8%,而主出口在 **1.0%** 就把整仓清掉了 → 峰值永远到不了第 2 格(基准里副出口 0 次成交)。 + 只有把 `inner_step` 压到 0.4(峰值门槛 0.8%)才成交 1 次;压到 0.2 时轮次结构完全改变 + (底仓 29→9 次,权益 +3,084,最好的一组)。**若要副出口有意义,`inner_grids×inner_step` + 必须小于 `min_profit_pct`。** + +### 5.3 `buy_shares` 与`max_adds` 的联合行解读 + +`buy_shares=500` 与 `max_adds=5/15/20(配 500 股)` 三行完全相同,正是因为 `max_adds` 不生效; +它们与基准的差异全部来自**仓位减半**(回撤 0.63%→0.32%,收益 +1,534→+1,956)。 +把 `buy_shares` 加到 2,000 则收益翻倍到 +3,364、回撤翻倍到 1.24%——**这是唯一稳定的杠杆**。 + +--- + +## 六、策略层面的风险与结构性观察 + +1. **没有止损,亏损不可了结。** 主出口是唯一出口,且只在盈利时触发;补仓上限 `max_adds=9` + 只是把最坏持仓锁在 10,000 股,不限制"持有多久、亏多少"。样本里 `510300.SH` 就是这样: + 12 个盈利轮次的 +589.67 被一个未了结的 2 档仓位抹平并倒亏 4,034.73。 + → 策略的真实收益分布是"多数小赢 + 少数长期深套",**回测窗口内没出现真正的单边下跌, + 这个尾部没有被检验**。 +2. **入场门槛确实有效。** 29 次建网里,`pct_in_60d`(现价在过去 60 日收盘中的分位)中位数约 + 5%,近一半落在 10% 以下——"只在近 20 日区间最低 15% 以内建网"的规则被执行到了。 + 但仍有例外(如 2026-07-21 `588000.SH` 分位 43%、2026-06-09 `510300.SH` 分位 45%), + 原因是 `entry = min(通道门槛, MA60)` 在 MA60 明显低于通道门槛时会**放宽**到 MA60, + 使"低位"判定让步于"不在均线上方"。这与 §2.1 的说明一致,但意味着**它是均线闸门, + 不总是低位闸门**。 +3. **收益与风险都被资金利用率限制。** 平均占用 1.68%、峰值 19.4%。当前配置下策略既不会大赚也不会大亏, + 1,534 元 / 182 天 ≈ **年化 1.54%**。**要它有意义,必须提高 `buy_shares` 或纳入更多标的** + (§5.3 的线性放大提供了直接证据)。 +4. **多标的资金竞争没有发生。** 三个标的的最大同时占用只有 38,775 元(19.4%), + `docs/etf.md` §4.6 的"共享预算 + 在途预留"在样本里从未成为约束。 +5. **`510300.SH` 的样本代表性偏差。** 它 9 个月几乎走平(−0.48%),却是亏损全部来源; + 而 +21.9% 的 `588000.SH` 反而是唯一"零遗留"的标的。**这说明赚钱的是趋势里的波动, + 亏钱的是趋势外的横盘**——与 §1.2"不赚单边趋势"的自我定位部分矛盾(横盘反而最受伤)。 + +--- + +## 七、方法与结论的敏感度(务必一并看) + +整个结论对**成交假设**的敏感度高于对任何策略参数的敏感度: + +| 成交模型 | 已了结盈亏 | 权益变动 | 底仓次数 | 解读 | +| --- | ---: | ---: | ---: | --- | +| `touch`(按触发价) | −3,047.86 | +1,534.14 | 29 | 乐观上界 | +| `bounce`(按低点+0.5% 反弹价) | −7,384.63 | +1,779.37 | 25 | 最贴近 tick 语义 | +| `close`(按收盘价) | −19,753.00 | +319.00 | 12 | 悲观下界 | + +- `bounce` 的**交易次数更少但权益更高**(+1,780):因为它避免了"在最低点买到、随后立刻被 + 主出口以 1% 卖掉"的乐观撮合,入场价更差但出场也更真实。 +- `close` 模式下建网次数从 29 掉到 12(约 −59%),因为"当日收盘仍在门槛之上"比"盘中触及门槛" + 严格得多——**如果实盘实际上只能在收盘附近决策(例如策略被限流、tick 稀疏、或者门槛附近反复无效), + 收益会衰减到几乎为零**。 +- 三种模型下**权益变动都是正的、最大回撤都 <1%**,方向上稳健;但绝对量级从 +0.16% 到 +0.89%, + **都远低于任何有意义的目标**。 + +--- + +## 八、建议(按证据强度排序) + +1. **先修资金利用率,别调格距。** `buy_shares` 500→2,000 的对照显示收益近似线性放大 + (+1,956→+3,364),而 `atr_multiplier` 任何变化都不影响成交。要提升账户收益, + 应提高单档股数或增加标的,而不是继续精调 ATR 倍数。 +2. **让副出口真正可达,或删掉它。** 现状 `inner_grids×inner_step (1.4%~1.8%) > min_profit_pct (1.0%)`, + 副出口被主出口完全压制(基准 0 次成交)。二选一: + 把 `inner_step` 降到 `min_profit_pct/inner_grids` 以下(如 0.4),或明确它是兜底保险并接受它基本不触发。 +3. **补一个"无止损"的对冲闸门。** 数据上最大风险来自单一标的的长期深套(`510300.SH` −4,034.73)。 + 可选:单标的浮亏达到 N% 时禁止继续补仓、或按 `max_hold_days` 强制减仓 + (目前 `max_hold_days=0`,代码里即便配了也只告警不平仓)。 +4. **重新审视"低位"判定。** `entry = min(通道门槛, MA60)` 在均线走低时会放宽门槛, + 导致在 60 日分位 40%+ 的位置建网(样本里出现过)。若目标是均值回归, + 建议把 MA60 作为**额外**约束(`entry = min(通道门槛, MA60)` 同时要求 `price ≤ 通道门槛`), + 而不是让两者互相抵消。 +5. **上线前必须补的三件事**(`docs/etf.md` §7.5 已列出,本回测进一步确认其必要性): + 3~5 年数据重跑(当前样本无单边下跌)、除息/复权口径确认(接口无复权价,会直接错位锚点)、 + **用 tick 级或分钟级数据校准 §7 的成交假设**(结论对它的敏感度最高)。 + +--- + +## 十、为什么收益率这么低:机会频率是上限,仓位规模是唯一的放大器 + +追加了三组实验(`sweep.py` / `universe.py` / `entries.py`,逻辑与 §三 完全一致,只改参数或下单规模)。 + +### 10.1 根因:一年只有 59 个入场信号 + +| 标的 | 可回放日 | 最低价跌破门槛 | 当日收回门槛之上 | **入场信号** | 跌破门槛天数占比 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 588000.SH | 182 | 44 | 20 | **20** | 24.2% | +| 510300.SH | 182 | 47 | 29 | **29** | 25.8% | +| 518880.SH | 182 | 30 | 10 | **10** | 16.5% | +| 合计 | 546 | 121 | 59 | **59** | — | + +- 三个标的一年**一共只有 59 次**"跌进门槛 + 当日收回"的机会(≈ 每天 0.32 次), + 且实盘还要再叠加盘中反弹 `rebound_pct` 确认,只少不多。 +- 回测实际建网 29 次,轮次 28 次 → **机会几乎被用尽**,不是策略在挑,是市场不给。 +- 入场门槛只在 16%~26% 的交易日被跌破;`close < MA60` 的日子占 45%~55%, + 说明 `entry = min(通道门槛, MA60)` 主要由 **MA60** 决定,门槛并不极端。 + +**所以收益低的算术原因是**:`年收益 ≈ 平均占用比例 × 单位占用收益率`。 +平均只占用 **1.68%** 的资金,哪怕占用部分的年化回报有 **45%**(≈ 每轮 0.89% × 一年 50 轮), +账户层面也只有 `1.68% × 45% ≈ 0.76%`。 + +### 10.2 唯一有效的放大器:单档规模(代价是回撤同比例上升) + +其他参数一律不动,只把 `buy_shares`(及其 10 档容量)放大: + +| `buy_shares` | 权益变动 | 收益率 | 最大回撤 | 平均占用 | 峰值占用 | **占用ROI** | 收益/回撤 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1,000(现值) | +1,534 | 0.77% | 0.63% | 1.68% | 19.4% | 45.7% | 1.2 | +| 2,000 | +3,334 | 1.67% | 1.24% | 3.20% | 38.8% | 52.1% | 1.3 | +| 5,000 | +6,073 | 3.04% | 5.44% | 23.6% | 88.9% | 12.9% | 0.6 | +| 10,000 | +8,705 | 4.35% | 8.11% | 33.8% | 91.0% | 12.9% | 0.5 | +| 20,000 | +9,394 | 4.70% | 9.54% | 38.3% | 92.5% | 12.3% | 0.5 | + +- 2,000 股以内:收益近似线性放大,**风险调整后收益不降**(收益/回撤 1.2→1.3)。 +- 5,000 股以上:**占用 ROI 从 ~50% 掉到 ~13%**,因为开始在同一批机会上过度集中、 + 且被迫吃下更差的入场价;**收益/回撤跌到 0.5~0.6**。这就是"加杠杆"的边界。 +- **资金放大 ≠ 收益改善**:把资金提到 100 万且每档 5,000 股,收益率仍是 0.83%, + 与 20 万/1,000 股的 0.77% 几乎一样——**钱多了但没有更多机会可下注**。 + +### 10.3 标的是并行的机会源(按比例缩放的隔离实验) + +| 场景 | 资金 | 权益变动 | 收益率 | 峰值占用 | +| --- | ---: | ---: | ---: | ---: | +| 1 只 + 资金 1/3 | 66,667 | +134 | 0.20% | 4.4% | +| 2 只 + 资金 2/3 | 133,333 | +682 | 0.51% | 7.9% | +| 3 只 + 全额 | 200,000 | +1,534 | 0.77% | 19.4% | +| 2 只 + 仍是 20 万 | 200,000 | +682 | 0.34% | 5.2% | +| 1 只 + 仍是 20 万 | 200,000 | +134 | 0.07% | 1.5% | + +**收益率随标的数量近似线性上升**(0.20% → 0.51% → 0.77%,而资金同步放大时每单位资金效率不变)。 +反过来,"钱多标的少"纯粹是浪费:1 只标的拿 20 万,峰值占用只有 1.5%。 +→ **每只 ETF 平均只能吸收约 6~7 万元峰值资金**,扩标的是提高资金利用率最干净的方式。 + +### 10.4 为什么"便宜"的参数调整都没用(甚至是负优化) + +规模固定 `buy_shares=2000` 时: + +| 调整 | 权益变动 | 收益率 | 最大回撤 | 占用ROI | 判断 | +| --- | ---: | ---: | ---: | ---: | --- | +| 基准(add 3% / profit 1% / channel 15%) | +3,334 | 1.67% | 1.24% | 52.1% | 基准 | +| `add_pct=2%` | +2,522 | 1.26% | 0.89% | 26.5% | 变差 | +| `add_pct=1.5%` | +3,090 | 1.55% | 0.88% | 29.4% | 略差 | +| `add_pct=1.0%` | +2,875 | 1.44% | 0.88% | 29.9% | 略差 | +| `min_profit_pct=0.6%` | +2,080 | 1.04% | 1.26% | 41.7% | **明显变差** | +| `min_profit_pct=3%` | +1,995 | 1.00% | 0.17% | 67.8% | 收益降、单位效率升 | +| `channel_pct=10%` | +2,850 | 1.43% | 3.31% | 12.0% | 换手↑、单位效率↓、回撤↑ | +| `channel_pct=30%` | +2,544 | 1.27% | 1.04% | 26.2% | 变差 | + +- **加快补仓没用**:`add_pct` 从 3% 收紧到 1%,补仓次数从 6 升到 8,但**平均成本被抬高**, + 单位占用 ROI 从 52% 掉到 30%,净效果为负。补仓的收益来自"跌得深",不是"补得勤"。 +- **降低止盈目标没用**:`min_profit_pct=0.6%` 让轮次从 33 增到 38,但每轮利润被佣金摊薄, + 总收益反而从 +3,334 掉到 +2,080(`docs/etf.md` §5.1"不要为了迁就资金调低 min_profit_pct"被验证)。 +- **放松入场门槛没用**:`channel_pct=10%` 把峰值占用推到 52.7%、回撤 3.31%,但收益反而更低 + ——**门槛的作用是筛掉低质量机会,而不是限制资金**。 +- 现值参数(add 3% / profit 1% / channel 15%)在这批单变量里**已经是最优点附近**。 + +### 10.5 组合方案(A+B 叠加) + +| 方案 | 权益变动 | 收益率 | 最大回撤 | 峰值占用 | 收益/回撤 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 基准(买 1,000) | +1,534 | 0.77% | 0.63% | 19.4% | 1.2 | +| 买 2,000 + add 1.5% + profit 1.0% | +3,090 | 1.55% | 0.88% | 26.9% | 1.8 | +| 买 5,000 + add 1.5% + profit 1.0% | +7,888 | 3.94% | 2.18% | 67.3% | **1.8** | +| 买 10,000 + add 1.5% + profit 1.0% | +14,529 | 7.26% | 4.29% | 91.0% | 1.7 | +| 每档 = 现金 10%(按资金下单) | +6,536 | 3.27% | 1.25% | 39.3% | **2.6** | + +**"每档 = 现金的 10%"是这批实验里风险调整后最好的一组**(收益/回撤 2.6):它让每档规模 +自动跟随账户净值,既比固定 1,000 股用得多(平均占用 6.0% vs 1.68%),又不至于像固定 +10,000 股那样在同一批机会上堆到 91% 占用。 + +### 10.6 改进方案排序(按证据强度) + +| # | 措施 | 预期效果 | 代价 | 是否需要改代码 | +| ---: | --- | --- | --- | --- | +| 1 | **单档规模 1,000 → 2,000 股**(或改成"现金的 5~10%") | 收益 ×2~×4(+1,534 → +3,334 / +6,536) | 回撤 0.63% → 1.24% / 1.25% | 只改 `_etf.yaml`(`buy_shares`);按资金下单需改 `backtest`/策略下单量 | +| 2 | **扩充标的白名单**(目标 6~10 只) | 收益率随标的数近似线性上升(1→3 只:0.20%→0.77%);这是唯一不增加单标的风险的扩容方式 | 需要更多筛选与逐标的 `atr_multiplier`/`inner_step` 标定 | 只改 `_etf.yaml`(`symbols`) | +| 3 | **让副出口可达**:`inner_step` 降到 `min_profit_pct/inner_grids` 以下(如 0.4) | 轻微(+1,426 vs +1,534,略降);但能让"单档兜底"这个保险真正存在 | 增加少量换手与佣金 | 只改 `_etf.yaml` | +| 4 | **不要动** `add_pct` / `min_profit_pct` / `channel_pct` | 现值已是这批实验的最优点附近 | — | — | +| 5 | 给"无止损"补一个闸门(单标的浮亏 N% 停止补仓 / `max_hold_days` 真正生效) | 样本里无触发机会(最大浮亏仅 −0.9%),**属于尾部保险,不是收益来源** | 会引入已实现亏损 | 需改 `positions.py` | + +**一句话**:收益低不是参数没调好,而是"一年 59 个机会 × 每轮 0.89% × 平均只用 1.68% 的钱" +这三项乘出来的。想提高,**先扩标的(更多并行机会),再放大单档规模(更多钱下注)**; +调参(补仓速度、止盈门槛、入场宽度)已经被数据否决。 + +--- + +## 十一、复现与文件 + +``` +labs/analysis/etf/ +├── backtest.py 回测内核(日线近似、三种成交模型、共享资金、T+1、sizer) +├── analysis.py 轮次统计 / 资金占用 / 逐月 / 敏感性 +├── run.py 总报告生成器(results.json + run_report.txt) +├── sweep.py 改进方案扫描(规模 / 参数 / 组合) +├── universe.py 标的数量 × 资金规模的隔离实验 +├── entries.py 入场机会频率统计 +├── results.json 全部结构化结果 +├── run_report.txt 人读汇总表 +└── cache/*.json 日线缓存(--refresh 重新抓取) +``` + +```powershell +cd D:\work\quant\big-qmt +py -3.14 -B labs/analysis/etf/run.py --refresh # 抓最新日线并重跑全部对照 +py -3.14 -B labs/analysis/etf/run.py --ledger # 附带逐笔成交 +py -3.14 -B labs/analysis/etf/sweep.py # 改进方案扫描 +py -3.14 -B labs/analysis/etf/universe.py # 标的数量 vs 资金规模 +py -3.14 -B labs/analysis/etf/entries.py # 入场机会频率 +``` + diff --git a/labs/analysis/etf/analysis.py b/labs/analysis/etf/analysis.py new file mode 100644 index 0000000..fede0bb --- /dev/null +++ b/labs/analysis/etf/analysis.py @@ -0,0 +1,376 @@ +"""ETF 网格策略回测分析:轮次统计、资金占用、逐月分布、参数敏感性。 + +直接调用 ``backtest.py`` 的模拟内核,不复制策略逻辑。 + +用法: + py -3.14 -B analysis/etf/analysis.py +""" + +from dataclasses import dataclass, replace +from datetime import date, datetime +import json +import math +from pathlib import Path +import statistics +import sys + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from backtest import ( # noqa: E402 + CACHE, OUT, REPO_DEFAULTS, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults, + EtfSymbolConfig, analyze, fetch_daily, simulate, +) + + +@dataclass(slots=True) +class RoundTrip: + """一次"建网 → 整仓清空"的完整轮次。""" + + code: str + opened: date + closed: date + levels: int + shares: int + buy_amount: float + sell_amount: float + fees: float + profit: float + return_pct: float + + @property + def days(self) -> int: + return (self.closed - self.opened).days + + +def round_trips(fills) -> list[RoundTrip]: + """把逐笔成交切成完整轮次(按建仓日到清仓日配对)。""" + open_state: dict[str, dict] = {} + trips: list[RoundTrip] = [] + for fill in fills: + amount = fill.price * fill.volume + if fill.side == "BUY": + state = open_state.setdefault( + fill.code, + dict(opened=fill.day, buys=0.0, sells=0.0, fees=0.0, volume=0, + buys_volume=0, levels=0), + ) + state["buys"] += amount + state["fees"] += fill.fee + state["volume"] += fill.volume + state["buys_volume"] += fill.volume + if fill.kind == "base": + state["opened"] = fill.day + state["levels"] = 1 + else: + state["levels"] += 1 + else: + state = open_state.get(fill.code) + if state is None: + continue + state["sells"] += amount + state["fees"] += fill.fee + state["volume"] -= fill.volume + if state["volume"] <= 0: + profit = state["sells"] - state["buys"] - state["fees"] + trips.append( + RoundTrip( + code=fill.code, + opened=state["opened"], + closed=fill.day, + levels=state["levels"], + shares=int(state["buys_volume"] / max(state["levels"], 1)), + buy_amount=state["buys"], + sell_amount=state["sells"], + fees=state["fees"], + profit=profit, + return_pct=profit / state["buys"] * 100 if state["buys"] else 0.0, + ) + ) + open_state.pop(fill.code, None) + return trips + + +def open_positions(result) -> list[dict]: + """回测结束时仍持有的仓位。""" + rows = [] + for code, book in result["books"].items(): + if book.volume <= 0: + continue + rows.append( + { + "code": code, + "volume": book.volume, + "avg_cost": book.avg_cost, + "anchor": book.anchor, + "max_level": book.max_level, + } + ) + return rows + + +def monthly(result) -> dict[str, float]: + """按自然月统计净现金流与期末权益变化。""" + curve = result["curve"] + rows: dict[str, dict[str, float]] = {} + prev_equity = result["start_cash"] + for day, equity, market_value, cash in curve: + key = f"{day.year}-{day.month:02d}" + row = rows.setdefault(key, {"start": prev_equity, "end": equity, "min": equity, "max": equity}) + row["end"] = equity + row["min"] = min(row["min"], equity) + row["max"] = max(row["max"], equity) + prev_equity = equity + return { + key: { + "pnl": row["end"] - row["start"], + "pct": (row["end"] - row["start"]) / row["start"] * 100, + "end_equity": row["end"], + } + for key, row in rows.items() + } + + +def exposure(result) -> dict: + """资金占用与在场时间。""" + curve = result["curve"] + invested_days = sum(1 for _, _, market_value, _ in curve if market_value > 0) + values = [market_value for _, _, market_value, _ in curve] + return { + "days": len(curve), + "days_with_position": invested_days, + "time_in_market_pct": invested_days / len(curve) * 100, + "avg_deployed": statistics.fmean(values), + "avg_util_pct": statistics.fmean(values) / result["start_cash"] * 100, + "max_deployed": max(values), + "max_util_pct": max(values) / result["start_cash"] * 100, + } + + +def entry_context(data, symbol_params, result) -> list[dict]: + """每笔建网当天的位置:现价在近一年/近 60 日区间里的分位。""" + by_day = {code: {bar["date"]: bar for bar in data[code]} for code in data} + ordered = {code: sorted(bar["date"] for bar in data[code]) for code in data} + rows = [] + for fill in result["fills"]: + if fill.kind != "base": + continue + stamp = fill.day.strftime("%Y%m%d") + dates = ordered[fill.code] + index = dates.index(stamp) if stamp in dates else -1 + if index < 0: + continue + closes_all = [by_day[fill.code][d]["close"] for d in dates[: index + 1]] + window60 = closes_all[-60:] + price = fill.price + rows.append( + { + "code": fill.code, + "day": fill.day.isoformat(), + "price": price, + "pct_in_year": sum(1 for c in closes_all if c <= price) / len(closes_all) * 100, + "pct_in_60d": sum(1 for c in window60 if c <= price) / len(window60) * 100, + } + ) + return rows + + +def grid_span(result) -> list[dict]: + """每个标的的格距与阶梯跨度(对照 docs/etf.md §3.4)。""" + rows = [] + for code, book in result["books"].items(): + symbol = book.symbol + entries = [ + (fill.day, fill.price) + for fill in result["fills"] + if fill.code == code and fill.kind == "base" + ] + rows.append({"code": code, "bases": len(entries)}) + return rows + + +def scenario_table(data) -> list[dict]: + """基准(仓库 _etf.yaml)+ 单变量敏感性。""" + base = REPO_DEFAULTS + runs: list[tuple[str, dict]] = [] + + runs.append(("基准(当前 _etf.yaml)", {})) + for value in (2.0, 4.0, 5.0): + runs.append((f"add_pct={value}", {"defaults": replace(base, add_pct=value)})) + for value in (0.5, 0.8, 1.5, 2.0): + runs.append((f"min_profit_pct={value}", {"defaults": replace(base, min_profit_pct=value)})) + for value in (10.0, 20.0, 30.0): + runs.append((f"channel_pct={value}", {"defaults": replace(base, channel_pct=value)})) + for value in (3, 5, 15): + runs.append((f"max_adds={value}", {"defaults": replace(base, max_adds=value)})) + for value in (0.0, 0.0003, 0.001): + runs.append((f"佣金率={value}", { + "defaults": replace(base, commission_rate=value), + "commission_rate": value, + })) + runs.append(("无副出口", {"secondary_exit": False})) + runs.append(("成交=反弹确认价(贴近实盘)", {"fill_mode": "bounce"})) + runs.append(("成交=当日收盘价(悲观)", {"fill_mode": "close"})) + runs.append(("无 T+1 限制(min_hold_days=0)", {"min_hold_days": 0})) + # 副出口可达性:inner_step 必须小于 min_profit_pct(见 REPORT §5.2) + for step in (0.2, 0.4): + runs.append((f"inner_step={step}(副出口可达)", { + "symbol_params": {code: {**SYMBOL_PARAMS[code], "inner_step": step} for code in SYMBOLS}, + })) + # 仓位规模:逐标的每档股数同乘一个系数(保持 10 档容量) + for factor in (0.25, 0.5, 2.0): + runs.append((f"buy_shares×{factor}", { + "symbol_params": { + code: {**SYMBOL_PARAMS[code], + "buy_shares": max(100, int(SYMBOL_PARAMS[code]["buy_shares"] * factor)), + "max_shares": max(1000, int(SYMBOL_PARAMS[code]["max_shares"] * factor))} + for code in SYMBOLS + }, + })) + # ATR 倍数整体缩放(逐标的同乘):只影响格距与跨度,不影响任何触发价位 + for factor in (0.5, 2.0): + runs.append((f"atr_multiplier×{factor}(仅格距)", { + "symbol_params": { + code: {**SYMBOL_PARAMS[code], + "atr_multiplier": SYMBOL_PARAMS[code]["atr_multiplier"] * factor} + for code in SYMBOLS + }, + })) + runs.append(("channel_pct=1(贴近区间下沿)", {"defaults": replace(base, channel_pct=1.0)})) + + rows = [] + for label, kwargs in runs: + defaults = kwargs.pop("defaults", base) + result = simulate(data, defaults=defaults, **kwargs) + stats = analyze(result) + rows.append( + { + "label": label, + "net": stats["net"], + "equity_delta": stats["final_equity"] - result["start_cash"], + "return_pct": stats["return_pct"], + "max_dd_pct": stats["max_dd_pct"], + "bases": stats["buy_count"] - sum( + 1 for f in result["fills"] if f.kind == "add" + ), + "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": stats["fees"], + "avg_util_pct": stats["avg_util_pct"], + "max_deployed": stats["max_deployed"], + } + ) + return rows + + +def main() -> int: + data = {code: fetch_daily(code) for code in SYMBOLS} + result = simulate(data) + stats = analyze(result) + stats["fee_pct_of_buy"] = stats["fees"] / stats["buy_amount"] * 100 if stats["buy_amount"] else 0.0 + trips = round_trips(result["fills"]) + expo = exposure(result) + months = monthly(result) + entries = entry_context(data, SYMBOL_PARAMS, result) + scenarios = scenario_table(data) + + # 未实现盈亏:主出口只兑现盈利,亏损全部留在持仓里。 + open_rows = [] + for code, book in result["books"].items(): + if book.volume <= 0: + continue + close = data[code][-1]["close"] + market = close * book.volume + cost = book.avg_cost * book.volume + open_rows.append( + { + "code": code, + "volume": book.volume, + "avg_cost": book.avg_cost, + "last_close": close, + "cost_amount": cost, + "market_amount": market, + "unrealized": market - cost, + "unrealized_pct": (close / book.avg_cost - 1) * 100, + "max_level": book.max_level, + "anchor": book.anchor, + } + ) + unrealized = sum(row["unrealized"] for row in open_rows) + open_cost = sum(row["cost_amount"] for row in open_rows) + realized = stats["net"] + # 轮次口径:只统计"建网 → 整仓清空"的完整轮次,不含仍在持仓里的仓位。 + all_in_net = realized - open_cost + per_symbol_trips = {} + for code in SYMBOLS: + rows = [t for t in trips if t.code == code] + book = result["books"][code] + held_cost = book.avg_cost * book.volume + per_symbol_trips[code] = { + "rounds": len(rows), + "wins": sum(1 for t in rows if t.profit > 0), + "net_realized_closed": sum(t.profit for t in rows), + "held_cost": held_cost, + "net_incl_open": sum(t.profit for t in rows) - held_cost, + "avg_days": statistics.fmean([t.days for t in rows]) if rows else 0.0, + "max_days": max([t.days for t in rows], default=0), + "worst": min([t.profit for t in rows], default=0.0), + } + + report = { + "period": { + "first": result["curve"][0][0].isoformat(), + "last": result["curve"][-1][0].isoformat(), + "days": len(result["curve"]), + }, + "base": stats, + "exposure": expo, + "pnl_bridge": { + "realized_net": realized, + "unrealized_net": unrealized, + "total": realized + unrealized, + "total_pct": (realized + unrealized) / result["start_cash"] * 100, + "open_positions": open_rows, + }, + "per_symbol_rounds": per_symbol_trips, + "round_trips": { + "count": len(trips), + "wins": sum(1 for t in trips if t.profit > 0), + "losses": sum(1 for t in trips if t.profit <= 0), + "avg_days": statistics.fmean([t.days for t in trips]) if trips else 0.0, + "max_days": max([t.days for t in trips], default=0), + "avg_levels": statistics.fmean([t.levels for t in trips]) if trips else 0.0, + "max_levels": max([t.levels for t in trips], default=0), + "avg_profit": statistics.fmean([t.profit for t in trips]) if trips else 0.0, + "best": max([t.profit for t in trips], default=0.0), + "worst": min([t.profit for t in trips], default=0.0), + "detail": [ + { + "code": t.code, + "opened": t.opened.isoformat(), + "closed": t.closed.isoformat(), + "days": t.days, + "levels": t.levels, + "buy": round(t.buy_amount, 2), + "sell": round(t.sell_amount, 2), + "profit": round(t.profit, 2), + "return_pct": round(t.return_pct, 3), + } + for t in trips + ], + }, + "open_positions": open_positions(result), + "monthly": months, + "entries": entries, + "scenarios": scenarios, + } + (OUT / "results.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/labs/analysis/etf/backtest.py b/labs/analysis/etf/backtest.py new file mode 100644 index 0000000..35947b7 --- /dev/null +++ b/labs/analysis/etf/backtest.py @@ -0,0 +1,476 @@ +"""ETF 网格策略离线回测(只读分析,不修改策略代码)。 + +为什么要单独写:真实策略跑在 30 秒 tick 上(``strategy/etf/boot.py`` 的 RunOnce), +回测只有日线,必须把"入场区内反弹确认"和"限价单是否成交"用日线 OHLC 近似。 +近似口径在 ``REPORT.md`` 里逐条列出并做了敏感性对照。 + +指标计算直接调用策略自己的 ``strategy.etf.signal.calculate``, +保证回测与实盘的 ATR/MA60/通道/格距口径完全一致。 + +用法: + py -3.14 -B analysis/etf/backtest.py # 基准 + 敏感性 + py -3.14 -B analysis/etf/backtest.py --refresh # 重新抓取日线 +""" + +import argparse +from dataclasses import dataclass, replace +from datetime import date, datetime +import json +import math +from pathlib import Path +import statistics +import sys + +# labs/analysis/etf/backtest.py -> labs/analysis/etf -> labs/analysis -> labs -> 仓库根 +ROOT = Path(__file__).resolve().parents[3] +PY_CLIENT = ROOT / "py-client" +CACHE = Path(__file__).resolve().parent / "cache" +OUT = Path(__file__).resolve().parent +sys.path.insert(0, str(PY_CLIENT)) + +from config import EtfDefaults, EtfSymbolConfig # noqa: E402 +from strategy.etf.signal import calculate # noqa: E402 + +DAILY_URL = "http://139.224.247.176:13499/etf/daily" +# 直接从仓库配置读取,避免回测参数与实盘配置漂移。 +ETF_YAML = PY_CLIENT / "etc" / "_etf.yaml" + + +def load_repo_config() -> tuple[EtfDefaults, dict, tuple[str, ...]]: + """读取 py-client/etc/_etf.yaml:全局默认、逐标的参数、白名单顺序。""" + import yaml + + raw = yaml.safe_load(ETF_YAML.read_text(encoding="utf-8")) or {} + defaults = EtfDefaults(**(raw.get("defaults") or {})) + params = { + code: dict(values or {}) + for code, values in (raw.get("symbols") or {}).items() + } + return defaults, params, tuple(params) + + +# 账户级参数(account_config;_etf.yaml 不含这两项,按账户配置写在这里)。 +MIN_CASH_RATIO = 0.10 +COMMISSION_RATE = 0.0003 +MIN_COMMISSION = 5.0 +# 起始资金:新配置三只各铺满 10 档约需 46.5 万,取 50 万作为"铺得开"的参考账户。 +# 注意该策略按固定股数下单,绝对盈亏由 _etf.yaml 的股数决定,与账户规模无关(见 REPORT §10)。 +START_CASH = 500_000.0 +# 日线最后一根是 2026-09-18,用之后的日期做"今天",保证整段日线可用。 +RUN_TODAY = date(2026, 9, 19) +WARMUP = 61 + +# 导入时按仓库配置初始化,供按需调整的脚本直接使用。 +REPO_DEFAULTS, REPO_PARAMS, SYMBOLS = load_repo_config() +SYMBOL_PARAMS = REPO_PARAMS + + +def fetch_daily(code: str, refresh: bool = False) -> list[dict]: + """读取日线;默认用本地缓存,避免反复打接口。""" + CACHE.mkdir(parents=True, exist_ok=True) + path = CACHE / f"{code}.json" + if refresh or not path.exists(): + import urllib.request + + with urllib.request.urlopen(f"{DAILY_URL}?code={code}", timeout=30) as response: + payload = json.load(response) + path.write_text(json.dumps(payload), encoding="utf-8") + rows = json.loads(path.read_text(encoding="utf-8")) + bars: dict[str, dict] = {} + for row in rows: + if row.get("ts_code") != code: + continue + stamp = str(row.get("trade_date")) + if len(stamp) != 8 or not stamp.isdigit(): + continue + day = datetime.strptime(stamp, "%Y%m%d").date() + if day >= RUN_TODAY: + continue + values = {key: float(row[key]) for key in ("open", "high", "low", "close")} + if not all(math.isfinite(v) and v > 0 for v in values.values()): + continue + if not (values["low"] <= values["open"] <= values["high"] + and values["low"] <= values["close"] <= values["high"]): + continue + bars[stamp] = dict(date=stamp, **values) + return [bars[stamp] for stamp in sorted(bars)] + + +def fees(amount: float, rate: float, minimum: float) -> float: + if amount <= 0: + return 0.0 + return max(minimum, amount * rate) + + +@dataclass(slots=True) +class Lot: + volume: int + cost: float # 含买入佣金 + bought: date + + +@dataclass(slots=True) +class Fill: + day: date + code: str + side: str # BUY / SELL + kind: str # base / add / exit / level + volume: int + price: float + fee: float + note: str = "" + + +class SymbolBook: + """单标的网格状态;持仓档位是唯一跨轮存活的状态。""" + + def __init__(self, code: str, symbol: EtfSymbolConfig): + self.code = code + self.symbol = symbol + self.lots: list[Lot] = [] + self.anchor: float | None = None + self.last_buy = 0.0 + self.adds = 0 + self.last_add_day: date | None = None + self.peak_grid: int | None = None + self.rounds = 0 # 主出口清仓次数 + self.max_level = 0 # 曾经达到的档位数 + + @property + def volume(self) -> int: + return sum(lot.volume for lot in self.lots) + + @property + def avg_cost(self) -> float: + total = self.volume + if total <= 0: + return 0.0 + return sum(lot.volume * lot.cost for lot in self.lots) / total + + def sellable(self, day: date) -> int: + """当日可卖份额:is_t0 当日可卖,否则只算隔夜份额(T+1)。""" + if self.symbol.is_t0: + volume = self.volume + else: + volume = sum(lot.volume for lot in self.lots if lot.bought < day) + return volume - volume % 100 + + def add(self, volume: int, price: float, fee: float, day: date) -> None: + cost = (price * volume + fee) / volume if volume else price + for lot in self.lots: + if lot.bought == day: + total = lot.volume + volume + lot.cost = (lot.cost * lot.volume + cost * volume) / total + lot.volume = total + break + else: + self.lots.append(Lot(volume=volume, cost=cost, bought=day)) + self.last_buy = price + self.max_level = max(self.max_level, self.volume // self.symbol.buy_shares) + + def reduce(self, volume: int) -> float: + """先进先出减仓,返回被减仓位的含费成本。""" + removed = 0.0 + left = volume + while left > 0 and self.lots: + lot = self.lots[0] + take = min(lot.volume, left) + removed += take * lot.cost + lot.volume -= take + left -= take + if lot.volume <= 0: + self.lots.pop(0) + return removed + + def held_days(self, day: date) -> int: + return min((day - lot.bought).days for lot in self.lots) if self.lots else 0 + + +def precondition( + bars: list[dict], symbol: EtfSymbolConfig, defaults: EtfDefaults +) -> list[tuple[dict, dict]]: + """给每根日线预先算好指标。 + + 关键:传给 ``calculate`` 的 ``today`` 必须是**该日线自己的日期**。 + 策略里 ``today`` 是"运行当天",而 ``calculate`` 会拒绝距今超过 15 个自然日的 + 日线(防停牌/缓存过期);回测必须逐日回放,否则整段历史都会被当成过期数据丢掉。 + """ + series = [] + for index, bar in enumerate(bars): + if index + 1 < WARMUP: + continue + day = datetime.strptime(bar["date"], "%Y%m%d").date() + window = bars[max(0, index - 119): index + 1] # 与 signal.BAR_COUNT=120 一致 + try: + ind = calculate(window, symbol, defaults, day) + except ValueError: + continue + series.append((bar, ind)) + return series + + +def fill_price( + mode: str, trigger: float, bar: dict, side: str, rebound_pct: float = 0.5 +) -> float: + """把"触发价 + 当日 OHLC"折算成成交价。 + + - ``touch``:限价单在触价当天按触价成交(最乐观,隐含着"盘中挂单必成交")。 + - ``bounce``:跌到触发价后,等价格从当日最低点反弹 ``rebound_pct`` 才成交 + (最贴近实盘 tick 语义,见 ``docs/etf.md`` §2.2 / §4.2)。 + - ``close``:只在收盘时判断,并按收盘价成交(最悲观)。 + """ + if mode == "touch": + return trigger + if mode == "bounce": + rebound = bar["low"] * (1 + rebound_pct / 100) + if side == "BUY": + return min(bar["close"], max(trigger, rebound)) + return max(bar["close"], min(trigger, rebound)) + return bar["close"] + + +def _order_volume(sizer, cash: float, price: float, symbol: EtfSymbolConfig) -> int: + """单档股数:默认用配置的 ``buy_shares``,给了 ``sizer`` 就按资金比例算。""" + if sizer is None: + return symbol.buy_shares + volume = int(sizer(cash, price)) + return max(0, volume - volume % 100) + + +def simulate( + data: dict[str, list[dict]], + *, + defaults: EtfDefaults | None = None, + symbol_params: dict | None = None, + fill_mode: str = "touch", + trigger_fill: bool | None = None, + secondary_exit: bool = True, + min_hold_days: int | None = None, + start_cash: float = START_CASH, + commission_rate: float = COMMISSION_RATE, + min_commission: float = MIN_COMMISSION, + sizer=None, + precomputed: dict | None = None, +) -> dict: + """共享资金的多标的组合回测。 + + ``fill_mode``:``touch`` / ``bounce`` / ``close``,见 ``fill_price``。 + ``trigger_fill``:兼容旧参数,False 等价于 ``close``。 + ``secondary_exit``:是否启用单档峰值回撤副出口。 + ``min_hold_days``:覆盖 min_hold_days(is_t0 标的仍不受限)。 + ``sizer``:``(equity, price) -> 股数``,把固定股数换成按资金比例下单。 + ``precomputed``:复用 ``precondition`` 结果加速扫描(其指标只依赖 symbol/defaults)。 + """ + if trigger_fill is not None: + fill_mode = "touch" if trigger_fill else "close" + defaults = defaults or EtfDefaults() + params = symbol_params or SYMBOL_PARAMS + hold_days = defaults.min_hold_days if min_hold_days is None else min_hold_days + books = { + code: SymbolBook(code, EtfSymbolConfig(**{**params[code]})) + for code in data + } + if precomputed is not None: + series = precomputed + else: + series = {code: precondition(data[code], books[code].symbol, defaults) for code in data} + by_date = {code: {bar["date"]: (bar, ind) for bar, ind in series[code]} for code in data} + calendar = sorted({stamp for code in data for stamp in by_date[code]}) + latest_close = {code: 0.0 for code in data} + cash = start_cash + fills: list[Fill] = [] + curve: list[tuple[date, float, float, float]] = [] # 日期, 权益, 持仓市值, 现金 + reserve = start_cash * MIN_CASH_RATIO + + for stamp in calendar: + day = datetime.strptime(stamp, "%Y%m%d").date() + for code in sorted(data): # 白名单顺序即资金优先级 + book = books[code] + row = by_date[code].get(stamp) + if row is None: + continue + bar, ind = row + close, high, low = bar["close"], bar["high"], bar["low"] + latest_close[code] = close + entry, grid = ind["etf_entry"], ind["etf_grid"] + symbol = book.symbol + + # 1. 主出口:盈亏率 ≥ min_profit_pct,整仓止盈(受 T+1/min_hold_days 约束) + if book.volume > 0: + avg = book.avg_cost + target = avg * (1 + defaults.min_profit_pct / 100) + can_sell = book.sellable(day) + if (not symbol.is_t0) and hold_days > 0 and book.held_days(day) < hold_days: + can_sell = 0 + if high >= target and can_sell > 0: + price = fill_price(fill_mode, target, bar, "SELL", defaults.rebound_pct) + price = min(price, high) + amount = price * can_sell + fee = fees(amount, commission_rate, min_commission) + cash += amount - fee + book.reduce(can_sell) + book.rounds += 1 + book.peak_grid = None + fills.append(Fill(day, code, "SELL", "exit", can_sell, price, fee, + f"目标={target:.3f} 档位={book.max_level}")) + if book.volume == 0: + book.anchor, book.adds, book.last_buy, book.last_add_day = None, 0, 0.0, None + continue + + # 2. 副出口:单档峰值回撤(只卖该档) + if secondary_exit and book.volume > 0: + avg = book.avg_cost + pnl_rate = (close - avg) / avg * 100 + current = math.floor(pnl_rate / symbol.inner_step) + if book.peak_grid is None: + book.peak_grid = current + elif current > book.peak_grid: + book.peak_grid = current + elif current < book.peak_grid and book.peak_grid >= defaults.inner_grids: + volume = min(book.sellable(day), symbol.buy_shares) + if volume > 0: + amount = close * volume + fee = fees(amount, commission_rate, min_commission) + cash += amount - fee + book.reduce(volume) + fills.append(Fill(day, code, "SELL", "level", volume, close, fee, + f"峰值={book.peak_grid}格")) + book.peak_grid = None + + # 3. 补仓:自上一档再跌 add_pct,当日收盘回到触发价之上 + if book.volume > 0 and book.adds < defaults.max_adds and book.last_buy > 0: + trigger = book.last_buy * (1 - defaults.add_pct / 100) + room = symbol.max_shares - book.volume + volume = min(_order_volume(sizer, cash, trigger, symbol), + room - room % 100) + if volume > 0 and low <= trigger and close > trigger and book.last_add_day != day: + price = min(high, fill_price(fill_mode, trigger, bar, "BUY", defaults.rebound_pct)) + amount = price * volume + fee = fees(amount, commission_rate, min_commission) + if amount + fee <= cash - reserve: + cash -= amount + fee + book.add(volume, price, fee, day) + book.adds += 1 + book.last_add_day = day + drop = (book.last_buy / price - 1) * 100 if book.last_buy else 0.0 + fills.append(Fill(day, code, "BUY", "add", volume, price, fee, + f"触发={trigger:.3f} 跌幅={drop:.2f}%")) + + # 4. 建网:跌进入场门槛且当日收在门槛之上(反弹确认的日线近似) + if book.volume == 0 and book.anchor is None and low <= entry and close > entry: + price = min(high, fill_price(fill_mode, entry, bar, "BUY", defaults.rebound_pct)) + volume = _order_volume(sizer, cash, entry, symbol) + amount = price * volume + fee = fees(amount, commission_rate, min_commission) + if amount + fee <= cash - reserve: + cash -= amount + fee + book.add(volume, price, fee, day) + book.anchor = price + book.adds = 0 + book.last_add_day = day + book.peak_grid = None + fills.append(Fill(day, code, "BUY", "base", volume, price, fee, + f"门槛={entry:.3f} MA60={ind['etf_ma60']:.3f}")) + + market_value = sum(books[code].volume * (latest_close[code] or 0.0) for code in data) + curve.append((day, cash + market_value, market_value, cash)) + + return { + "fills": fills, + "curve": curve, + "books": books, + "cash": cash, + "start_cash": start_cash, + "params": { + "fill_mode": fill_mode, + "secondary_exit": secondary_exit, + "min_hold_days": hold_days, + "add_pct": defaults.add_pct, + "min_profit_pct": defaults.min_profit_pct, + "channel_pct": defaults.channel_pct, + "max_adds": defaults.max_adds, + "commission_rate": commission_rate, + "min_commission": min_commission, + }, + } + + +def analyze(result: dict) -> dict: + """把成交与权益曲线折算成指标。""" + fills: list[Fill] = result["fills"] + curve = result["curve"] + buys = [f for f in fills if f.side == "BUY"] + sells = [f for f in fills if f.side == "SELL"] + buy_amount = sum(f.price * f.volume for f in buys) + sell_amount = sum(f.price * f.volume for f in sells) + fee_total = sum(f.fee for f in fills) + net = (sell_amount - buy_amount) - fee_total + equity = [point[1] for point in curve] + peak, max_dd = -math.inf, 0.0 + for value in equity: + peak = max(peak, value) + max_dd = max(max_dd, (peak - value) / peak) + deployed = [point[2] for point in curve] + initial, final = result["start_cash"], equity[-1] + days = len(curve) + per_symbol = {} + for code, book in result["books"].items(): + rows = [f for f in fills if f.code == code] + per_symbol[code] = { + "base": len([f for f in rows if f.kind == "base"]), + "adds": len([f for f in rows if f.kind == "add"]), + "exits": book.rounds, + "levels": len([f for f in rows if f.kind == "level"]), + "held_shares": book.volume, + "max_level": book.max_level, + "net": sum((f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee for f in rows), + } + return { + "days": days, + "net": net, + "gross": sell_amount - buy_amount, + "fees": fee_total, + "fee_share_of_gross": fee_total / (sell_amount - buy_amount) * 100 if sell_amount > buy_amount else 0.0, + "return_pct": (final - initial) / initial * 100, + "max_dd_pct": max_dd * 100, + "buy_count": len(buys), + "sell_count": len(sells), + "buy_amount": buy_amount, + "turnover_x": buy_amount / initial, + "avg_deployed": statistics.fmean(deployed) if deployed else 0.0, + "avg_util_pct": (statistics.fmean(deployed) / initial * 100) if deployed else 0.0, + "max_deployed": max(deployed) if deployed else 0.0, + "final_equity": final, + "cash": result["cash"], + "per_symbol": per_symbol, + "params": result["params"], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--refresh", action="store_true") + parser.add_argument("--ledger", action="store_true", help="打印逐笔成交") + args = parser.parse_args() + + defaults = EtfDefaults() + data = {code: fetch_daily(code, args.refresh) for code in SYMBOLS} + for code, bars in data.items(): + print(f"{code}: {len(bars)} bars {bars[0]['date']}..{bars[-1]['date']}") + + base = simulate(data) + stats = analyze(base) + print(json.dumps(stats, ensure_ascii=False, indent=2, default=str)) + if args.ledger: + for fill in base["fills"]: + amount = fill.price * fill.volume + print( + f"{fill.day} {fill.code} {fill.side:4} {fill.kind:4} " + f"{fill.volume:6} @{fill.price:.3f} amount={amount:10.2f} " + f"fee={fill.fee:5.2f} {fill.note}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/labs/analysis/etf/cache/510300.SH.json b/labs/analysis/etf/cache/510300.SH.json new file mode 100644 index 0000000..a5cc67c --- /dev/null +++ b/labs/analysis/etf/cache/510300.SH.json @@ -0,0 +1 @@ +[{"amount": 2877462.543, "change": 0.05, "close": 4.582, "high": 4.596, "low": 4.548, "open": 4.556, "pct_chg": 1.1, "pre_close": 4.532, "trade_date": "20260918", "ts_code": "510300.SH", "vol": 6297211.23}, {"amount": 2304178.588, "change": -0.018, "close": 4.532, "high": 4.568, "low": 4.524, "open": 4.539, "pct_chg": -0.4, "pre_close": 4.55, "trade_date": "20260917", "ts_code": "510300.SH", "vol": 5075461.02}, {"amount": 4384779.786, "change": 0.027, "close": 4.55, "high": 4.555, "low": 4.485, "open": 4.522, "pct_chg": 0.6, "pre_close": 4.523, "trade_date": "20260916", "ts_code": "510300.SH", "vol": 9687597.02}, {"amount": 3164632.673, "change": -0.029, "close": 4.523, "high": 4.568, "low": 4.515, "open": 4.541, "pct_chg": -0.64, "pre_close": 4.552, "trade_date": "20260915", "ts_code": "510300.SH", "vol": 6973417.99}, {"amount": 2863784.49, "change": -0.027, "close": 4.552, "high": 4.574, "low": 4.538, "open": 4.551, "pct_chg": -0.59, "pre_close": 4.579, "trade_date": "20260914", "ts_code": "510300.SH", "vol": 6291774.42}, {"amount": 4409407.029, "change": -0.038, "close": 4.579, "high": 4.592, "low": 4.532, "open": 4.592, "pct_chg": -0.82, "pre_close": 4.617, "trade_date": "20260911", "ts_code": "510300.SH", "vol": 9666651.82}, {"amount": 2026289.592, "change": -0.02, "close": 4.617, "high": 4.64, "low": 4.599, "open": 4.617, "pct_chg": -0.43, "pre_close": 4.637, "trade_date": "20260910", "ts_code": "510300.SH", "vol": 4388540.72}, {"amount": 2656716.69, "change": 0.013, "close": 4.637, "high": 4.648, "low": 4.613, "open": 4.638, "pct_chg": 0.28, "pre_close": 4.624, "trade_date": "20260909", "ts_code": "510300.SH", "vol": 5732886.54}, {"amount": 2838526.769, "change": -0.01, "close": 4.624, "high": 4.657, "low": 4.61, "open": 4.638, "pct_chg": -0.22, "pre_close": 4.634, "trade_date": "20260908", "ts_code": "510300.SH", "vol": 6125122.2}, {"amount": 4602371.534, "change": 0.018, "close": 4.634, "high": 4.649, "low": 4.612, "open": 4.635, "pct_chg": 0.39, "pre_close": 4.616, "trade_date": "20260907", "ts_code": "510300.SH", "vol": 9930783}, {"amount": 3905672.999, "change": -0.005, "close": 4.616, "high": 4.672, "low": 4.599, "open": 4.637, "pct_chg": -0.11, "pre_close": 4.621, "trade_date": "20260904", "ts_code": "510300.SH", "vol": 8414655.43}, {"amount": 2305980.422, "change": 0.001, "close": 4.621, "high": 4.65, "low": 4.605, "open": 4.631, "pct_chg": 0.02, "pre_close": 4.62, "trade_date": "20260903", "ts_code": "510300.SH", "vol": 4981829.63}, {"amount": 2941487.681, "change": -0.064, "close": 4.62, "high": 4.654, "low": 4.601, "open": 4.65, "pct_chg": -1.37, "pre_close": 4.684, "trade_date": "20260902", "ts_code": "510300.SH", "vol": 6364597.77}, {"amount": 3962899.774, "change": -0.001, "close": 4.684, "high": 4.705, "low": 4.666, "open": 4.683, "pct_chg": -0.02, "pre_close": 4.685, "trade_date": "20260901", "ts_code": "510300.SH", "vol": 8467983.12}, {"amount": 3134489.338, "change": 0.006, "close": 4.685, "high": 4.692, "low": 4.618, "open": 4.64, "pct_chg": 0.13, "pre_close": 4.679, "trade_date": "20260831", "ts_code": "510300.SH", "vol": 6731264.94}, {"amount": 2835120.646, "change": -0.012, "close": 4.679, "high": 4.706, "low": 4.675, "open": 4.684, "pct_chg": -0.26, "pre_close": 4.691, "trade_date": "20260828", "ts_code": "510300.SH", "vol": 6046933.22}, {"amount": 3001316.406, "change": 0.039, "close": 4.691, "high": 4.694, "low": 4.649, "open": 4.659, "pct_chg": 0.84, "pre_close": 4.652, "trade_date": "20260827", "ts_code": "510300.SH", "vol": 6419297.34}, {"amount": 3935828.217, "change": 0.036, "close": 4.652, "high": 4.68, "low": 4.611, "open": 4.615, "pct_chg": 0.78, "pre_close": 4.616, "trade_date": "20260826", "ts_code": "510300.SH", "vol": 8463208.51}, {"amount": 3435989.271, "change": -0.011, "close": 4.616, "high": 4.639, "low": 4.587, "open": 4.601, "pct_chg": -0.24, "pre_close": 4.627, "trade_date": "20260825", "ts_code": "510300.SH", "vol": 7452568}, {"amount": 4272980.684, "change": -0.053, "close": 4.627, "high": 4.692, "low": 4.593, "open": 4.678, "pct_chg": -1.13, "pre_close": 4.68, "trade_date": "20260824", "ts_code": "510300.SH", "vol": 9229738}, {"amount": 3400014.848, "change": 0.027, "close": 4.68, "high": 4.693, "low": 4.641, "open": 4.648, "pct_chg": 0.58, "pre_close": 4.653, "trade_date": "20260821", "ts_code": "510300.SH", "vol": 7273846.3}, {"amount": 3491619.584, "change": -0.001, "close": 4.653, "high": 4.689, "low": 4.632, "open": 4.67, "pct_chg": -0.02, "pre_close": 4.654, "trade_date": "20260820", "ts_code": "510300.SH", "vol": 7488662.92}, {"amount": 7147497.737, "change": -0.133, "close": 4.654, "high": 4.749, "low": 4.627, "open": 4.748, "pct_chg": -2.78, "pre_close": 4.787, "trade_date": "20260819", "ts_code": "510300.SH", "vol": 15327229.15}, {"amount": 3143413.605, "change": -0.014, "close": 4.787, "high": 4.803, "low": 4.751, "open": 4.794, "pct_chg": -0.29, "pre_close": 4.801, "trade_date": "20260818", "ts_code": "510300.SH", "vol": 6576444.05}, {"amount": 3985898.57, "change": 0.075, "close": 4.801, "high": 4.802, "low": 4.725, "open": 4.73, "pct_chg": 1.59, "pre_close": 4.726, "trade_date": "20260817", "ts_code": "510300.SH", "vol": 8356561.14}, {"amount": 3238306.187, "change": -0.003, "close": 4.726, "high": 4.742, "low": 4.701, "open": 4.737, "pct_chg": -0.06, "pre_close": 4.729, "trade_date": "20260814", "ts_code": "510300.SH", "vol": 6859529.84}, {"amount": 4364799.044, "change": -0.019, "close": 4.729, "high": 4.786, "low": 4.725, "open": 4.77, "pct_chg": -0.4, "pre_close": 4.748, "trade_date": "20260813", "ts_code": "510300.SH", "vol": 9155827.41}, {"amount": 4275919.625, "change": 0.02, "close": 4.748, "high": 4.759, "low": 4.72, "open": 4.727, "pct_chg": 0.42, "pre_close": 4.728, "trade_date": "20260812", "ts_code": "510300.SH", "vol": 9010137.43}, {"amount": 2541400.132, "change": -0.031, "close": 4.728, "high": 4.775, "low": 4.722, "open": 4.75, "pct_chg": -0.65, "pre_close": 4.759, "trade_date": "20260811", "ts_code": "510300.SH", "vol": 5353219.17}, {"amount": 2663755.927, "change": 0.008, "close": 4.759, "high": 4.772, "low": 4.72, "open": 4.755, "pct_chg": 0.17, "pre_close": 4.751, "trade_date": "20260810", "ts_code": "510300.SH", "vol": 5610851.63}, {"amount": 4474693.309, "change": 0.042, "close": 4.751, "high": 4.764, "low": 4.706, "open": 4.706, "pct_chg": 0.89, "pre_close": 4.709, "trade_date": "20260807", "ts_code": "510300.SH", "vol": 9435355.85}, {"amount": 4127254.988, "change": -0.005, "close": 4.709, "high": 4.731, "low": 4.668, "open": 4.688, "pct_chg": -0.11, "pre_close": 4.714, "trade_date": "20260806", "ts_code": "510300.SH", "vol": 8783559.96}, {"amount": 6690045.414, "change": 0.063, "close": 4.714, "high": 4.737, "low": 4.613, "open": 4.62, "pct_chg": 1.36, "pre_close": 4.651, "trade_date": "20260805", "ts_code": "510300.SH", "vol": 14245658.36}, {"amount": 5451256.791, "change": 0.052, "close": 4.651, "high": 4.672, "low": 4.615, "open": 4.618, "pct_chg": 1.13, "pre_close": 4.599, "trade_date": "20260804", "ts_code": "510300.SH", "vol": 11730428.66}, {"amount": 4269964.026, "change": -0.054, "close": 4.599, "high": 4.638, "low": 4.591, "open": 4.624, "pct_chg": -1.16, "pre_close": 4.653, "trade_date": "20260803", "ts_code": "510300.SH", "vol": 9263921.07}, {"amount": 7016550.822, "change": 0.048, "close": 4.653, "high": 4.703, "low": 4.644, "open": 4.68, "pct_chg": 1.04, "pre_close": 4.605, "trade_date": "20260731", "ts_code": "510300.SH", "vol": 15016262.67}, {"amount": 6953209.405, "change": -0.052, "close": 4.605, "high": 4.655, "low": 4.536, "open": 4.63, "pct_chg": -1.12, "pre_close": 4.657, "trade_date": "20260730", "ts_code": "510300.SH", "vol": 15120389.03}, {"amount": 6987965.715, "change": 0.03, "close": 4.657, "high": 4.686, "low": 4.574, "open": 4.624, "pct_chg": 0.65, "pre_close": 4.627, "trade_date": "20260729", "ts_code": "510300.SH", "vol": 15092027.12}, {"amount": 7832309.396, "change": -0.126, "close": 4.627, "high": 4.7, "low": 4.609, "open": 4.69, "pct_chg": -2.65, "pre_close": 4.753, "trade_date": "20260728", "ts_code": "510300.SH", "vol": 16853986.67}, {"amount": 4584814.67, "change": 0.052, "close": 4.753, "high": 4.758, "low": 4.674, "open": 4.702, "pct_chg": 1.11, "pre_close": 4.701, "trade_date": "20260727", "ts_code": "510300.SH", "vol": 9715927.3}, {"amount": 4844479.626, "change": -0.086, "close": 4.701, "high": 4.759, "low": 4.699, "open": 4.75, "pct_chg": -1.8, "pre_close": 4.787, "trade_date": "20260724", "ts_code": "510300.SH", "vol": 10247631.52}, {"amount": 2775499.281, "change": 0.022, "close": 4.787, "high": 4.795, "low": 4.755, "open": 4.774, "pct_chg": 0.46, "pre_close": 4.765, "trade_date": "20260723", "ts_code": "510300.SH", "vol": 5811785.59}, {"amount": 6059305.801, "change": -0.022, "close": 4.765, "high": 4.823, "low": 4.75, "open": 4.75, "pct_chg": -0.46, "pre_close": 4.787, "trade_date": "20260722", "ts_code": "510300.SH", "vol": 12650379.41}, {"amount": 12894642.144, "change": 0.137, "close": 4.787, "high": 4.789, "low": 4.617, "open": 4.677, "pct_chg": 2.95, "pre_close": 4.65, "trade_date": "20260721", "ts_code": "510300.SH", "vol": 27388321.12}, {"amount": 18562451.759, "change": 0.061, "close": 4.65, "high": 4.685, "low": 4.577, "open": 4.63, "pct_chg": 1.33, "pre_close": 4.589, "trade_date": "20260720", "ts_code": "510300.SH", "vol": 40104538.02}, {"amount": 14648790.58, "change": -0.164, "close": 4.589, "high": 4.73, "low": 4.546, "open": 4.72, "pct_chg": -3.45, "pre_close": 4.753, "trade_date": "20260717", "ts_code": "510300.SH", "vol": 31647757.15}, {"amount": 7909234.146, "change": -0.085, "close": 4.753, "high": 4.825, "low": 4.724, "open": 4.775, "pct_chg": -1.76, "pre_close": 4.838, "trade_date": "20260716", "ts_code": "510300.SH", "vol": 16578227.91}, {"amount": 3497653.884, "change": 0.001, "close": 4.838, "high": 4.87, "low": 4.819, "open": 4.829, "pct_chg": 0.02, "pre_close": 4.837, "trade_date": "20260715", "ts_code": "510300.SH", "vol": 7220327.04}, {"amount": 8630537.927, "change": 0.093, "close": 4.837, "high": 4.838, "low": 4.696, "open": 4.744, "pct_chg": 1.96, "pre_close": 4.744, "trade_date": "20260714", "ts_code": "510300.SH", "vol": 18084400.38}, {"amount": 7705111.563, "change": -0.085, "close": 4.744, "high": 4.821, "low": 4.719, "open": 4.802, "pct_chg": -1.76, "pre_close": 4.829, "trade_date": "20260713", "ts_code": "510300.SH", "vol": 16190529.68}, {"amount": 4039507.185, "change": -0.087, "close": 4.829, "high": 4.949, "low": 4.827, "open": 4.918, "pct_chg": -1.77, "pre_close": 4.916, "trade_date": "20260710", "ts_code": "510300.SH", "vol": 8242800.21}, {"amount": 4636438.104, "change": 0.118, "close": 4.916, "high": 4.925, "low": 4.777, "open": 4.813, "pct_chg": 2.46, "pre_close": 4.798, "trade_date": "20260709", "ts_code": "510300.SH", "vol": 9558592.48}, {"amount": 2337287.019, "change": -0.028, "close": 4.798, "high": 4.872, "low": 4.793, "open": 4.836, "pct_chg": -0.58, "pre_close": 4.826, "trade_date": "20260708", "ts_code": "510300.SH", "vol": 4846847.69}, {"amount": 3378515.92, "change": -0.051, "close": 4.826, "high": 4.877, "low": 4.796, "open": 4.858, "pct_chg": -1.05, "pre_close": 4.877, "trade_date": "20260707", "ts_code": "510300.SH", "vol": 6986967.78}, {"amount": 3016661.205, "change": 0.001, "close": 4.877, "high": 4.914, "low": 4.831, "open": 4.9, "pct_chg": 0.02, "pre_close": 4.876, "trade_date": "20260706", "ts_code": "510300.SH", "vol": 6179918.15}, {"amount": 9792434.457, "change": 0.026, "close": 4.876, "high": 4.927, "low": 4.828, "open": 4.83, "pct_chg": 0.54, "pre_close": 4.85, "trade_date": "20260703", "ts_code": "510300.SH", "vol": 19983307.82}, {"amount": 6704066.004, "change": -0.148, "close": 4.85, "high": 4.96, "low": 4.835, "open": 4.955, "pct_chg": -2.96, "pre_close": 4.998, "trade_date": "20260702", "ts_code": "510300.SH", "vol": 13691536.1}, {"amount": 4820926.453, "change": -0.021, "close": 4.998, "high": 5.054, "low": 4.957, "open": 5.009, "pct_chg": -0.42, "pre_close": 5.019, "trade_date": "20260701", "ts_code": "510300.SH", "vol": 9626190.27}, {"amount": 10142732.805, "change": 0.06, "close": 5.019, "high": 5.022, "low": 4.942, "open": 4.947, "pct_chg": 1.21, "pre_close": 4.959, "trade_date": "20260630", "ts_code": "510300.SH", "vol": 20297212.63}, {"amount": 7294621.804, "change": 0.052, "close": 4.959, "high": 4.966, "low": 4.866, "open": 4.897, "pct_chg": 1.06, "pre_close": 4.907, "trade_date": "20260629", "ts_code": "510300.SH", "vol": 14848057.33}, {"amount": 4730764.08, "change": -0.141, "close": 4.907, "high": 5.015, "low": 4.88, "open": 5.008, "pct_chg": -2.79, "pre_close": 5.048, "trade_date": "20260626", "ts_code": "510300.SH", "vol": 9582429.43}, {"amount": 13252886.361, "change": 0.081, "close": 5.048, "high": 5.055, "low": 4.966, "open": 4.973, "pct_chg": 1.63, "pre_close": 4.967, "trade_date": "20260625", "ts_code": "510300.SH", "vol": 26406087.36}, {"amount": 12839317.641, "change": 0.025, "close": 4.967, "high": 4.978, "low": 4.916, "open": 4.929, "pct_chg": 0.51, "pre_close": 4.942, "trade_date": "20260624", "ts_code": "510300.SH", "vol": 25906734.09}, {"amount": 13121283.633, "change": -0.149, "close": 4.942, "high": 5.095, "low": 4.915, "open": 5.079, "pct_chg": -2.93, "pre_close": 5.091, "trade_date": "20260623", "ts_code": "510300.SH", "vol": 26173354.39}, {"amount": 9643147.387, "change": 0.107, "close": 5.091, "high": 5.095, "low": 4.954, "open": 4.983, "pct_chg": 2.15, "pre_close": 4.984, "trade_date": "20260622", "ts_code": "510300.SH", "vol": 19131577.65}, {"amount": 6434057.084, "change": 0.026, "close": 4.984, "high": 5.001, "low": 4.941, "open": 4.943, "pct_chg": 0.52, "pre_close": 4.958, "trade_date": "20260618", "ts_code": "510300.SH", "vol": 12933430.39}, {"amount": 6854308.726, "change": 0.048, "close": 4.958, "high": 4.96, "low": 4.894, "open": 4.898, "pct_chg": 0.98, "pre_close": 4.91, "trade_date": "20260617", "ts_code": "510300.SH", "vol": 13911869.8}, {"amount": 4467242.13, "change": -0.009, "close": 4.91, "high": 4.933, "low": 4.893, "open": 4.92, "pct_chg": -0.18, "pre_close": 4.919, "trade_date": "20260616", "ts_code": "510300.SH", "vol": 9085294.32}, {"amount": 7928405.421, "change": 0.101, "close": 4.919, "high": 4.92, "low": 4.839, "open": 4.858, "pct_chg": 2.1, "pre_close": 4.818, "trade_date": "20260615", "ts_code": "510300.SH", "vol": 16233006.71}, {"amount": 3761995.011, "change": 0.067, "close": 4.818, "high": 4.846, "low": 4.789, "open": 4.81, "pct_chg": 1.41, "pre_close": 4.751, "trade_date": "20260612", "ts_code": "510300.SH", "vol": 7808858.96}, {"amount": 1975529.542, "change": -0.033, "close": 4.751, "high": 4.793, "low": 4.718, "open": 4.766, "pct_chg": -0.69, "pre_close": 4.784, "trade_date": "20260611", "ts_code": "510300.SH", "vol": 4160687.68}, {"amount": 1879211.957, "change": -0.042, "close": 4.784, "high": 4.814, "low": 4.751, "open": 4.79, "pct_chg": -0.87, "pre_close": 4.826, "trade_date": "20260610", "ts_code": "510300.SH", "vol": 3931622.95}, {"amount": 2085593.718, "change": 0.087, "close": 4.826, "high": 4.828, "low": 4.741, "open": 4.764, "pct_chg": 1.84, "pre_close": 4.739, "trade_date": "20260609", "ts_code": "510300.SH", "vol": 4361644.82}, {"amount": 4394322.754, "change": -0.104, "close": 4.739, "high": 4.808, "low": 4.705, "open": 4.79, "pct_chg": -2.15, "pre_close": 4.843, "trade_date": "20260608", "ts_code": "510300.SH", "vol": 9232723.64}, {"amount": 3023262.809, "change": -0.083, "close": 4.843, "high": 4.948, "low": 4.825, "open": 4.909, "pct_chg": -1.69, "pre_close": 4.926, "trade_date": "20260605", "ts_code": "510300.SH", "vol": 6182927.34}, {"amount": 3006706.301, "change": -0.039, "close": 4.926, "high": 4.961, "low": 4.91, "open": 4.919, "pct_chg": -0.79, "pre_close": 4.965, "trade_date": "20260604", "ts_code": "510300.SH", "vol": 6097282.84}, {"amount": 3784497.955, "change": 0.029, "close": 4.965, "high": 5.016, "low": 4.928, "open": 4.938, "pct_chg": 0.59, "pre_close": 4.936, "trade_date": "20260603", "ts_code": "510300.SH", "vol": 7604618.91}, {"amount": 3345673.27, "change": 0.068, "close": 4.936, "high": 4.956, "low": 4.856, "open": 4.867, "pct_chg": 1.4, "pre_close": 4.868, "trade_date": "20260602", "ts_code": "510300.SH", "vol": 6809437.44}, {"amount": 2443095.51, "change": -0.055, "close": 4.868, "high": 4.94, "low": 4.861, "open": 4.923, "pct_chg": -1.12, "pre_close": 4.923, "trade_date": "20260601", "ts_code": "510300.SH", "vol": 4990927.16}, {"amount": 3243446.893, "change": -0.009, "close": 4.923, "high": 4.978, "low": 4.9, "open": 4.946, "pct_chg": -0.18, "pre_close": 4.932, "trade_date": "20260529", "ts_code": "510300.SH", "vol": 6566854.45}, {"amount": 2155076.599, "change": 0, "close": 4.932, "high": 4.948, "low": 4.866, "open": 4.917, "pct_chg": 0, "pre_close": 4.932, "trade_date": "20260528", "ts_code": "510300.SH", "vol": 4396498.24}, {"amount": 3174582.673, "change": -0.04, "close": 4.932, "high": 4.993, "low": 4.913, "open": 4.963, "pct_chg": -0.8045, "pre_close": 4.972, "trade_date": "20260527", "ts_code": "510300.SH", "vol": 6405215.4}, {"amount": 4786813.577, "change": 0.035, "close": 4.972, "high": 4.975, "low": 4.91, "open": 4.912, "pct_chg": 0.7089, "pre_close": 4.937, "trade_date": "20260526", "ts_code": "510300.SH", "vol": 9686751.61}, {"amount": 7970991.059, "change": 0.075, "close": 4.937, "high": 4.938, "low": 4.872, "open": 4.88, "pct_chg": 1.5426, "pre_close": 4.862, "trade_date": "20260525", "ts_code": "510300.SH", "vol": 16250559.28}, {"amount": 6421618.436, "change": 0.054, "close": 4.862, "high": 4.868, "low": 4.807, "open": 4.828, "pct_chg": 1.1231, "pre_close": 4.808, "trade_date": "20260522", "ts_code": "510300.SH", "vol": 13250518.45}, {"amount": 5913215.496, "change": -0.063, "close": 4.808, "high": 4.954, "low": 4.801, "open": 4.897, "pct_chg": -1.2934, "pre_close": 4.871, "trade_date": "20260521", "ts_code": "510300.SH", "vol": 12055773.37}, {"amount": 4664422.387, "change": 0.002, "close": 4.871, "high": 4.882, "low": 4.84, "open": 4.845, "pct_chg": 0.0411, "pre_close": 4.869, "trade_date": "20260520", "ts_code": "510300.SH", "vol": 9597200.86}, {"amount": 4670192.858, "change": 0.017, "close": 4.869, "high": 4.874, "low": 4.789, "open": 4.843, "pct_chg": 0.3504, "pre_close": 4.852, "trade_date": "20260519", "ts_code": "510300.SH", "vol": 9674998.89}, {"amount": 2832450.612, "change": -0.026, "close": 4.852, "high": 4.885, "low": 4.819, "open": 4.855, "pct_chg": -0.533, "pre_close": 4.878, "trade_date": "20260518", "ts_code": "510300.SH", "vol": 5835763.7}, {"amount": 5135152.285, "change": -0.063, "close": 4.878, "high": 4.954, "low": 4.852, "open": 4.935, "pct_chg": -1.275, "pre_close": 4.941, "trade_date": "20260515", "ts_code": "510300.SH", "vol": 10479024.45}, {"amount": 5118663.487, "change": -0.076, "close": 4.941, "high": 5.041, "low": 4.931, "open": 5.037, "pct_chg": -1.5148, "pre_close": 5.017, "trade_date": "20260514", "ts_code": "510300.SH", "vol": 10281645.63}, {"amount": 6331249.194, "change": 0.054, "close": 5.017, "high": 5.023, "low": 4.939, "open": 4.94, "pct_chg": 1.0881, "pre_close": 4.963, "trade_date": "20260513", "ts_code": "510300.SH", "vol": 12726150.05}, {"amount": 4976355.85, "change": -0.003, "close": 4.963, "high": 4.987, "low": 4.944, "open": 4.975, "pct_chg": -0.0604, "pre_close": 4.966, "trade_date": "20260512", "ts_code": "510300.SH", "vol": 10021704.59}, {"amount": 10456512.044, "change": 0.08, "close": 4.966, "high": 4.98, "low": 4.9, "open": 4.911, "pct_chg": 1.6373, "pre_close": 4.886, "trade_date": "20260511", "ts_code": "510300.SH", "vol": 21165217.79}, {"amount": 6121164.078, "change": -0.026, "close": 4.886, "high": 4.902, "low": 4.862, "open": 4.89, "pct_chg": -0.5293, "pre_close": 4.912, "trade_date": "20260508", "ts_code": "510300.SH", "vol": 12535649.19}, {"amount": 10262731.527, "change": 0.024, "close": 4.912, "high": 4.914, "low": 4.879, "open": 4.905, "pct_chg": 0.491, "pre_close": 4.888, "trade_date": "20260507", "ts_code": "510300.SH", "vol": 20946226.49}, {"amount": 9131466.569, "change": 0.064, "close": 4.888, "high": 4.914, "low": 4.852, "open": 4.866, "pct_chg": 1.3267, "pre_close": 4.824, "trade_date": "20260506", "ts_code": "510300.SH", "vol": 18677827.57}, {"amount": 7965711.08, "change": 0.003, "close": 4.824, "high": 4.841, "low": 4.808, "open": 4.83, "pct_chg": 0.0622, "pre_close": 4.821, "trade_date": "20260430", "ts_code": "510300.SH", "vol": 16517864.02}, {"amount": 6139314.265, "change": 0.051, "close": 4.821, "high": 4.828, "low": 4.755, "open": 4.755, "pct_chg": 1.0692, "pre_close": 4.77, "trade_date": "20260429", "ts_code": "510300.SH", "vol": 12796797.74}, {"amount": 4125806.962, "change": -0.012, "close": 4.77, "high": 4.79, "low": 4.757, "open": 4.766, "pct_chg": -0.2509, "pre_close": 4.782, "trade_date": "20260428", "ts_code": "510300.SH", "vol": 8644202.29}, {"amount": 4443760.487, "change": 0, "close": 4.782, "high": 4.81, "low": 4.773, "open": 4.786, "pct_chg": 0, "pre_close": 4.782, "trade_date": "20260427", "ts_code": "510300.SH", "vol": 9280360.2}, {"amount": 3432906.825, "change": -0.019, "close": 4.782, "high": 4.798, "low": 4.75, "open": 4.785, "pct_chg": -0.3958, "pre_close": 4.801, "trade_date": "20260424", "ts_code": "510300.SH", "vol": 7193226.06}, {"amount": 4848688.474, "change": -0.01, "close": 4.801, "high": 4.834, "low": 4.772, "open": 4.82, "pct_chg": -0.2079, "pre_close": 4.811, "trade_date": "20260423", "ts_code": "510300.SH", "vol": 10085181.12}, {"amount": 4851593.064, "change": 0.035, "close": 4.811, "high": 4.813, "low": 4.756, "open": 4.76, "pct_chg": 0.7328, "pre_close": 4.776, "trade_date": "20260422", "ts_code": "510300.SH", "vol": 10125925.63}, {"amount": 3026895.923, "change": 0.011, "close": 4.776, "high": 4.786, "low": 4.73, "open": 4.764, "pct_chg": 0.2308, "pre_close": 4.765, "trade_date": "20260421", "ts_code": "510300.SH", "vol": 6361081.37}, {"amount": 3627929.894, "change": 0.026, "close": 4.765, "high": 4.777, "low": 4.728, "open": 4.735, "pct_chg": 0.5486, "pre_close": 4.739, "trade_date": "20260420", "ts_code": "510300.SH", "vol": 7624578.19}, {"amount": 3485941.36, "change": -0.007, "close": 4.739, "high": 4.75, "low": 4.722, "open": 4.737, "pct_chg": -0.1475, "pre_close": 4.746, "trade_date": "20260417", "ts_code": "510300.SH", "vol": 7361500.74}, {"amount": 4036951.794, "change": 0.052, "close": 4.746, "high": 4.747, "low": 4.7, "open": 4.704, "pct_chg": 1.1078, "pre_close": 4.694, "trade_date": "20260416", "ts_code": "510300.SH", "vol": 8532344.23}, {"amount": 3185349.113, "change": -0.018, "close": 4.694, "high": 4.746, "low": 4.684, "open": 4.732, "pct_chg": -0.382, "pre_close": 4.712, "trade_date": "20260415", "ts_code": "510300.SH", "vol": 6759806.05}, {"amount": 2005429.241, "change": 0.06, "close": 4.712, "high": 4.712, "low": 4.663, "open": 4.679, "pct_chg": 1.2898, "pre_close": 4.652, "trade_date": "20260414", "ts_code": "510300.SH", "vol": 4276570.77}, {"amount": 1825187.755, "change": 0.01, "close": 4.652, "high": 4.66, "low": 4.623, "open": 4.629, "pct_chg": 0.2154, "pre_close": 4.642, "trade_date": "20260413", "ts_code": "510300.SH", "vol": 3928925.99}, {"amount": 4203124.967, "change": 0.07, "close": 4.642, "high": 4.664, "low": 4.588, "open": 4.588, "pct_chg": 1.5311, "pre_close": 4.572, "trade_date": "20260410", "ts_code": "510300.SH", "vol": 9063872.71}, {"amount": 3223934.997, "change": -0.03, "close": 4.572, "high": 4.586, "low": 4.56, "open": 4.573, "pct_chg": -0.6519, "pre_close": 4.602, "trade_date": "20260409", "ts_code": "510300.SH", "vol": 7049553.23}, {"amount": 4550818.244, "change": 0.156, "close": 4.602, "high": 4.604, "low": 4.517, "open": 4.519, "pct_chg": 3.5088, "pre_close": 4.446, "trade_date": "20260408", "ts_code": "510300.SH", "vol": 9968845.8}, {"amount": 1908516.206, "change": -0.008, "close": 4.446, "high": 4.47, "low": 4.431, "open": 4.455, "pct_chg": -0.1796, "pre_close": 4.454, "trade_date": "20260407", "ts_code": "510300.SH", "vol": 4285685.61}, {"amount": 1903072.42, "change": -0.035, "close": 4.454, "high": 4.506, "low": 4.446, "open": 4.495, "pct_chg": -0.7797, "pre_close": 4.489, "trade_date": "20260403", "ts_code": "510300.SH", "vol": 4261095.95}, {"amount": 2098973.595, "change": -0.045, "close": 4.489, "high": 4.526, "low": 4.47, "open": 4.521, "pct_chg": -0.9925, "pre_close": 4.534, "trade_date": "20260402", "ts_code": "510300.SH", "vol": 4663511.03}, {"amount": 2267466.693, "change": 0.071, "close": 4.534, "high": 4.542, "low": 4.501, "open": 4.52, "pct_chg": 1.5909, "pre_close": 4.463, "trade_date": "20260401", "ts_code": "510300.SH", "vol": 5012280.66}, {"amount": 2385310.576, "change": -0.037, "close": 4.463, "high": 4.529, "low": 4.462, "open": 4.501, "pct_chg": -0.8222, "pre_close": 4.5, "trade_date": "20260331", "ts_code": "510300.SH", "vol": 5305655.53}, {"amount": 2434696.108, "change": -0.008, "close": 4.5, "high": 4.505, "low": 4.453, "open": 4.462, "pct_chg": -0.1775, "pre_close": 4.508, "trade_date": "20260330", "ts_code": "510300.SH", "vol": 5439942.87}, {"amount": 2529639.239, "change": 0.02, "close": 4.508, "high": 4.53, "low": 4.445, "open": 4.45, "pct_chg": 0.4456, "pre_close": 4.488, "trade_date": "20260327", "ts_code": "510300.SH", "vol": 5626908.26}, {"amount": 2139118.213, "change": -0.056, "close": 4.488, "high": 4.547, "low": 4.477, "open": 4.536, "pct_chg": -1.2324, "pre_close": 4.544, "trade_date": "20260326", "ts_code": "510300.SH", "vol": 4742424.14}, {"amount": 3932266.869, "change": 0.065, "close": 4.544, "high": 4.549, "low": 4.504, "open": 4.51, "pct_chg": 1.4512, "pre_close": 4.479, "trade_date": "20260325", "ts_code": "510300.SH", "vol": 8677126.37}, {"amount": 3784113.263, "change": 0.049, "close": 4.479, "high": 4.48, "low": 4.405, "open": 4.463, "pct_chg": 1.1061, "pre_close": 4.43, "trade_date": "20260324", "ts_code": "510300.SH", "vol": 8504351}, {"amount": 6799292.79, "change": -0.146, "close": 4.43, "high": 4.537, "low": 4.406, "open": 4.528, "pct_chg": -3.1906, "pre_close": 4.576, "trade_date": "20260323", "ts_code": "510300.SH", "vol": 15234981.1}, {"amount": 3509938.019, "change": -0.023, "close": 4.576, "high": 4.635, "low": 4.573, "open": 4.598, "pct_chg": -0.5001, "pre_close": 4.599, "trade_date": "20260320", "ts_code": "510300.SH", "vol": 7612735.68}, {"amount": 3558170.504, "change": -0.063, "close": 4.599, "high": 4.641, "low": 4.581, "open": 4.621, "pct_chg": -1.3514, "pre_close": 4.662, "trade_date": "20260319", "ts_code": "510300.SH", "vol": 7722418.09}, {"amount": 3575798.297, "change": 0.016, "close": 4.662, "high": 4.67, "low": 4.616, "open": 4.652, "pct_chg": 0.3444, "pre_close": 4.646, "trade_date": "20260318", "ts_code": "510300.SH", "vol": 7702627.09}, {"amount": 3388771.831, "change": -0.034, "close": 4.646, "high": 4.731, "low": 4.645, "open": 4.686, "pct_chg": -0.7265, "pre_close": 4.68, "trade_date": "20260317", "ts_code": "510300.SH", "vol": 7233376.6}, {"amount": 1881524.447, "change": 0.003, "close": 4.68, "high": 4.686, "low": 4.632, "open": 4.675, "pct_chg": 0.0641, "pre_close": 4.677, "trade_date": "20260316", "ts_code": "510300.SH", "vol": 4038627.01}, {"amount": 1526480.622, "change": -0.017, "close": 4.677, "high": 4.716, "low": 4.67, "open": 4.672, "pct_chg": -0.3622, "pre_close": 4.694, "trade_date": "20260313", "ts_code": "510300.SH", "vol": 3251129.05}, {"amount": 1610201.018, "change": -0.017, "close": 4.694, "high": 4.711, "low": 4.665, "open": 4.711, "pct_chg": -0.3609, "pre_close": 4.711, "trade_date": "20260312", "ts_code": "510300.SH", "vol": 3433836.26}, {"amount": 2792866.73, "change": 0.028, "close": 4.711, "high": 4.719, "low": 4.682, "open": 4.686, "pct_chg": 0.5979, "pre_close": 4.683, "trade_date": "20260311", "ts_code": "510300.SH", "vol": 5937122.21}, {"amount": 2550822.882, "change": 0.054, "close": 4.683, "high": 4.689, "low": 4.641, "open": 4.642, "pct_chg": 1.1666, "pre_close": 4.629, "trade_date": "20260310", "ts_code": "510300.SH", "vol": 5460125.17}, {"amount": 3854535.508, "change": -0.038, "close": 4.629, "high": 4.638, "low": 4.56, "open": 4.623, "pct_chg": -0.8142, "pre_close": 4.667, "trade_date": "20260309", "ts_code": "510300.SH", "vol": 8380982.6}, {"amount": 2349185.776, "change": 0.013, "close": 4.667, "high": 4.682, "low": 4.624, "open": 4.64, "pct_chg": 0.2793, "pre_close": 4.654, "trade_date": "20260306", "ts_code": "510300.SH", "vol": 5043758.59}, {"amount": 2646464.156, "change": 0.044, "close": 4.654, "high": 4.678, "low": 4.639, "open": 4.647, "pct_chg": 0.9544, "pre_close": 4.61, "trade_date": "20260305", "ts_code": "510300.SH", "vol": 5684209.59}, {"amount": 3830984.4, "change": -0.068, "close": 4.61, "high": 4.65, "low": 4.587, "open": 4.64, "pct_chg": -1.4536, "pre_close": 4.678, "trade_date": "20260304", "ts_code": "510300.SH", "vol": 8292240.2}, {"amount": 4550837.091, "change": -0.061, "close": 4.678, "high": 4.751, "low": 4.665, "open": 4.741, "pct_chg": -1.2872, "pre_close": 4.739, "trade_date": "20260303", "ts_code": "510300.SH", "vol": 9665578.66}, {"amount": 5860854.055, "change": 0.014, "close": 4.739, "high": 4.743, "low": 4.674, "open": 4.694, "pct_chg": 0.2963, "pre_close": 4.725, "trade_date": "20260302", "ts_code": "510300.SH", "vol": 12423675.25}, {"amount": 2859792.911, "change": -0.01, "close": 4.725, "high": 4.732, "low": 4.7, "open": 4.72, "pct_chg": -0.2112, "pre_close": 4.735, "trade_date": "20260227", "ts_code": "510300.SH", "vol": 6062452.61}, {"amount": 3624553.287, "change": -0.015, "close": 4.735, "high": 4.754, "low": 4.712, "open": 4.751, "pct_chg": -0.3158, "pre_close": 4.75, "trade_date": "20260226", "ts_code": "510300.SH", "vol": 7663721.32}, {"amount": 4148107.679, "change": 0.034, "close": 4.75, "high": 4.778, "low": 4.716, "open": 4.717, "pct_chg": 0.7209, "pre_close": 4.716, "trade_date": "20260225", "ts_code": "510300.SH", "vol": 8734031.06}, {"amount": 2946760.847, "change": 0.045, "close": 4.716, "high": 4.736, "low": 4.705, "open": 4.725, "pct_chg": 0.9634, "pre_close": 4.671, "trade_date": "20260224", "ts_code": "510300.SH", "vol": 6242455.87}, {"amount": 6405790.17, "change": -0.056, "close": 4.671, "high": 4.714, "low": 4.667, "open": 4.714, "pct_chg": -1.1847, "pre_close": 4.727, "trade_date": "20260213", "ts_code": "510300.SH", "vol": 13673571.51}, {"amount": 1881073.591, "change": 0.004, "close": 4.727, "high": 4.735, "low": 4.716, "open": 4.729, "pct_chg": 0.0847, "pre_close": 4.723, "trade_date": "20260212", "ts_code": "510300.SH", "vol": 3980124.11}, {"amount": 1757895.647, "change": -0.01, "close": 4.723, "high": 4.732, "low": 4.714, "open": 4.727, "pct_chg": -0.2113, "pre_close": 4.733, "trade_date": "20260211", "ts_code": "510300.SH", "vol": 3720786.9}, {"amount": 3219183.511, "change": 0.006, "close": 4.733, "high": 4.739, "low": 4.72, "open": 4.733, "pct_chg": 0.1269, "pre_close": 4.727, "trade_date": "20260210", "ts_code": "510300.SH", "vol": 6804616.63}, {"amount": 3304026.961, "change": 0.078, "close": 4.727, "high": 4.73, "low": 4.686, "open": 4.695, "pct_chg": 1.6778, "pre_close": 4.649, "trade_date": "20260209", "ts_code": "510300.SH", "vol": 7011195.04}, {"amount": 4106577.054, "change": -0.03, "close": 4.649, "high": 4.691, "low": 4.61, "open": 4.643, "pct_chg": -0.6412, "pre_close": 4.679, "trade_date": "20260206", "ts_code": "510300.SH", "vol": 8816737.4}, {"amount": 4364498.239, "change": -0.028, "close": 4.679, "high": 4.695, "low": 4.645, "open": 4.68, "pct_chg": -0.5949, "pre_close": 4.707, "trade_date": "20260205", "ts_code": "510300.SH", "vol": 9339828.76}, {"amount": 4914865.553, "change": 0.043, "close": 4.707, "high": 4.709, "low": 4.643, "open": 4.652, "pct_chg": 0.922, "pre_close": 4.664, "trade_date": "20260204", "ts_code": "510300.SH", "vol": 10525059.16}, {"amount": 4881749.326, "change": 0.064, "close": 4.664, "high": 4.668, "low": 4.585, "open": 4.64, "pct_chg": 1.3913, "pre_close": 4.6, "trade_date": "20260203", "ts_code": "510300.SH", "vol": 10529446.12}, {"amount": 12299263.716, "change": -0.111, "close": 4.6, "high": 4.719, "low": 4.591, "open": 4.68, "pct_chg": -2.3562, "pre_close": 4.711, "trade_date": "20260202", "ts_code": "510300.SH", "vol": 26511343.91}, {"amount": 7860479.504, "change": -0.057, "close": 4.711, "high": 4.758, "low": 4.642, "open": 4.736, "pct_chg": -1.1955, "pre_close": 4.768, "trade_date": "20260130", "ts_code": "510300.SH", "vol": 16702748.01}, {"amount": 16773879.626, "change": 0.043, "close": 4.768, "high": 4.779, "low": 4.704, "open": 4.724, "pct_chg": 0.9101, "pre_close": 4.725, "trade_date": "20260129", "ts_code": "510300.SH", "vol": 35456647.39}, {"amount": 40100175.381, "change": 0.015, "close": 4.725, "high": 4.743, "low": 4.704, "open": 4.725, "pct_chg": 0.3185, "pre_close": 4.71, "trade_date": "20260128", "ts_code": "510300.SH", "vol": 84977822.53}, {"amount": 20450057.577, "change": -0.002, "close": 4.71, "high": 4.742, "low": 4.679, "open": 4.712, "pct_chg": -0.0424, "pre_close": 4.712, "trade_date": "20260127", "ts_code": "510300.SH", "vol": 43391350.15}, {"amount": 28002364.939, "change": 0.008, "close": 4.712, "high": 4.76, "low": 4.694, "open": 4.712, "pct_chg": 0.1701, "pre_close": 4.704, "trade_date": "20260126", "ts_code": "510300.SH", "vol": 59398528.82}, {"amount": 31834663.643, "change": -0.023, "close": 4.704, "high": 4.742, "low": 4.687, "open": 4.732, "pct_chg": -0.4866, "pre_close": 4.727, "trade_date": "20260123", "ts_code": "510300.SH", "vol": 67693781.09}, {"amount": 20376067.887, "change": -0.003, "close": 4.727, "high": 4.76, "low": 4.703, "open": 4.741, "pct_chg": -0.0634, "pre_close": 4.73, "trade_date": "20260122", "ts_code": "510300.SH", "vol": 43163442.08}, {"amount": 23207895.509, "change": 0.006, "close": 4.73, "high": 4.764, "low": 4.709, "open": 4.709, "pct_chg": 0.127, "pre_close": 4.724, "trade_date": "20260121", "ts_code": "510300.SH", "vol": 49039739.86}, {"amount": 13613769.428, "change": -0.014, "close": 4.724, "high": 4.751, "low": 4.688, "open": 4.735, "pct_chg": -0.2955, "pre_close": 4.738, "trade_date": "20260120", "ts_code": "510300.SH", "vol": 28867656.33}, {"amount": 13792576.409, "change": 0.002, "close": 4.738, "high": 4.769, "low": 4.718, "open": 4.737, "pct_chg": 0.0422, "pre_close": 4.736, "trade_date": "20260119", "ts_code": "510300.SH", "vol": 29134398.64}, {"amount": 25922582.412, "change": -0.016, "close": 4.859, "high": 4.919, "low": 4.838, "open": 4.905, "pct_chg": -0.3282, "pre_close": 4.875, "trade_date": "20260116", "ts_code": "510300.SH", "vol": 53201664.68}, {"amount": 25390672.681, "change": 0.009, "close": 4.875, "high": 4.894, "low": 4.843, "open": 4.849, "pct_chg": 0.185, "pre_close": 4.866, "trade_date": "20260115", "ts_code": "510300.SH", "vol": 52171488.6}, {"amount": 10465332.592, "change": -0.03, "close": 4.866, "high": 4.949, "low": 4.841, "open": 4.896, "pct_chg": -0.6127, "pre_close": 4.896, "trade_date": "20260114", "ts_code": "510300.SH", "vol": 21367222.88}, {"amount": 6249488.306, "change": -0.017, "close": 4.896, "high": 4.965, "low": 4.876, "open": 4.922, "pct_chg": -0.346, "pre_close": 4.913, "trade_date": "20260113", "ts_code": "510300.SH", "vol": 12717831.95}, {"amount": 6529675.045, "change": 0.028, "close": 4.913, "high": 4.929, "low": 4.861, "open": 4.893, "pct_chg": 0.5732, "pre_close": 4.885, "trade_date": "20260112", "ts_code": "510300.SH", "vol": 13330753.33}, {"amount": 5535581.862, "change": 0.022, "close": 4.885, "high": 4.903, "low": 4.844, "open": 4.857, "pct_chg": 0.4524, "pre_close": 4.863, "trade_date": "20260109", "ts_code": "510300.SH", "vol": 11348552.58}, {"amount": 3324674.355, "change": -0.038, "close": 4.863, "high": 4.894, "low": 4.843, "open": 4.888, "pct_chg": -0.7754, "pre_close": 4.901, "trade_date": "20260108", "ts_code": "510300.SH", "vol": 6827197.78}, {"amount": 4578481.053, "change": -0.018, "close": 4.901, "high": 4.927, "low": 4.876, "open": 4.92, "pct_chg": -0.3659, "pre_close": 4.919, "trade_date": "20260107", "ts_code": "510300.SH", "vol": 9334025.54}, {"amount": 5141603.633, "change": 0.075, "close": 4.919, "high": 4.92, "low": 4.848, "open": 4.85, "pct_chg": 1.5483, "pre_close": 4.844, "trade_date": "20260106", "ts_code": "510300.SH", "vol": 10523015.45}, {"amount": 4902061.805, "change": 0.091, "close": 4.844, "high": 4.848, "low": 4.78, "open": 4.783, "pct_chg": 1.9146, "pre_close": 4.753, "trade_date": "20260105", "ts_code": "510300.SH", "vol": 10171324.85}, {"amount": 3175879.377, "change": -0.02, "close": 4.753, "high": 4.789, "low": 4.742, "open": 4.777, "pct_chg": -0.419, "pre_close": 4.773, "trade_date": "20251231", "ts_code": "510300.SH", "vol": 6668292.36}, {"amount": 2275508.778, "change": 0.01, "close": 4.773, "high": 4.79, "low": 4.742, "open": 4.746, "pct_chg": 0.21, "pre_close": 4.763, "trade_date": "20251230", "ts_code": "510300.SH", "vol": 4772242.25}, {"amount": 4069832.489, "change": -0.021, "close": 4.763, "high": 4.794, "low": 4.749, "open": 4.78, "pct_chg": -0.439, "pre_close": 4.784, "trade_date": "20251229", "ts_code": "510300.SH", "vol": 8529050.33}, {"amount": 3902900.785, "change": 0.018, "close": 4.784, "high": 4.801, "low": 4.755, "open": 4.766, "pct_chg": 0.3777, "pre_close": 4.766, "trade_date": "20251226", "ts_code": "510300.SH", "vol": 8160800.91}, {"amount": 2033628.744, "change": 0.009, "close": 4.766, "high": 4.777, "low": 4.744, "open": 4.75, "pct_chg": 0.1892, "pre_close": 4.757, "trade_date": "20251225", "ts_code": "510300.SH", "vol": 4272443.04}, {"amount": 2531260.594, "change": 0.017, "close": 4.757, "high": 4.765, "low": 4.726, "open": 4.74, "pct_chg": 0.3586, "pre_close": 4.74, "trade_date": "20251224", "ts_code": "510300.SH", "vol": 5334293.78}, {"amount": 3833240.598, "change": 0.01, "close": 4.74, "high": 4.763, "low": 4.726, "open": 4.735, "pct_chg": 0.2114, "pre_close": 4.73, "trade_date": "20251223", "ts_code": "510300.SH", "vol": 8083405.36}, {"amount": 3956863.485, "change": 0.042, "close": 4.73, "high": 4.735, "low": 4.702, "open": 4.702, "pct_chg": 0.8959, "pre_close": 4.688, "trade_date": "20251222", "ts_code": "510300.SH", "vol": 8371219.64}, {"amount": 3901916.782, "change": 0.02, "close": 4.688, "high": 4.714, "low": 4.666, "open": 4.68, "pct_chg": 0.4284, "pre_close": 4.668, "trade_date": "20251219", "ts_code": "510300.SH", "vol": 8317889.37}, {"amount": 3169750.209, "change": -0.031, "close": 4.668, "high": 4.692, "low": 4.661, "open": 4.683, "pct_chg": -0.6597, "pre_close": 4.699, "trade_date": "20251218", "ts_code": "510300.SH", "vol": 6780188.55}, {"amount": 5079203.142, "change": 0.079, "close": 4.699, "high": 4.715, "low": 4.616, "open": 4.619, "pct_chg": 1.71, "pre_close": 4.62, "trade_date": "20251217", "ts_code": "510300.SH", "vol": 10856133.18}, {"amount": 3559031.617, "change": -0.047, "close": 4.62, "high": 4.667, "low": 4.601, "open": 4.66, "pct_chg": -1.0071, "pre_close": 4.667, "trade_date": "20251216", "ts_code": "510300.SH", "vol": 7693550.01}, {"amount": 3241015.143, "change": -0.027, "close": 4.667, "high": 4.712, "low": 4.55, "open": 4.674, "pct_chg": -0.5752, "pre_close": 4.694, "trade_date": "20251215", "ts_code": "510300.SH", "vol": 6917855.14}, {"amount": 4057237.306, "change": 0.026, "close": 4.694, "high": 4.702, "low": 4.648, "open": 4.678, "pct_chg": 0.557, "pre_close": 4.668, "trade_date": "20251212", "ts_code": "510300.SH", "vol": 8671539.2}, {"amount": 2910601.742, "change": -0.034, "close": 4.668, "high": 4.722, "low": 4.662, "open": 4.708, "pct_chg": -0.7231, "pre_close": 4.702, "trade_date": "20251211", "ts_code": "510300.SH", "vol": 6199442.53}, {"amount": 2903153.594, "change": -0.006, "close": 4.702, "high": 4.709, "low": 4.658, "open": 4.7, "pct_chg": -0.1274, "pre_close": 4.708, "trade_date": "20251210", "ts_code": "510300.SH", "vol": 6207764.36}, {"amount": 3302885.875, "change": -0.024, "close": 4.708, "high": 4.739, "low": 4.694, "open": 4.727, "pct_chg": -0.5072, "pre_close": 4.732, "trade_date": "20251209", "ts_code": "510300.SH", "vol": 7001054.99}, {"amount": 3908784.386, "change": 0.034, "close": 4.732, "high": 4.754, "low": 4.709, "open": 4.711, "pct_chg": 0.7237, "pre_close": 4.698, "trade_date": "20251208", "ts_code": "510300.SH", "vol": 8263437.51}, {"amount": 3217071.412, "change": 0.041, "close": 4.698, "high": 4.71, "low": 4.643, "open": 4.656, "pct_chg": 0.8804, "pre_close": 4.657, "trade_date": "20251205", "ts_code": "510300.SH", "vol": 6872030.54}, {"amount": 1871831.892, "change": 0.012, "close": 4.657, "high": 4.671, "low": 4.628, "open": 4.648, "pct_chg": 0.2583, "pre_close": 4.645, "trade_date": "20251204", "ts_code": "510300.SH", "vol": 4026188.36}, {"amount": 1840202.209, "change": -0.021, "close": 4.645, "high": 4.684, "low": 4.635, "open": 4.665, "pct_chg": -0.4501, "pre_close": 4.666, "trade_date": "20251203", "ts_code": "510300.SH", "vol": 3950347.8}, {"amount": 1790231.961, "change": -0.021, "close": 4.666, "high": 4.686, "low": 4.651, "open": 4.686, "pct_chg": -0.448, "pre_close": 4.687, "trade_date": "20251202", "ts_code": "510300.SH", "vol": 3834449}, {"amount": 2698559.001, "change": 0.052, "close": 4.687, "high": 4.688, "low": 4.639, "open": 4.646, "pct_chg": 1.1219, "pre_close": 4.635, "trade_date": "20251201", "ts_code": "510300.SH", "vol": 5783013.7}, {"amount": 2403092.677, "change": 0.013, "close": 4.635, "high": 4.642, "low": 4.603, "open": 4.621, "pct_chg": 0.2813, "pre_close": 4.622, "trade_date": "20251128", "ts_code": "510300.SH", "vol": 5192340.54}, {"amount": 2226374.836, "change": -0.004, "close": 4.622, "high": 4.674, "low": 4.618, "open": 4.629, "pct_chg": -0.0865, "pre_close": 4.626, "trade_date": "20251127", "ts_code": "510300.SH", "vol": 4793694.11}, {"amount": 4228349.386, "change": 0.029, "close": 4.626, "high": 4.646, "low": 4.594, "open": 4.6, "pct_chg": 0.6308, "pre_close": 4.597, "trade_date": "20251126", "ts_code": "510300.SH", "vol": 9140793.06}, {"amount": 4120829.562, "change": 0.04, "close": 4.597, "high": 4.623, "low": 4.579, "open": 4.58, "pct_chg": 0.8778, "pre_close": 4.557, "trade_date": "20251125", "ts_code": "510300.SH", "vol": 8959405.77}, {"amount": 5269730.484, "change": -0.007, "close": 4.557, "high": 4.585, "low": 4.533, "open": 4.579, "pct_chg": -0.1534, "pre_close": 4.564, "trade_date": "20251124", "ts_code": "510300.SH", "vol": 11557477.22}, {"amount": 6884359.92, "change": -0.112, "close": 4.564, "high": 4.654, "low": 4.562, "open": 4.645, "pct_chg": -2.3952, "pre_close": 4.676, "trade_date": "20251121", "ts_code": "510300.SH", "vol": 14964004.1}, {"amount": 2644705.315, "change": -0.021, "close": 4.676, "high": 4.729, "low": 4.673, "open": 4.724, "pct_chg": -0.4471, "pre_close": 4.697, "trade_date": "20251120", "ts_code": "510300.SH", "vol": 5619395.01}, {"amount": 4026545.623, "change": 0.014, "close": 4.697, "high": 4.717, "low": 4.678, "open": 4.678, "pct_chg": 0.299, "pre_close": 4.683, "trade_date": "20251119", "ts_code": "510300.SH", "vol": 8569614.94}, {"amount": 4018390.178, "change": -0.027, "close": 4.683, "high": 4.717, "low": 4.664, "open": 4.7, "pct_chg": -0.5732, "pre_close": 4.71, "trade_date": "20251118", "ts_code": "510300.SH", "vol": 8568239.33}, {"amount": 3545084.093, "change": -0.031, "close": 4.71, "high": 4.74, "low": 4.691, "open": 4.73, "pct_chg": -0.6539, "pre_close": 4.741, "trade_date": "20251117", "ts_code": "510300.SH", "vol": 7524669.29}, {"amount": 2832605.947, "change": -0.071, "close": 4.741, "high": 4.796, "low": 4.739, "open": 4.785, "pct_chg": -1.4755, "pre_close": 4.812, "trade_date": "20251114", "ts_code": "510300.SH", "vol": 5936317.15}, {"amount": 3096198.221, "change": 0.047, "close": 4.812, "high": 4.815, "low": 4.752, "open": 4.757, "pct_chg": 0.9864, "pre_close": 4.765, "trade_date": "20251113", "ts_code": "510300.SH", "vol": 6459560.59}, {"amount": 2491775.456, "change": 0.002, "close": 4.765, "high": 4.783, "low": 4.731, "open": 4.754, "pct_chg": 0.042, "pre_close": 4.763, "trade_date": "20251112", "ts_code": "510300.SH", "vol": 5236906.75}, {"amount": 2702663.134, "change": -0.044, "close": 4.763, "high": 4.824, "low": 4.757, "open": 4.814, "pct_chg": -0.9153, "pre_close": 4.807, "trade_date": "20251111", "ts_code": "510300.SH", "vol": 5651935.63}, {"amount": 2146884.969, "change": 0.012, "close": 4.807, "high": 4.811, "low": 4.763, "open": 4.797, "pct_chg": 0.2503, "pre_close": 4.795, "trade_date": "20251110", "ts_code": "510300.SH", "vol": 4483488.44}, {"amount": 2584816.479, "change": -0.01, "close": 4.795, "high": 4.812, "low": 4.774, "open": 4.79, "pct_chg": -0.2081, "pre_close": 4.805, "trade_date": "20251107", "ts_code": "510300.SH", "vol": 5392555.53}, {"amount": 3506780.405, "change": 0.07, "close": 4.805, "high": 4.813, "low": 4.742, "open": 4.742, "pct_chg": 1.4784, "pre_close": 4.735, "trade_date": "20251106", "ts_code": "510300.SH", "vol": 7326649.05}, {"amount": 3599158.874, "change": 0.006, "close": 4.735, "high": 4.751, "low": 4.668, "open": 4.692, "pct_chg": 0.1269, "pre_close": 4.729, "trade_date": "20251105", "ts_code": "510300.SH", "vol": 7633532.85}, {"amount": 5708778.783, "change": -0.034, "close": 4.729, "high": 4.773, "low": 4.706, "open": 4.76, "pct_chg": -0.7138, "pre_close": 4.763, "trade_date": "20251104", "ts_code": "510300.SH", "vol": 12015243.29}, {"amount": 3674194.55, "change": 0.007, "close": 4.763, "high": 4.767, "low": 4.705, "open": 4.752, "pct_chg": 0.1472, "pre_close": 4.756, "trade_date": "20251103", "ts_code": "510300.SH", "vol": 7759881.37}, {"amount": 6851751.22, "change": -0.067, "close": 4.756, "high": 4.826, "low": 4.755, "open": 4.819, "pct_chg": -1.3892, "pre_close": 4.823, "trade_date": "20251031", "ts_code": "510300.SH", "vol": 14325686.2}, {"amount": 3955485.339, "change": -0.039, "close": 4.823, "high": 4.874, "low": 4.816, "open": 4.858, "pct_chg": -0.8021, "pre_close": 4.862, "trade_date": "20251030", "ts_code": "510300.SH", "vol": 8164618.54}, {"amount": 3284668.925, "change": 0.06, "close": 4.862, "high": 4.864, "low": 4.81, "open": 4.81, "pct_chg": 1.2495, "pre_close": 4.802, "trade_date": "20251029", "ts_code": "510300.SH", "vol": 6792783.56}, {"amount": 3497088.82, "change": -0.024, "close": 4.802, "high": 4.85, "low": 4.792, "open": 4.815, "pct_chg": -0.4973, "pre_close": 4.826, "trade_date": "20251028", "ts_code": "510300.SH", "vol": 7259491.38}, {"amount": 3652922.851, "change": 0.056, "close": 4.826, "high": 4.831, "low": 4.796, "open": 4.808, "pct_chg": 1.174, "pre_close": 4.77, "trade_date": "20251027", "ts_code": "510300.SH", "vol": 7585249.12}, {"amount": 4110591.097, "change": 0.057, "close": 4.77, "high": 4.772, "low": 4.727, "open": 4.728, "pct_chg": 1.2094, "pre_close": 4.713, "trade_date": "20251024", "ts_code": "510300.SH", "vol": 8653036.18}, {"amount": 3267824.361, "change": 0.018, "close": 4.713, "high": 4.719, "low": 4.642, "open": 4.687, "pct_chg": 0.3834, "pre_close": 4.695, "trade_date": "20251023", "ts_code": "510300.SH", "vol": 6993428.41}, {"amount": 2601506.365, "change": -0.015, "close": 4.695, "high": 4.708, "low": 4.668, "open": 4.69, "pct_chg": -0.3185, "pre_close": 4.71, "trade_date": "20251022", "ts_code": "510300.SH", "vol": 5544841.98}, {"amount": 4511371.971, "change": 0.07, "close": 4.71, "high": 4.723, "low": 4.652, "open": 4.656, "pct_chg": 1.5086, "pre_close": 4.64, "trade_date": "20251021", "ts_code": "510300.SH", "vol": 9598685.19}, {"amount": 3554256.321, "change": 0.016, "close": 4.64, "high": 4.678, "low": 4.624, "open": 4.651, "pct_chg": 0.346, "pre_close": 4.624, "trade_date": "20251020", "ts_code": "510300.SH", "vol": 7644178.05}, {"amount": 3909864.092, "change": -0.097, "close": 4.624, "high": 4.724, "low": 4.612, "open": 4.715, "pct_chg": -2.0546, "pre_close": 4.721, "trade_date": "20251017", "ts_code": "510300.SH", "vol": 8400582.05}, {"amount": 3835512.324, "change": 0.015, "close": 4.721, "high": 4.747, "low": 4.689, "open": 4.689, "pct_chg": 0.3187, "pre_close": 4.706, "trade_date": "20251016", "ts_code": "510300.SH", "vol": 8124633.37}, {"amount": 4649845.988, "change": 0.061, "close": 4.706, "high": 4.715, "low": 4.627, "open": 4.648, "pct_chg": 1.3132, "pre_close": 4.645, "trade_date": "20251015", "ts_code": "510300.SH", "vol": 9974012.76}, {"amount": 5445976.98, "change": -0.047, "close": 4.645, "high": 4.74, "low": 4.62, "open": 4.725, "pct_chg": -1.0017, "pre_close": 4.692, "trade_date": "20251014", "ts_code": "510300.SH", "vol": 11619095.16}, {"amount": 4499844.601, "change": -0.027, "close": 4.692, "high": 4.704, "low": 4.596, "open": 4.598, "pct_chg": -0.5722, "pre_close": 4.719, "trade_date": "20251013", "ts_code": "510300.SH", "vol": 9658361.87}, {"amount": 5581932.563, "change": -0.097, "close": 4.719, "high": 4.794, "low": 4.704, "open": 4.787, "pct_chg": -2.0141, "pre_close": 4.816, "trade_date": "20251010", "ts_code": "510300.SH", "vol": 11746425.59}, {"amount": 4308933.449, "change": 0.075, "close": 4.816, "high": 4.836, "low": 4.75, "open": 4.754, "pct_chg": 1.5819, "pre_close": 4.741, "trade_date": "20251009", "ts_code": "510300.SH", "vol": 8970906.08}, {"amount": 3508639.995, "change": 0.013, "close": 4.741, "high": 4.75, "low": 4.72, "open": 4.734, "pct_chg": 0.275, "pre_close": 4.728, "trade_date": "20250930", "ts_code": "510300.SH", "vol": 7403809.27}, {"amount": 5620190.347, "change": 0.077, "close": 4.728, "high": 4.755, "low": 4.64, "open": 4.648, "pct_chg": 1.6556, "pre_close": 4.651, "trade_date": "20250929", "ts_code": "510300.SH", "vol": 11954365.2}, {"amount": 3344592.886, "change": -0.044, "close": 4.651, "high": 4.699, "low": 4.65, "open": 4.677, "pct_chg": -0.9372, "pre_close": 4.695, "trade_date": "20250926", "ts_code": "510300.SH", "vol": 7153059.37}, {"amount": 3614405.601, "change": 0.032, "close": 4.695, "high": 4.718, "low": 4.657, "open": 4.663, "pct_chg": 0.6863, "pre_close": 4.663, "trade_date": "20250925", "ts_code": "510300.SH", "vol": 7713362.31}, {"amount": 4656117.026, "change": 0.041, "close": 4.663, "high": 4.664, "low": 4.596, "open": 4.597, "pct_chg": 0.8871, "pre_close": 4.622, "trade_date": "20250924", "ts_code": "510300.SH", "vol": 10044858.67}, {"amount": 4859849.641, "change": 0.003, "close": 4.622, "high": 4.656, "low": 4.558, "open": 4.627, "pct_chg": 0.0649, "pre_close": 4.619, "trade_date": "20250923", "ts_code": "510300.SH", "vol": 10550001.35}, {"amount": 2896946.428, "change": 0.015, "close": 4.619, "high": 4.621, "low": 4.582, "open": 4.604, "pct_chg": 0.3258, "pre_close": 4.604, "trade_date": "20250922", "ts_code": "510300.SH", "vol": 6292766.23}, {"amount": 3369611.818, "change": 0.01, "close": 4.604, "high": 4.633, "low": 4.587, "open": 4.59, "pct_chg": 0.2177, "pre_close": 4.594, "trade_date": "20250919", "ts_code": "510300.SH", "vol": 7316138.11}] \ No newline at end of file diff --git a/labs/analysis/etf/cache/518880.SH.json b/labs/analysis/etf/cache/518880.SH.json new file mode 100644 index 0000000..c457a21 --- /dev/null +++ b/labs/analysis/etf/cache/518880.SH.json @@ -0,0 +1 @@ +[{"amount": 5385512.314, "change": 0.153, "close": 9.009, "high": 9.026, "low": 8.896, "open": 8.95, "pct_chg": 1.73, "pre_close": 8.856, "trade_date": "20260918", "ts_code": "518880.SH", "vol": 6013088.14}, {"amount": 5062606.725, "change": -0.049, "close": 8.856, "high": 8.888, "low": 8.787, "open": 8.86, "pct_chg": -0.55, "pre_close": 8.905, "trade_date": "20260917", "ts_code": "518880.SH", "vol": 5727481.59}, {"amount": 4945405.175, "change": 0.076, "close": 8.905, "high": 8.924, "low": 8.804, "open": 8.828, "pct_chg": 0.86, "pre_close": 8.829, "trade_date": "20260916", "ts_code": "518880.SH", "vol": 5570271.13}, {"amount": 3637561.796, "change": -0.046, "close": 8.829, "high": 8.879, "low": 8.814, "open": 8.849, "pct_chg": -0.52, "pre_close": 8.875, "trade_date": "20260915", "ts_code": "518880.SH", "vol": 4113994.21}, {"amount": 4054072.492, "change": -0.068, "close": 8.875, "high": 8.95, "low": 8.875, "open": 8.907, "pct_chg": -0.76, "pre_close": 8.943, "trade_date": "20260914", "ts_code": "518880.SH", "vol": 4548250.43}, {"amount": 5504843.797, "change": -0.14, "close": 8.943, "high": 8.963, "low": 8.852, "open": 8.895, "pct_chg": -1.54, "pre_close": 9.083, "trade_date": "20260911", "ts_code": "518880.SH", "vol": 6178323.29}, {"amount": 4001856.207, "change": 0.042, "close": 9.083, "high": 9.105, "low": 9.051, "open": 9.06, "pct_chg": 0.47, "pre_close": 9.041, "trade_date": "20260910", "ts_code": "518880.SH", "vol": 4410136.07}, {"amount": 4042974.966, "change": -0.006, "close": 9.041, "high": 9.052, "low": 8.97, "open": 8.97, "pct_chg": -0.07, "pre_close": 9.047, "trade_date": "20260909", "ts_code": "518880.SH", "vol": 4486996.52}, {"amount": 3766204.867, "change": 0.023, "close": 9.047, "high": 9.123, "low": 9.039, "open": 9.102, "pct_chg": 0.26, "pre_close": 9.024, "trade_date": "20260908", "ts_code": "518880.SH", "vol": 4143978.29}, {"amount": 4537564.249, "change": -0.14, "close": 9.024, "high": 9.076, "low": 9.009, "open": 9.021, "pct_chg": -1.53, "pre_close": 9.164, "trade_date": "20260907", "ts_code": "518880.SH", "vol": 5015777.86}, {"amount": 4243476.111, "change": 0.059, "close": 9.164, "high": 9.218, "low": 9.16, "open": 9.21, "pct_chg": 0.65, "pre_close": 9.105, "trade_date": "20260904", "ts_code": "518880.SH", "vol": 4615569.5}, {"amount": 5923257.966, "change": 0.203, "close": 9.105, "high": 9.135, "low": 9.045, "open": 9.045, "pct_chg": 2.28, "pre_close": 8.902, "trade_date": "20260903", "ts_code": "518880.SH", "vol": 6510649.68}, {"amount": 7544980.786, "change": -0.216, "close": 8.902, "high": 8.922, "low": 8.833, "open": 8.9, "pct_chg": -2.37, "pre_close": 9.118, "trade_date": "20260902", "ts_code": "518880.SH", "vol": 8497014.87}, {"amount": 4118875.882, "change": -0.017, "close": 9.118, "high": 9.156, "low": 9.108, "open": 9.128, "pct_chg": -0.19, "pre_close": 9.135, "trade_date": "20260901", "ts_code": "518880.SH", "vol": 4510448.54}, {"amount": 8556652.878, "change": -0.34, "close": 9.135, "high": 9.171, "low": 9.05, "open": 9.16, "pct_chg": -3.59, "pre_close": 9.475, "trade_date": "20260831", "ts_code": "518880.SH", "vol": 9381907.65}, {"amount": 4462695.029, "change": 0.029, "close": 9.475, "high": 9.475, "low": 9.398, "open": 9.437, "pct_chg": 0.31, "pre_close": 9.446, "trade_date": "20260828", "ts_code": "518880.SH", "vol": 4737013.59}, {"amount": 5438577.528, "change": -0.071, "close": 9.446, "high": 9.54, "low": 9.445, "open": 9.504, "pct_chg": -0.75, "pre_close": 9.517, "trade_date": "20260827", "ts_code": "518880.SH", "vol": 5729584.27}, {"amount": 5450348.265, "change": -0.016, "close": 9.517, "high": 9.588, "low": 9.498, "open": 9.539, "pct_chg": -0.17, "pre_close": 9.533, "trade_date": "20260826", "ts_code": "518880.SH", "vol": 5712793.84}, {"amount": 8837077.964, "change": -0.031, "close": 9.533, "high": 9.62, "low": 9.497, "open": 9.607, "pct_chg": -0.32, "pre_close": 9.564, "trade_date": "20260825", "ts_code": "518880.SH", "vol": 9249731.89}, {"amount": 8717758.032, "change": 0.176, "close": 9.564, "high": 9.582, "low": 9.48, "open": 9.488, "pct_chg": 1.88, "pre_close": 9.388, "trade_date": "20260824", "ts_code": "518880.SH", "vol": 9136327.92}, {"amount": 7684951.957, "change": 0.154, "close": 9.388, "high": 9.391, "low": 9.295, "open": 9.301, "pct_chg": 1.67, "pre_close": 9.234, "trade_date": "20260821", "ts_code": "518880.SH", "vol": 8229367.17}, {"amount": 5097893.331, "change": 0.26, "close": 9.234, "high": 9.253, "low": 9.2, "open": 9.251, "pct_chg": 2.9, "pre_close": 8.974, "trade_date": "20260820", "ts_code": "518880.SH", "vol": 5522733.33}, {"amount": 3593183.883, "change": -0.104, "close": 8.974, "high": 9.011, "low": 8.949, "open": 8.957, "pct_chg": -1.15, "pre_close": 9.078, "trade_date": "20260819", "ts_code": "518880.SH", "vol": 4002565.83}, {"amount": 3266272.875, "change": 0.01, "close": 9.078, "high": 9.126, "low": 9.047, "open": 9.111, "pct_chg": 0.11, "pre_close": 9.068, "trade_date": "20260818", "ts_code": "518880.SH", "vol": 3597530.54}, {"amount": 4032553.61, "change": 0.122, "close": 9.068, "high": 9.095, "low": 9.048, "open": 9.09, "pct_chg": 1.36, "pre_close": 8.946, "trade_date": "20260817", "ts_code": "518880.SH", "vol": 4450545.56}, {"amount": 4681877.001, "change": -0.096, "close": 8.946, "high": 8.949, "low": 8.891, "open": 8.906, "pct_chg": -1.06, "pre_close": 9.042, "trade_date": "20260814", "ts_code": "518880.SH", "vol": 5250783.77}, {"amount": 4877606.464, "change": -0.061, "close": 9.042, "high": 9.144, "low": 9.016, "open": 9.126, "pct_chg": -0.67, "pre_close": 9.103, "trade_date": "20260813", "ts_code": "518880.SH", "vol": 5373857.11}, {"amount": 5324529.499, "change": 0.094, "close": 9.103, "high": 9.121, "low": 9.051, "open": 9.07, "pct_chg": 1.04, "pre_close": 9.009, "trade_date": "20260812", "ts_code": "518880.SH", "vol": 5861954.01}, {"amount": 8414200.025, "change": 0.004, "close": 9.009, "high": 9.163, "low": 9.001, "open": 9.13, "pct_chg": 0.04, "pre_close": 9.005, "trade_date": "20260811", "ts_code": "518880.SH", "vol": 9266083.43}, {"amount": 5999793.89, "change": 0.113, "close": 9.005, "high": 9.005, "low": 8.908, "open": 8.94, "pct_chg": 1.27, "pre_close": 8.892, "trade_date": "20260810", "ts_code": "518880.SH", "vol": 6699550.11}, {"amount": 6154122.28, "change": 0.097, "close": 8.892, "high": 8.892, "low": 8.774, "open": 8.775, "pct_chg": 1.1, "pre_close": 8.795, "trade_date": "20260807", "ts_code": "518880.SH", "vol": 6963895.42}, {"amount": 7254680.44, "change": 0.155, "close": 8.795, "high": 8.905, "low": 8.783, "open": 8.88, "pct_chg": 1.79, "pre_close": 8.64, "trade_date": "20260806", "ts_code": "518880.SH", "vol": 8200628.38}, {"amount": 6399150.042, "change": 0.232, "close": 8.64, "high": 8.644, "low": 8.442, "open": 8.459, "pct_chg": 2.76, "pre_close": 8.408, "trade_date": "20260805", "ts_code": "518880.SH", "vol": 7474522.12}, {"amount": 2054825.165, "change": -0.009, "close": 8.408, "high": 8.425, "low": 8.391, "open": 8.394, "pct_chg": -0.11, "pre_close": 8.417, "trade_date": "20260804", "ts_code": "518880.SH", "vol": 2442896.78}, {"amount": 2710491.4, "change": -0.016, "close": 8.417, "high": 8.43, "low": 8.383, "open": 8.4, "pct_chg": -0.19, "pre_close": 8.433, "trade_date": "20260803", "ts_code": "518880.SH", "vol": 3223144.06}, {"amount": 2518760.872, "change": 0.073, "close": 8.433, "high": 8.464, "low": 8.42, "open": 8.46, "pct_chg": 0.87, "pre_close": 8.36, "trade_date": "20260731", "ts_code": "518880.SH", "vol": 2983895.17}, {"amount": 3395683.596, "change": -0.032, "close": 8.36, "high": 8.484, "low": 8.35, "open": 8.463, "pct_chg": -0.38, "pre_close": 8.392, "trade_date": "20260730", "ts_code": "518880.SH", "vol": 4033314.64}, {"amount": 2274588.148, "change": -0.008, "close": 8.392, "high": 8.399, "low": 8.34, "open": 8.34, "pct_chg": -0.1, "pre_close": 8.4, "trade_date": "20260729", "ts_code": "518880.SH", "vol": 2715563.76}, {"amount": 1793455.522, "change": -0.102, "close": 8.4, "high": 8.406, "low": 8.372, "open": 8.391, "pct_chg": -1.2, "pre_close": 8.502, "trade_date": "20260728", "ts_code": "518880.SH", "vol": 2137056.79}, {"amount": 2182507.755, "change": 0.117, "close": 8.502, "high": 8.527, "low": 8.476, "open": 8.503, "pct_chg": 1.4, "pre_close": 8.385, "trade_date": "20260727", "ts_code": "518880.SH", "vol": 2567335.38}, {"amount": 3099445.119, "change": -0.175, "close": 8.385, "high": 8.42, "low": 8.36, "open": 8.4, "pct_chg": -2.04, "pre_close": 8.56, "trade_date": "20260724", "ts_code": "518880.SH", "vol": 3696369}, {"amount": 3180421.456, "change": 0, "close": 8.56, "high": 8.61, "low": 8.541, "open": 8.604, "pct_chg": 0, "pre_close": 8.56, "trade_date": "20260723", "ts_code": "518880.SH", "vol": 3712661.98}, {"amount": 5570743.361, "change": 0.092, "close": 8.56, "high": 8.614, "low": 8.546, "open": 8.555, "pct_chg": 1.09, "pre_close": 8.468, "trade_date": "20260722", "ts_code": "518880.SH", "vol": 6494273.12}, {"amount": 2700615.686, "change": 0.15, "close": 8.468, "high": 8.477, "low": 8.345, "open": 8.352, "pct_chg": 1.8, "pre_close": 8.318, "trade_date": "20260721", "ts_code": "518880.SH", "vol": 3211973.19}, {"amount": 1671393.158, "change": 0.007, "close": 8.318, "high": 8.362, "low": 8.3, "open": 8.311, "pct_chg": 0.08, "pre_close": 8.311, "trade_date": "20260720", "ts_code": "518880.SH", "vol": 2007208}, {"amount": 2358048.522, "change": -0.052, "close": 8.311, "high": 8.326, "low": 8.256, "open": 8.291, "pct_chg": -0.62, "pre_close": 8.363, "trade_date": "20260717", "ts_code": "518880.SH", "vol": 2844768.6}, {"amount": 1506556.933, "change": -0.006, "close": 8.363, "high": 8.395, "low": 8.349, "open": 8.375, "pct_chg": -0.07, "pre_close": 8.369, "trade_date": "20260716", "ts_code": "518880.SH", "vol": 1800219.1}, {"amount": 1912067.301, "change": -0.004, "close": 8.369, "high": 8.42, "low": 8.347, "open": 8.415, "pct_chg": -0.05, "pre_close": 8.373, "trade_date": "20260715", "ts_code": "518880.SH", "vol": 2283309.52}, {"amount": 1974213.7, "change": -0.046, "close": 8.373, "high": 8.389, "low": 8.302, "open": 8.302, "pct_chg": -0.55, "pre_close": 8.419, "trade_date": "20260714", "ts_code": "518880.SH", "vol": 2362128}, {"amount": 2551745.651, "change": -0.122, "close": 8.419, "high": 8.497, "low": 8.413, "open": 8.47, "pct_chg": -1.43, "pre_close": 8.541, "trade_date": "20260713", "ts_code": "518880.SH", "vol": 3020485.24}, {"amount": 1953497.774, "change": -0.003, "close": 8.541, "high": 8.587, "low": 8.538, "open": 8.551, "pct_chg": -0.04, "pre_close": 8.544, "trade_date": "20260710", "ts_code": "518880.SH", "vol": 2282915.24}, {"amount": 2084790.591, "change": -0.045, "close": 8.544, "high": 8.55, "low": 8.446, "open": 8.495, "pct_chg": -0.52, "pre_close": 8.589, "trade_date": "20260709", "ts_code": "518880.SH", "vol": 2454206.53}, {"amount": 2068067.225, "change": -0.008, "close": 8.589, "high": 8.605, "low": 8.53, "open": 8.543, "pct_chg": -0.09, "pre_close": 8.597, "trade_date": "20260708", "ts_code": "518880.SH", "vol": 2410681.82}, {"amount": 1924146.85, "change": -0.034, "close": 8.597, "high": 8.62, "low": 8.564, "open": 8.59, "pct_chg": -0.39, "pre_close": 8.631, "trade_date": "20260707", "ts_code": "518880.SH", "vol": 2238576.97}, {"amount": 2744403.494, "change": -0.041, "close": 8.631, "high": 8.738, "low": 8.62, "open": 8.696, "pct_chg": -0.47, "pre_close": 8.672, "trade_date": "20260706", "ts_code": "518880.SH", "vol": 3162708.03}, {"amount": 4059995.573, "change": 0.197, "close": 8.672, "high": 8.726, "low": 8.636, "open": 8.706, "pct_chg": 2.32, "pre_close": 8.475, "trade_date": "20260703", "ts_code": "518880.SH", "vol": 4676684.83}, {"amount": 4346984.456, "change": 0.204, "close": 8.475, "high": 8.484, "low": 8.397, "open": 8.425, "pct_chg": 2.47, "pre_close": 8.271, "trade_date": "20260702", "ts_code": "518880.SH", "vol": 5149789.77}, {"amount": 2526820.875, "change": -0.107, "close": 8.271, "high": 8.3, "low": 8.253, "open": 8.284, "pct_chg": -1.28, "pre_close": 8.378, "trade_date": "20260701", "ts_code": "518880.SH", "vol": 3052898.7}, {"amount": 3473352.887, "change": -0.071, "close": 8.378, "high": 8.389, "low": 8.224, "open": 8.262, "pct_chg": -0.84, "pre_close": 8.449, "trade_date": "20260630", "ts_code": "518880.SH", "vol": 4192779.45}, {"amount": 2870486.268, "change": 0.059, "close": 8.449, "high": 8.479, "low": 8.404, "open": 8.445, "pct_chg": 0.7, "pre_close": 8.39, "trade_date": "20260629", "ts_code": "518880.SH", "vol": 3398349.07}, {"amount": 3213357.332, "change": 0.103, "close": 8.39, "high": 8.393, "low": 8.293, "open": 8.365, "pct_chg": 1.24, "pre_close": 8.287, "trade_date": "20260626", "ts_code": "518880.SH", "vol": 3848075.48}, {"amount": 4224829.996, "change": -0.228, "close": 8.287, "high": 8.33, "low": 8.267, "open": 8.33, "pct_chg": -2.68, "pre_close": 8.515, "trade_date": "20260625", "ts_code": "518880.SH", "vol": 5089866.39}, {"amount": 3418706.769, "change": -0.022, "close": 8.515, "high": 8.53, "low": 8.426, "open": 8.485, "pct_chg": -0.26, "pre_close": 8.537, "trade_date": "20260624", "ts_code": "518880.SH", "vol": 4029970.39}, {"amount": 3654668.516, "change": -0.179, "close": 8.537, "high": 8.674, "low": 8.525, "open": 8.668, "pct_chg": -2.05, "pre_close": 8.716, "trade_date": "20260623", "ts_code": "518880.SH", "vol": 4256305.33}, {"amount": 3834546.126, "change": -0.192, "close": 8.716, "high": 8.742, "low": 8.655, "open": 8.735, "pct_chg": -2.16, "pre_close": 8.908, "trade_date": "20260622", "ts_code": "518880.SH", "vol": 4409718.31}, {"amount": 2663369.298, "change": -0.049, "close": 8.908, "high": 8.964, "low": 8.892, "open": 8.95, "pct_chg": -0.55, "pre_close": 8.957, "trade_date": "20260618", "ts_code": "518880.SH", "vol": 2982682.13}, {"amount": 2361143.262, "change": 0.008, "close": 8.957, "high": 8.986, "low": 8.933, "open": 8.978, "pct_chg": 0.09, "pre_close": 8.949, "trade_date": "20260617", "ts_code": "518880.SH", "vol": 2634057.74}, {"amount": 2461164.661, "change": 0.021, "close": 8.949, "high": 8.968, "low": 8.92, "open": 8.945, "pct_chg": 0.24, "pre_close": 8.928, "trade_date": "20260616", "ts_code": "518880.SH", "vol": 2752220.53}, {"amount": 4468968.234, "change": 0.269, "close": 8.928, "high": 8.988, "low": 8.91, "open": 8.916, "pct_chg": 3.11, "pre_close": 8.659, "trade_date": "20260615", "ts_code": "518880.SH", "vol": 4994174.55}, {"amount": 3787489.191, "change": 0.153, "close": 8.659, "high": 8.715, "low": 8.639, "open": 8.67, "pct_chg": 1.8, "pre_close": 8.506, "trade_date": "20260612", "ts_code": "518880.SH", "vol": 4367406.87}, {"amount": 5515478.4, "change": -0.23, "close": 8.506, "high": 8.58, "low": 8.418, "open": 8.561, "pct_chg": -2.63, "pre_close": 8.736, "trade_date": "20260611", "ts_code": "518880.SH", "vol": 6491526.77}, {"amount": 4889766.654, "change": -0.269, "close": 8.736, "high": 8.78, "low": 8.671, "open": 8.74, "pct_chg": -2.99, "pre_close": 9.005, "trade_date": "20260610", "ts_code": "518880.SH", "vol": 5609092.57}, {"amount": 2589621.005, "change": 0.052, "close": 9.005, "high": 9.046, "low": 9.004, "open": 9.01, "pct_chg": 0.58, "pre_close": 8.953, "trade_date": "20260609", "ts_code": "518880.SH", "vol": 2870005.33}, {"amount": 4442538.006, "change": -0.299, "close": 8.953, "high": 9.024, "low": 8.893, "open": 9.001, "pct_chg": -3.23, "pre_close": 9.252, "trade_date": "20260608", "ts_code": "518880.SH", "vol": 4952978.82}, {"amount": 2788572.679, "change": -0.038, "close": 9.252, "high": 9.275, "low": 9.21, "open": 9.263, "pct_chg": -0.41, "pre_close": 9.29, "trade_date": "20260605", "ts_code": "518880.SH", "vol": 3017553.05}, {"amount": 2623259.392, "change": 0.009, "close": 9.29, "high": 9.32, "low": 9.274, "open": 9.301, "pct_chg": 0.1, "pre_close": 9.281, "trade_date": "20260604", "ts_code": "518880.SH", "vol": 2821726.74}, {"amount": 3024667.411, "change": -0.129, "close": 9.281, "high": 9.327, "low": 9.263, "open": 9.28, "pct_chg": -1.37, "pre_close": 9.41, "trade_date": "20260603", "ts_code": "518880.SH", "vol": 3254876}, {"amount": 3034940.31, "change": 0.066, "close": 9.41, "high": 9.414, "low": 9.266, "open": 9.3, "pct_chg": 0.71, "pre_close": 9.344, "trade_date": "20260602", "ts_code": "518880.SH", "vol": 3247780.5}, {"amount": 2427380.926, "change": -0.053, "close": 9.344, "high": 9.414, "low": 9.343, "open": 9.398, "pct_chg": -0.56, "pre_close": 9.397, "trade_date": "20260601", "ts_code": "518880.SH", "vol": 2586409.02}, {"amount": 3262565.266, "change": 0.265, "close": 9.397, "high": 9.4, "low": 9.34, "open": 9.385, "pct_chg": 2.9, "pre_close": 9.132, "trade_date": "20260529", "ts_code": "518880.SH", "vol": 3482352.18}, {"amount": 4181014.488, "change": -0.213, "close": 9.132, "high": 9.218, "low": 9.108, "open": 9.216, "pct_chg": -2.28, "pre_close": 9.345, "trade_date": "20260528", "ts_code": "518880.SH", "vol": 4566207.53}, {"amount": 3044601.314, "change": -0.109, "close": 9.345, "high": 9.425, "low": 9.336, "open": 9.419, "pct_chg": -1.153, "pre_close": 9.454, "trade_date": "20260527", "ts_code": "518880.SH", "vol": 3246682.08}, {"amount": 2089349.183, "change": -0.058, "close": 9.454, "high": 9.487, "low": 9.442, "open": 9.481, "pct_chg": -0.6098, "pre_close": 9.512, "trade_date": "20260526", "ts_code": "518880.SH", "vol": 2208614.2}, {"amount": 2269776.991, "change": 0.051, "close": 9.512, "high": 9.54, "low": 9.49, "open": 9.531, "pct_chg": 0.5391, "pre_close": 9.461, "trade_date": "20260525", "ts_code": "518880.SH", "vol": 2386059.4}, {"amount": 2567719.896, "change": 0.011, "close": 9.461, "high": 9.474, "low": 9.424, "open": 9.46, "pct_chg": 0.1164, "pre_close": 9.45, "trade_date": "20260522", "ts_code": "518880.SH", "vol": 2715696.63}, {"amount": 3206814.236, "change": 0.083, "close": 9.45, "high": 9.543, "low": 9.447, "open": 9.53, "pct_chg": 0.8861, "pre_close": 9.367, "trade_date": "20260521", "ts_code": "518880.SH", "vol": 3377730.7}, {"amount": 5114796.529, "change": -0.148, "close": 9.367, "high": 9.4, "low": 9.332, "open": 9.4, "pct_chg": -1.5554, "pre_close": 9.515, "trade_date": "20260520", "ts_code": "518880.SH", "vol": 5463214.91}, {"amount": 2516240.589, "change": 0.002, "close": 9.515, "high": 9.554, "low": 9.485, "open": 9.519, "pct_chg": 0.021, "pre_close": 9.513, "trade_date": "20260519", "ts_code": "518880.SH", "vol": 2643764.15}, {"amount": 4527932.597, "change": -0.041, "close": 9.513, "high": 9.534, "low": 9.435, "open": 9.44, "pct_chg": -0.4291, "pre_close": 9.554, "trade_date": "20260518", "ts_code": "518880.SH", "vol": 4769784.63}, {"amount": 6233847.911, "change": -0.241, "close": 9.554, "high": 9.659, "low": 9.532, "open": 9.645, "pct_chg": -2.4604, "pre_close": 9.795, "trade_date": "20260515", "ts_code": "518880.SH", "vol": 6498776.83}, {"amount": 2903498.943, "change": -0.009, "close": 9.795, "high": 9.802, "low": 9.761, "open": 9.78, "pct_chg": -0.0918, "pre_close": 9.804, "trade_date": "20260514", "ts_code": "518880.SH", "vol": 2968823.93}, {"amount": 2925864.489, "change": -0.002, "close": 9.804, "high": 9.834, "low": 9.78, "open": 9.818, "pct_chg": -0.0204, "pre_close": 9.806, "trade_date": "20260513", "ts_code": "518880.SH", "vol": 2983073.55}, {"amount": 3997558.136, "change": 0.032, "close": 9.806, "high": 9.906, "low": 9.8, "open": 9.9, "pct_chg": 0.3274, "pre_close": 9.774, "trade_date": "20260512", "ts_code": "518880.SH", "vol": 4059230.15}, {"amount": 4376016.747, "change": -0.106, "close": 9.774, "high": 9.827, "low": 9.714, "open": 9.827, "pct_chg": -1.0729, "pre_close": 9.88, "trade_date": "20260511", "ts_code": "518880.SH", "vol": 4475876.7}, {"amount": 3361489.92, "change": -0.029, "close": 9.88, "high": 9.897, "low": 9.853, "open": 9.871, "pct_chg": -0.2927, "pre_close": 9.909, "trade_date": "20260508", "ts_code": "518880.SH", "vol": 3403790.15}, {"amount": 4322115.66, "change": 0.118, "close": 9.909, "high": 9.933, "low": 9.835, "open": 9.871, "pct_chg": 1.2052, "pre_close": 9.791, "trade_date": "20260507", "ts_code": "518880.SH", "vol": 4380057.3}, {"amount": 3827234.55, "change": 0.113, "close": 9.791, "high": 9.792, "low": 9.698, "open": 9.698, "pct_chg": 1.1676, "pre_close": 9.678, "trade_date": "20260506", "ts_code": "518880.SH", "vol": 3926977.76}, {"amount": 3894564.587, "change": 0.041, "close": 9.678, "high": 9.679, "low": 9.58, "open": 9.629, "pct_chg": 0.4254, "pre_close": 9.637, "trade_date": "20260430", "ts_code": "518880.SH", "vol": 4046629.69}, {"amount": 4943877.284, "change": -0.095, "close": 9.637, "high": 9.68, "low": 9.63, "open": 9.641, "pct_chg": -0.9762, "pre_close": 9.732, "trade_date": "20260429", "ts_code": "518880.SH", "vol": 5119968.33}, {"amount": 3676337.287, "change": -0.165, "close": 9.732, "high": 9.854, "low": 9.714, "open": 9.85, "pct_chg": -1.6672, "pre_close": 9.897, "trade_date": "20260428", "ts_code": "518880.SH", "vol": 3761043.47}, {"amount": 2526051.171, "change": 0.071, "close": 9.897, "high": 9.917, "low": 9.857, "open": 9.859, "pct_chg": 0.7226, "pre_close": 9.826, "trade_date": "20260427", "ts_code": "518880.SH", "vol": 2554760.37}, {"amount": 3091878.872, "change": -0.074, "close": 9.826, "high": 9.886, "low": 9.814, "open": 9.885, "pct_chg": -0.7475, "pre_close": 9.9, "trade_date": "20260424", "ts_code": "518880.SH", "vol": 3141902.01}, {"amount": 4226616.551, "change": -0.104, "close": 9.9, "high": 9.947, "low": 9.865, "open": 9.935, "pct_chg": -1.0396, "pre_close": 10.004, "trade_date": "20260423", "ts_code": "518880.SH", "vol": 4267851.3}, {"amount": 3637270.995, "change": -0.006, "close": 10.004, "high": 10.008, "low": 9.968, "open": 9.968, "pct_chg": -0.0599, "pre_close": 10.01, "trade_date": "20260422", "ts_code": "518880.SH", "vol": 3642197.94}, {"amount": 3250579.583, "change": -0.026, "close": 10.01, "high": 10.072, "low": 10.003, "open": 10.07, "pct_chg": -0.2591, "pre_close": 10.036, "trade_date": "20260421", "ts_code": "518880.SH", "vol": 3239028.76}, {"amount": 3572623.672, "change": 0.007, "close": 10.036, "high": 10.075, "low": 10.013, "open": 10.04, "pct_chg": 0.0698, "pre_close": 10.029, "trade_date": "20260420", "ts_code": "518880.SH", "vol": 3558334.75}, {"amount": 3589199.404, "change": -0.068, "close": 10.029, "high": 10.071, "low": 9.99, "open": 10, "pct_chg": -0.6735, "pre_close": 10.097, "trade_date": "20260417", "ts_code": "518880.SH", "vol": 3577189.01}, {"amount": 3796363.197, "change": 0.033, "close": 10.097, "high": 10.111, "low": 10.07, "open": 10.095, "pct_chg": 0.3279, "pre_close": 10.064, "trade_date": "20260416", "ts_code": "518880.SH", "vol": 3762437.13}, {"amount": 4949413.437, "change": 0.053, "close": 10.064, "high": 10.15, "low": 10.062, "open": 10.127, "pct_chg": 0.5294, "pre_close": 10.011, "trade_date": "20260415", "ts_code": "518880.SH", "vol": 4897697.44}, {"amount": 4218777.617, "change": 0.087, "close": 10.011, "high": 10.012, "low": 9.968, "open": 9.999, "pct_chg": 0.8767, "pre_close": 9.924, "trade_date": "20260414", "ts_code": "518880.SH", "vol": 4221604.66}, {"amount": 4251246.607, "change": -0.048, "close": 9.924, "high": 9.939, "low": 9.9, "open": 9.918, "pct_chg": -0.4813, "pre_close": 9.972, "trade_date": "20260413", "ts_code": "518880.SH", "vol": 4285543.92}, {"amount": 5772482.719, "change": 0.064, "close": 9.972, "high": 10.03, "low": 9.954, "open": 9.995, "pct_chg": 0.6459, "pre_close": 9.908, "trade_date": "20260410", "ts_code": "518880.SH", "vol": 5776953.24}, {"amount": 6501536.554, "change": -0.205, "close": 9.908, "high": 9.95, "low": 9.9, "open": 9.92, "pct_chg": -2.0271, "pre_close": 10.113, "trade_date": "20260409", "ts_code": "518880.SH", "vol": 6552527.44}, {"amount": 10051847.595, "change": 0.276, "close": 10.113, "high": 10.153, "low": 10.057, "open": 10.103, "pct_chg": 2.8057, "pre_close": 9.837, "trade_date": "20260408", "ts_code": "518880.SH", "vol": 9943479.75}, {"amount": 5331846.502, "change": 0.009, "close": 9.837, "high": 9.867, "low": 9.781, "open": 9.845, "pct_chg": 0.0916, "pre_close": 9.828, "trade_date": "20260407", "ts_code": "518880.SH", "vol": 5427847.69}, {"amount": 4176219.528, "change": 0.09, "close": 9.828, "high": 9.914, "low": 9.793, "open": 9.914, "pct_chg": 0.9242, "pre_close": 9.738, "trade_date": "20260403", "ts_code": "518880.SH", "vol": 4242962}, {"amount": 12814253.058, "change": -0.286, "close": 9.738, "high": 10, "low": 9.641, "open": 10, "pct_chg": -2.8532, "pre_close": 10.024, "trade_date": "20260402", "ts_code": "518880.SH", "vol": 13026467.46}, {"amount": 8316311.343, "change": 0.325, "close": 10.024, "high": 10.028, "low": 9.912, "open": 9.95, "pct_chg": 3.3509, "pre_close": 9.699, "trade_date": "20260401", "ts_code": "518880.SH", "vol": 8356708.18}, {"amount": 9575690.405, "change": 0.043, "close": 9.699, "high": 9.836, "low": 9.697, "open": 9.751, "pct_chg": 0.4453, "pre_close": 9.656, "trade_date": "20260331", "ts_code": "518880.SH", "vol": 9818017.1}, {"amount": 10053472.875, "change": 0.162, "close": 9.656, "high": 9.696, "low": 9.473, "open": 9.496, "pct_chg": 1.7063, "pre_close": 9.494, "trade_date": "20260330", "ts_code": "518880.SH", "vol": 10467459.03}, {"amount": 7077216.082, "change": 0.044, "close": 9.494, "high": 9.528, "low": 9.358, "open": 9.358, "pct_chg": 0.4656, "pre_close": 9.45, "trade_date": "20260327", "ts_code": "518880.SH", "vol": 7487072.64}, {"amount": 10525933.922, "change": -0.191, "close": 9.45, "high": 9.637, "low": 9.358, "open": 9.628, "pct_chg": -1.9811, "pre_close": 9.641, "trade_date": "20260326", "ts_code": "518880.SH", "vol": 11076212.74}, {"amount": 11438092.704, "change": 0.342, "close": 9.641, "high": 9.772, "low": 9.609, "open": 9.7, "pct_chg": 3.6778, "pre_close": 9.299, "trade_date": "20260325", "ts_code": "518880.SH", "vol": 11786322.01}, {"amount": 13716736.873, "change": 0.358, "close": 9.299, "high": 9.303, "low": 9.105, "open": 9.21, "pct_chg": 4.004, "pre_close": 8.941, "trade_date": "20260324", "ts_code": "518880.SH", "vol": 14905164.64}, {"amount": 18231773.873, "change": -0.955, "close": 8.941, "high": 9.5, "low": 8.906, "open": 9.488, "pct_chg": -9.6504, "pre_close": 9.896, "trade_date": "20260323", "ts_code": "518880.SH", "vol": 19747659.11}, {"amount": 11878418.533, "change": -0.23, "close": 9.896, "high": 10.066, "low": 9.883, "open": 9.9, "pct_chg": -2.2714, "pre_close": 10.126, "trade_date": "20260320", "ts_code": "518880.SH", "vol": 11917840.92}, {"amount": 9006105.538, "change": -0.48, "close": 10.126, "high": 10.345, "low": 10.08, "open": 10.283, "pct_chg": -4.5257, "pre_close": 10.606, "trade_date": "20260319", "ts_code": "518880.SH", "vol": 8783621.47}, {"amount": 3756857.569, "change": -0.031, "close": 10.606, "high": 10.639, "low": 10.57, "open": 10.638, "pct_chg": -0.2914, "pre_close": 10.637, "trade_date": "20260318", "ts_code": "518880.SH", "vol": 3544273.28}, {"amount": 3766972.817, "change": -0.027, "close": 10.637, "high": 10.696, "low": 10.63, "open": 10.632, "pct_chg": -0.2532, "pre_close": 10.664, "trade_date": "20260317", "ts_code": "518880.SH", "vol": 3535281.9}, {"amount": 5100457.125, "change": -0.138, "close": 10.664, "high": 10.7, "low": 10.605, "open": 10.63, "pct_chg": -1.2775, "pre_close": 10.802, "trade_date": "20260316", "ts_code": "518880.SH", "vol": 4789246.6}, {"amount": 4211610.423, "change": -0.136, "close": 10.802, "high": 10.88, "low": 10.791, "open": 10.852, "pct_chg": -1.2434, "pre_close": 10.938, "trade_date": "20260313", "ts_code": "518880.SH", "vol": 3885245.33}, {"amount": 4253167.289, "change": -0.036, "close": 10.938, "high": 10.956, "low": 10.892, "open": 10.928, "pct_chg": -0.328, "pre_close": 10.974, "trade_date": "20260312", "ts_code": "518880.SH", "vol": 3892379.02}, {"amount": 5230093.074, "change": 0.018, "close": 10.974, "high": 11.028, "low": 10.964, "open": 11.026, "pct_chg": 0.1643, "pre_close": 10.956, "trade_date": "20260311", "ts_code": "518880.SH", "vol": 4754957.27}, {"amount": 4801155.476, "change": 0.088, "close": 10.956, "high": 10.991, "low": 10.943, "open": 10.963, "pct_chg": 0.8097, "pre_close": 10.868, "trade_date": "20260310", "ts_code": "518880.SH", "vol": 4379328.78}, {"amount": 8713281.061, "change": -0.005, "close": 10.868, "high": 10.95, "low": 10.793, "open": 10.858, "pct_chg": -0.046, "pre_close": 10.873, "trade_date": "20260309", "ts_code": "518880.SH", "vol": 8012132.16}, {"amount": 5356472.276, "change": -0.091, "close": 10.873, "high": 10.937, "low": 10.831, "open": 10.834, "pct_chg": -0.83, "pre_close": 10.964, "trade_date": "20260306", "ts_code": "518880.SH", "vol": 4916711.93}, {"amount": 7165201.084, "change": -0.016, "close": 10.964, "high": 11.014, "low": 10.889, "open": 10.98, "pct_chg": -0.1457, "pre_close": 10.98, "trade_date": "20260305", "ts_code": "518880.SH", "vol": 6536802}, {"amount": 10981117.96, "change": -0.279, "close": 10.98, "high": 11.085, "low": 10.926, "open": 11, "pct_chg": -2.478, "pre_close": 11.259, "trade_date": "20260304", "ts_code": "518880.SH", "vol": 9979811.75}, {"amount": 10968192.369, "change": -0.158, "close": 11.259, "high": 11.418, "low": 11.202, "open": 11.418, "pct_chg": -1.3839, "pre_close": 11.417, "trade_date": "20260303", "ts_code": "518880.SH", "vol": 9692849.41}, {"amount": 12775642.356, "change": 0.484, "close": 11.417, "high": 11.419, "low": 11.214, "open": 11.368, "pct_chg": 4.427, "pre_close": 10.933, "trade_date": "20260302", "ts_code": "518880.SH", "vol": 11300971.71}, {"amount": 5063424.364, "change": 0.02, "close": 10.933, "high": 10.95, "low": 10.911, "open": 10.92, "pct_chg": 0.1833, "pre_close": 10.913, "trade_date": "20260227", "ts_code": "518880.SH", "vol": 4631263.87}, {"amount": 4781387.315, "change": -0.046, "close": 10.913, "high": 10.944, "low": 10.896, "open": 10.939, "pct_chg": -0.4197, "pre_close": 10.959, "trade_date": "20260226", "ts_code": "518880.SH", "vol": 4377669.76}, {"amount": 6656474.798, "change": 0.009, "close": 10.959, "high": 10.992, "low": 10.92, "open": 10.953, "pct_chg": 0.0822, "pre_close": 10.95, "trade_date": "20260225", "ts_code": "518880.SH", "vol": 6074773.46}, {"amount": 8241565.507, "change": 0.375, "close": 10.95, "high": 10.998, "low": 10.903, "open": 10.92, "pct_chg": 3.5461, "pre_close": 10.575, "trade_date": "20260224", "ts_code": "518880.SH", "vol": 7523137.23}, {"amount": 8131354.16, "change": -0.145, "close": 10.575, "high": 10.617, "low": 10.508, "open": 10.582, "pct_chg": -1.3526, "pre_close": 10.72, "trade_date": "20260213", "ts_code": "518880.SH", "vol": 7693922.58}, {"amount": 6445229.523, "change": -0.038, "close": 10.72, "high": 10.752, "low": 10.7, "open": 10.72, "pct_chg": -0.3532, "pre_close": 10.758, "trade_date": "20260212", "ts_code": "518880.SH", "vol": 6004793.5}, {"amount": 7317564.815, "change": 0.089, "close": 10.758, "high": 10.765, "low": 10.7, "open": 10.704, "pct_chg": 0.8342, "pre_close": 10.669, "trade_date": "20260211", "ts_code": "518880.SH", "vol": 6818267.14}, {"amount": 7513073.622, "change": -0.05, "close": 10.669, "high": 10.733, "low": 10.661, "open": 10.686, "pct_chg": -0.4665, "pre_close": 10.719, "trade_date": "20260210", "ts_code": "518880.SH", "vol": 7026000.61}, {"amount": 12418166.669, "change": 0.362, "close": 10.719, "high": 10.75, "low": 10.61, "open": 10.72, "pct_chg": 3.4952, "pre_close": 10.357, "trade_date": "20260209", "ts_code": "518880.SH", "vol": 11612227.45}, {"amount": 19034272.998, "change": -0.166, "close": 10.357, "high": 10.523, "low": 10.05, "open": 10.079, "pct_chg": -1.5775, "pre_close": 10.523, "trade_date": "20260206", "ts_code": "518880.SH", "vol": 18412789.75}, {"amount": 20057436.535, "change": -0.344, "close": 10.523, "high": 10.695, "low": 10.3, "open": 10.61, "pct_chg": -3.1655, "pre_close": 10.867, "trade_date": "20260205", "ts_code": "518880.SH", "vol": 19090454.95}, {"amount": 15412917.353, "change": 0.445, "close": 10.867, "high": 10.904, "low": 10.738, "open": 10.795, "pct_chg": 4.2698, "pre_close": 10.422, "trade_date": "20260204", "ts_code": "518880.SH", "vol": 14242989.36}, {"amount": 21613177.107, "change": 0.514, "close": 10.422, "high": 10.461, "low": 10.105, "open": 10.355, "pct_chg": 5.1877, "pre_close": 9.908, "trade_date": "20260203", "ts_code": "518880.SH", "vol": 21009166.76}, {"amount": 19145014.244, "change": -1.101, "close": 9.908, "high": 10.245, "low": 9.908, "open": 9.908, "pct_chg": -10.0009, "pre_close": 11.009, "trade_date": "20260202", "ts_code": "518880.SH", "vol": 18998204.2}, {"amount": 25777505.557, "change": -0.895, "close": 11.009, "high": 11.533, "low": 10.8, "open": 11.471, "pct_chg": -7.5185, "pre_close": 11.904, "trade_date": "20260130", "ts_code": "518880.SH", "vol": 23016096.92}, {"amount": 17799095.491, "change": 0.624, "close": 11.904, "high": 11.977, "low": 11.796, "open": 11.918, "pct_chg": 5.5319, "pre_close": 11.28, "trade_date": "20260129", "ts_code": "518880.SH", "vol": 14971079.26}, {"amount": 11961498.145, "change": 0.346, "close": 11.28, "high": 11.288, "low": 11.109, "open": 11.11, "pct_chg": 3.1644, "pre_close": 10.934, "trade_date": "20260128", "ts_code": "518880.SH", "vol": 10662010.04}, {"amount": 8395732.861, "change": 0.01, "close": 10.934, "high": 10.946, "low": 10.848, "open": 10.883, "pct_chg": 0.0915, "pre_close": 10.924, "trade_date": "20260127", "ts_code": "518880.SH", "vol": 7711072.5}, {"amount": 10463068.625, "change": 0.307, "close": 10.924, "high": 10.955, "low": 10.848, "open": 10.886, "pct_chg": 2.8916, "pre_close": 10.617, "trade_date": "20260126", "ts_code": "518880.SH", "vol": 9604744.83}, {"amount": 7746553.192, "change": 0.276, "close": 10.617, "high": 10.643, "low": 10.585, "open": 10.618, "pct_chg": 2.669, "pre_close": 10.341, "trade_date": "20260123", "ts_code": "518880.SH", "vol": 7298894.96}, {"amount": 7398945.407, "change": -0.036, "close": 10.341, "high": 10.343, "low": 10.23, "open": 10.29, "pct_chg": -0.3469, "pre_close": 10.377, "trade_date": "20260122", "ts_code": "518880.SH", "vol": 7196858.37}, {"amount": 10230345.643, "change": 0.295, "close": 10.377, "high": 10.501, "low": 10.322, "open": 10.365, "pct_chg": 2.926, "pre_close": 10.082, "trade_date": "20260121", "ts_code": "518880.SH", "vol": 9845096.22}, {"amount": 5362959.212, "change": 0.076, "close": 10.082, "high": 10.095, "low": 9.983, "open": 10.005, "pct_chg": 0.7595, "pre_close": 10.006, "trade_date": "20260120", "ts_code": "518880.SH", "vol": 5338067.06}, {"amount": 4797229.646, "change": 0.149, "close": 10.006, "high": 10.009, "low": 9.96, "open": 9.962, "pct_chg": 1.5116, "pre_close": 9.857, "trade_date": "20260119", "ts_code": "518880.SH", "vol": 4806211.54}, {"amount": 3228858.975, "change": -0.01, "close": 9.857, "high": 9.874, "low": 9.828, "open": 9.851, "pct_chg": -0.1013, "pre_close": 9.867, "trade_date": "20260116", "ts_code": "518880.SH", "vol": 3278431.49}, {"amount": 5460435.94, "change": -0.055, "close": 9.867, "high": 9.883, "low": 9.82, "open": 9.861, "pct_chg": -0.5543, "pre_close": 9.922, "trade_date": "20260115", "ts_code": "518880.SH", "vol": 5542922.06}, {"amount": 4321616.075, "change": 0.129, "close": 9.922, "high": 9.925, "low": 9.869, "open": 9.871, "pct_chg": 1.3173, "pre_close": 9.793, "trade_date": "20260114", "ts_code": "518880.SH", "vol": 4369521.17}, {"amount": 3901169.996, "change": 0.016, "close": 9.793, "high": 9.825, "low": 9.771, "open": 9.79, "pct_chg": 0.1636, "pre_close": 9.777, "trade_date": "20260113", "ts_code": "518880.SH", "vol": 3982367.25}, {"amount": 4822414.247, "change": 0.185, "close": 9.777, "high": 9.791, "low": 9.736, "open": 9.779, "pct_chg": 1.9287, "pre_close": 9.592, "trade_date": "20260112", "ts_code": "518880.SH", "vol": 4935017}, {"amount": 3629946.415, "change": 0.081, "close": 9.592, "high": 9.596, "low": 9.544, "open": 9.544, "pct_chg": 0.8516, "pre_close": 9.511, "trade_date": "20260109", "ts_code": "518880.SH", "vol": 3791903.9}, {"amount": 4604940.099, "change": -0.007, "close": 9.511, "high": 9.559, "low": 9.474, "open": 9.555, "pct_chg": -0.0735, "pre_close": 9.518, "trade_date": "20260108", "ts_code": "518880.SH", "vol": 4838349.91}, {"amount": 5894333.39, "change": -0.059, "close": 9.518, "high": 9.601, "low": 9.511, "open": 9.6, "pct_chg": -0.6161, "pre_close": 9.577, "trade_date": "20260107", "ts_code": "518880.SH", "vol": 6163985.14}, {"amount": 5085365.304, "change": 0.082, "close": 9.577, "high": 9.606, "low": 9.528, "open": 9.536, "pct_chg": 0.8636, "pre_close": 9.495, "trade_date": "20260106", "ts_code": "518880.SH", "vol": 5313457.38}, {"amount": 6465146.234, "change": 0.194, "close": 9.495, "high": 9.499, "low": 9.421, "open": 9.421, "pct_chg": 2.0858, "pre_close": 9.301, "trade_date": "20260105", "ts_code": "518880.SH", "vol": 6832710.28}, {"amount": 8255346.608, "change": -0.062, "close": 9.301, "high": 9.392, "low": 9.196, "open": 9.359, "pct_chg": -0.6622, "pre_close": 9.363, "trade_date": "20251231", "ts_code": "518880.SH", "vol": 8873154.17}, {"amount": 8058585.692, "change": -0.2, "close": 9.363, "high": 9.402, "low": 9.272, "open": 9.272, "pct_chg": -2.0914, "pre_close": 9.563, "trade_date": "20251230", "ts_code": "518880.SH", "vol": 8615558.22}, {"amount": 6877104.094, "change": -0.087, "close": 9.563, "high": 9.667, "low": 9.55, "open": 9.64, "pct_chg": -0.9016, "pre_close": 9.65, "trade_date": "20251229", "ts_code": "518880.SH", "vol": 7144966.69}, {"amount": 4726243.848, "change": 0.084, "close": 9.65, "high": 9.68, "low": 9.618, "open": 9.67, "pct_chg": 0.8781, "pre_close": 9.566, "trade_date": "20251226", "ts_code": "518880.SH", "vol": 4898253.36}, {"amount": 4123112.968, "change": -0.076, "close": 9.566, "high": 9.58, "low": 9.539, "open": 9.571, "pct_chg": -0.7882, "pre_close": 9.642, "trade_date": "20251225", "ts_code": "518880.SH", "vol": 4311576.19}, {"amount": 7136924.986, "change": 0.003, "close": 9.642, "high": 9.722, "low": 9.613, "open": 9.719, "pct_chg": 0.0311, "pre_close": 9.639, "trade_date": "20251224", "ts_code": "518880.SH", "vol": 7384368.99}, {"amount": 8709327.485, "change": 0.128, "close": 9.639, "high": 9.689, "low": 9.608, "open": 9.63, "pct_chg": 1.3458, "pre_close": 9.511, "trade_date": "20251223", "ts_code": "518880.SH", "vol": 9022814.2}, {"amount": 6534234.042, "change": 0.198, "close": 9.511, "high": 9.514, "low": 9.38, "open": 9.38, "pct_chg": 2.1261, "pre_close": 9.313, "trade_date": "20251222", "ts_code": "518880.SH", "vol": 6918754.23}, {"amount": 3443922.186, "change": -0.005, "close": 9.313, "high": 9.321, "low": 9.273, "open": 9.29, "pct_chg": -0.0537, "pre_close": 9.318, "trade_date": "20251219", "ts_code": "518880.SH", "vol": 3703003.16}, {"amount": 2961343.686, "change": 0.008, "close": 9.318, "high": 9.327, "low": 9.302, "open": 9.311, "pct_chg": 0.0859, "pre_close": 9.31, "trade_date": "20251218", "ts_code": "518880.SH", "vol": 3178056.1}, {"amount": 4209416.976, "change": 0.07, "close": 9.31, "high": 9.345, "low": 9.283, "open": 9.283, "pct_chg": 0.7576, "pre_close": 9.24, "trade_date": "20251217", "ts_code": "518880.SH", "vol": 4520835.92}, {"amount": 4805902.569, "change": -0.111, "close": 9.24, "high": 9.296, "low": 9.199, "open": 9.272, "pct_chg": -1.187, "pre_close": 9.351, "trade_date": "20251216", "ts_code": "518880.SH", "vol": 5196267.08}, {"amount": 5297510.203, "change": 0.124, "close": 9.351, "high": 9.36, "low": 9.297, "open": 9.298, "pct_chg": 1.3439, "pre_close": 9.227, "trade_date": "20251215", "ts_code": "518880.SH", "vol": 5679028.46}, {"amount": 4388186.109, "change": 0.117, "close": 9.227, "high": 9.23, "low": 9.191, "open": 9.201, "pct_chg": 1.2843, "pre_close": 9.11, "trade_date": "20251212", "ts_code": "518880.SH", "vol": 4766188.09}, {"amount": 4260705.305, "change": 0.015, "close": 9.11, "high": 9.165, "low": 9.095, "open": 9.157, "pct_chg": 0.1649, "pre_close": 9.095, "trade_date": "20251211", "ts_code": "518880.SH", "vol": 4666349.93}, {"amount": 3111335.234, "change": 0.049, "close": 9.095, "high": 9.118, "low": 9.081, "open": 9.114, "pct_chg": 0.5417, "pre_close": 9.046, "trade_date": "20251210", "ts_code": "518880.SH", "vol": 3418584.28}, {"amount": 4322621.349, "change": -0.064, "close": 9.046, "high": 9.082, "low": 9.026, "open": 9.075, "pct_chg": -0.7025, "pre_close": 9.11, "trade_date": "20251209", "ts_code": "518880.SH", "vol": 4772816.32}, {"amount": 3929192.887, "change": -0.026, "close": 9.11, "high": 9.119, "low": 9.078, "open": 9.107, "pct_chg": -0.2846, "pre_close": 9.136, "trade_date": "20251208", "ts_code": "518880.SH", "vol": 4317056.23}, {"amount": 3791528.369, "change": 0.072, "close": 9.136, "high": 9.148, "low": 9.084, "open": 9.094, "pct_chg": 0.7944, "pre_close": 9.064, "trade_date": "20251205", "ts_code": "518880.SH", "vol": 4159658.11}, {"amount": 5070761.141, "change": -0.032, "close": 9.064, "high": 9.125, "low": 9.035, "open": 9.107, "pct_chg": -0.3518, "pre_close": 9.096, "trade_date": "20251204", "ts_code": "518880.SH", "vol": 5584621.41}, {"amount": 4198884.82, "change": -0.016, "close": 9.096, "high": 9.143, "low": 9.084, "open": 9.118, "pct_chg": -0.1756, "pre_close": 9.112, "trade_date": "20251203", "ts_code": "518880.SH", "vol": 4607233.35}, {"amount": 4083289.907, "change": -0.049, "close": 9.112, "high": 9.145, "low": 9.088, "open": 9.118, "pct_chg": -0.5349, "pre_close": 9.161, "trade_date": "20251202", "ts_code": "518880.SH", "vol": 4475178.19}, {"amount": 6708010.683, "change": 0.094, "close": 9.161, "high": 9.204, "low": 9.128, "open": 9.146, "pct_chg": 1.0367, "pre_close": 9.067, "trade_date": "20251201", "ts_code": "518880.SH", "vol": 7318583.99}, {"amount": 3603374.123, "change": 0.057, "close": 9.067, "high": 9.09, "low": 9.051, "open": 9.084, "pct_chg": 0.6326, "pre_close": 9.01, "trade_date": "20251128", "ts_code": "518880.SH", "vol": 3972928.13}, {"amount": 3427665.091, "change": -0.001, "close": 9.01, "high": 9.017, "low": 8.986, "open": 9.003, "pct_chg": -0.0111, "pre_close": 9.011, "trade_date": "20251127", "ts_code": "518880.SH", "vol": 3807152.68}, {"amount": 4596141.094, "change": 0.001, "close": 9.011, "high": 9.048, "low": 9.003, "open": 9.015, "pct_chg": 0.0111, "pre_close": 9.01, "trade_date": "20251126", "ts_code": "518880.SH", "vol": 5090521.24}, {"amount": 5789627.417, "change": 0.153, "close": 9.01, "high": 9.039, "low": 8.99, "open": 8.99, "pct_chg": 1.7274, "pre_close": 8.857, "trade_date": "20251125", "ts_code": "518880.SH", "vol": 6423142.63}, {"amount": 4141386.255, "change": 0.029, "close": 8.857, "high": 8.865, "low": 8.829, "open": 8.858, "pct_chg": 0.3285, "pre_close": 8.828, "trade_date": "20251124", "ts_code": "518880.SH", "vol": 4681449}, {"amount": 6071459.2, "change": -0.075, "close": 8.828, "high": 8.921, "low": 8.824, "open": 8.888, "pct_chg": -0.8424, "pre_close": 8.903, "trade_date": "20251121", "ts_code": "518880.SH", "vol": 6842939.9}, {"amount": 5649454.182, "change": -0.051, "close": 8.903, "high": 8.969, "low": 8.865, "open": 8.959, "pct_chg": -0.5696, "pre_close": 8.954, "trade_date": "20251120", "ts_code": "518880.SH", "vol": 6337762.32}, {"amount": 5392419.257, "change": 0.177, "close": 8.954, "high": 8.968, "low": 8.873, "open": 8.89, "pct_chg": 2.0166, "pre_close": 8.777, "trade_date": "20251119", "ts_code": "518880.SH", "vol": 6041360.17}, {"amount": 5133558.167, "change": -0.105, "close": 8.777, "high": 8.837, "low": 8.77, "open": 8.837, "pct_chg": -1.1822, "pre_close": 8.882, "trade_date": "20251118", "ts_code": "518880.SH", "vol": 5836812.81}, {"amount": 7238287.561, "change": -0.214, "close": 8.882, "high": 8.969, "low": 8.842, "open": 8.922, "pct_chg": -2.3527, "pre_close": 9.096, "trade_date": "20251117", "ts_code": "518880.SH", "vol": 8126693.65}, {"amount": 6540777.396, "change": -0.079, "close": 9.096, "high": 9.18, "low": 9.09, "open": 9.099, "pct_chg": -0.861, "pre_close": 9.175, "trade_date": "20251114", "ts_code": "518880.SH", "vol": 7161875.19}, {"amount": 5968013.292, "change": 0.146, "close": 9.175, "high": 9.184, "low": 9.134, "open": 9.157, "pct_chg": 1.617, "pre_close": 9.029, "trade_date": "20251113", "ts_code": "518880.SH", "vol": 6514007.98}, {"amount": 5948310.393, "change": -0.033, "close": 9.029, "high": 9.068, "low": 8.984, "open": 9.058, "pct_chg": -0.3642, "pre_close": 9.062, "trade_date": "20251112", "ts_code": "518880.SH", "vol": 6587183.82}, {"amount": 7518834.24, "change": 0.126, "close": 9.062, "high": 9.138, "low": 9.049, "open": 9.138, "pct_chg": 1.41, "pre_close": 8.936, "trade_date": "20251111", "ts_code": "518880.SH", "vol": 8281065.85}, {"amount": 6751619.483, "change": 0.143, "close": 8.936, "high": 8.949, "low": 8.856, "open": 8.869, "pct_chg": 1.6263, "pre_close": 8.793, "trade_date": "20251110", "ts_code": "518880.SH", "vol": 7585074.94}, {"amount": 3747892.162, "change": 0.038, "close": 8.793, "high": 8.798, "low": 8.755, "open": 8.769, "pct_chg": 0.434, "pre_close": 8.755, "trade_date": "20251107", "ts_code": "518880.SH", "vol": 4272647.48}, {"amount": 3576860.604, "change": 0.049, "close": 8.755, "high": 8.757, "low": 8.706, "open": 8.718, "pct_chg": 0.5628, "pre_close": 8.706, "trade_date": "20251106", "ts_code": "518880.SH", "vol": 4094435.62}, {"amount": 4136619.619, "change": -0.031, "close": 8.706, "high": 8.732, "low": 8.641, "open": 8.654, "pct_chg": -0.3548, "pre_close": 8.737, "trade_date": "20251105", "ts_code": "518880.SH", "vol": 4760923.29}, {"amount": 4638166.862, "change": -0.06, "close": 8.737, "high": 8.756, "low": 8.705, "open": 8.745, "pct_chg": -0.6821, "pre_close": 8.797, "trade_date": "20251104", "ts_code": "518880.SH", "vol": 5311229.27}, {"amount": 4085511.859, "change": 0.003, "close": 8.797, "high": 8.801, "low": 8.737, "open": 8.743, "pct_chg": 0.0341, "pre_close": 8.794, "trade_date": "20251103", "ts_code": "518880.SH", "vol": 4658113.03}, {"amount": 6782774.235, "change": 0.09, "close": 8.794, "high": 8.822, "low": 8.748, "open": 8.81, "pct_chg": 1.034, "pre_close": 8.704, "trade_date": "20251031", "ts_code": "518880.SH", "vol": 7719496.36}, {"amount": 8858790.469, "change": 0.012, "close": 8.704, "high": 8.706, "low": 8.559, "open": 8.635, "pct_chg": 0.1381, "pre_close": 8.692, "trade_date": "20251030", "ts_code": "518880.SH", "vol": 10255273.42}, {"amount": 6929137.801, "change": 0.095, "close": 8.692, "high": 8.699, "low": 8.622, "open": 8.68, "pct_chg": 1.105, "pre_close": 8.597, "trade_date": "20251029", "ts_code": "518880.SH", "vol": 7999573.87}, {"amount": 9433291.725, "change": -0.317, "close": 8.597, "high": 8.79, "low": 8.591, "open": 8.79, "pct_chg": -3.5562, "pre_close": 8.914, "trade_date": "20251028", "ts_code": "518880.SH", "vol": 10854757.13}, {"amount": 6024338.869, "change": -0.036, "close": 8.914, "high": 8.949, "low": 8.897, "open": 8.948, "pct_chg": -0.4022, "pre_close": 8.95, "trade_date": "20251027", "ts_code": "518880.SH", "vol": 6754374.97}, {"amount": 6652958.689, "change": -0.044, "close": 8.95, "high": 9.053, "low": 8.933, "open": 9.048, "pct_chg": -0.4892, "pre_close": 8.994, "trade_date": "20251024", "ts_code": "518880.SH", "vol": 7400820.55}, {"amount": 8366775.476, "change": -0.107, "close": 8.994, "high": 9.052, "low": 8.92, "open": 8.98, "pct_chg": -1.1757, "pre_close": 9.101, "trade_date": "20251023", "ts_code": "518880.SH", "vol": 9320664.67}, {"amount": 12694570.824, "change": -0.389, "close": 9.101, "high": 9.106, "low": 8.95, "open": 8.986, "pct_chg": -4.0991, "pre_close": 9.49, "trade_date": "20251022", "ts_code": "518880.SH", "vol": 14076692.27}, {"amount": 9473507.963, "change": 0.222, "close": 9.49, "high": 9.526, "low": 9.425, "open": 9.526, "pct_chg": 2.3953, "pre_close": 9.268, "trade_date": "20251021", "ts_code": "518880.SH", "vol": 9995217.8}, {"amount": 11239083.883, "change": -0.3, "close": 9.268, "high": 9.367, "low": 9.25, "open": 9.308, "pct_chg": -3.1355, "pre_close": 9.568, "trade_date": "20251020", "ts_code": "518880.SH", "vol": 12056984.99}, {"amount": 15083487.665, "change": 0.324, "close": 9.568, "high": 9.587, "low": 9.361, "open": 9.497, "pct_chg": 3.505, "pre_close": 9.244, "trade_date": "20251017", "ts_code": "518880.SH", "vol": 15863425.5}, {"amount": 9625793.165, "change": 0.057, "close": 9.244, "high": 9.288, "low": 9.198, "open": 9.23, "pct_chg": 0.6204, "pre_close": 9.187, "trade_date": "20251016", "ts_code": "518880.SH", "vol": 10409158.76}, {"amount": 8404693.734, "change": 0.211, "close": 9.187, "high": 9.19, "low": 9.09, "open": 9.143, "pct_chg": 2.3507, "pre_close": 8.976, "trade_date": "20251015", "ts_code": "518880.SH", "vol": 9193141.19}, {"amount": 15094002.873, "change": 0.117, "close": 8.976, "high": 9.377, "low": 8.878, "open": 9.1, "pct_chg": 1.3207, "pre_close": 8.859, "trade_date": "20251014", "ts_code": "518880.SH", "vol": 16679605.06}, {"amount": 6826004.392, "change": 0.257, "close": 8.859, "high": 8.868, "low": 8.77, "open": 8.77, "pct_chg": 2.9877, "pre_close": 8.602, "trade_date": "20251013", "ts_code": "518880.SH", "vol": 7739480.22}, {"amount": 5528015.329, "change": -0.127, "close": 8.602, "high": 8.651, "low": 8.586, "open": 8.637, "pct_chg": -1.4549, "pre_close": 8.729, "trade_date": "20251010", "ts_code": "518880.SH", "vol": 6414709.35}, {"amount": 6996319.98, "change": 0.39, "close": 8.729, "high": 8.779, "low": 8.69, "open": 8.724, "pct_chg": 4.6768, "pre_close": 8.339, "trade_date": "20251009", "ts_code": "518880.SH", "vol": 8014424.72}, {"amount": 5576070.203, "change": 0.08, "close": 8.339, "high": 8.388, "low": 8.324, "open": 8.324, "pct_chg": 0.9686, "pre_close": 8.259, "trade_date": "20250930", "ts_code": "518880.SH", "vol": 6669568.76}, {"amount": 4380687.035, "change": 0.099, "close": 8.259, "high": 8.268, "low": 8.174, "open": 8.177, "pct_chg": 1.2132, "pre_close": 8.16, "trade_date": "20250929", "ts_code": "518880.SH", "vol": 5329506.67}, {"amount": 3138261.886, "change": 0.014, "close": 8.16, "high": 8.183, "low": 8.147, "open": 8.15, "pct_chg": 0.1719, "pre_close": 8.146, "trade_date": "20250926", "ts_code": "518880.SH", "vol": 3845481}, {"amount": 3614722.212, "change": -0.044, "close": 8.146, "high": 8.164, "low": 8.116, "open": 8.16, "pct_chg": -0.5372, "pre_close": 8.19, "trade_date": "20250925", "ts_code": "518880.SH", "vol": 4441117.04}, {"amount": 3591418.429, "change": 0.047, "close": 8.19, "high": 8.204, "low": 8.123, "open": 8.141, "pct_chg": 0.5772, "pre_close": 8.143, "trade_date": "20250924", "ts_code": "518880.SH", "vol": 4399010.8}, {"amount": 4158136.063, "change": 0.095, "close": 8.143, "high": 8.149, "low": 8.105, "open": 8.125, "pct_chg": 1.1804, "pre_close": 8.048, "trade_date": "20250923", "ts_code": "518880.SH", "vol": 5115503.7}, {"amount": 3632554.944, "change": 0.15, "close": 8.048, "high": 8.058, "low": 7.984, "open": 8.005, "pct_chg": 1.8992, "pre_close": 7.898, "trade_date": "20250922", "ts_code": "518880.SH", "vol": 4532519.35}, {"amount": 2556742.578, "change": 0.036, "close": 7.898, "high": 7.917, "low": 7.869, "open": 7.901, "pct_chg": 0.4579, "pre_close": 7.862, "trade_date": "20250919", "ts_code": "518880.SH", "vol": 3237996.19}] \ No newline at end of file diff --git a/labs/analysis/etf/cache/588000.SH.json b/labs/analysis/etf/cache/588000.SH.json new file mode 100644 index 0000000..078a7dc --- /dev/null +++ b/labs/analysis/etf/cache/588000.SH.json @@ -0,0 +1 @@ +[{"amount": 8020686.565, "change": 0.048, "close": 1.744, "high": 1.764, "low": 1.72, "open": 1.721, "pct_chg": 2.83, "pre_close": 1.696, "trade_date": "20260918", "ts_code": "588000.SH", "vol": 46069653.36}, {"amount": 5590146.314, "change": -0.012, "close": 1.696, "high": 1.722, "low": 1.687, "open": 1.698, "pct_chg": -0.7, "pre_close": 1.708, "trade_date": "20260917", "ts_code": "588000.SH", "vol": 32804810.36}, {"amount": 8039920.184, "change": 0.07, "close": 1.708, "high": 1.712, "low": 1.638, "open": 1.638, "pct_chg": 4.27, "pre_close": 1.638, "trade_date": "20260916", "ts_code": "588000.SH", "vol": 47766717.11}, {"amount": 6044013.886, "change": 0.025, "close": 1.638, "high": 1.666, "low": 1.608, "open": 1.608, "pct_chg": 1.55, "pre_close": 1.613, "trade_date": "20260915", "ts_code": "588000.SH", "vol": 36841169.22}, {"amount": 4663730.402, "change": -0.026, "close": 1.613, "high": 1.629, "low": 1.603, "open": 1.613, "pct_chg": -1.59, "pre_close": 1.639, "trade_date": "20260914", "ts_code": "588000.SH", "vol": 28895186.46}, {"amount": 8083414.412, "change": -0.02, "close": 1.639, "high": 1.645, "low": 1.601, "open": 1.641, "pct_chg": -1.21, "pre_close": 1.659, "trade_date": "20260911", "ts_code": "588000.SH", "vol": 49762772}, {"amount": 3674623.56, "change": -0.012, "close": 1.659, "high": 1.678, "low": 1.651, "open": 1.656, "pct_chg": -0.72, "pre_close": 1.671, "trade_date": "20260910", "ts_code": "588000.SH", "vol": 22119177.1}, {"amount": 3919391.23, "change": -0.01, "close": 1.671, "high": 1.699, "low": 1.661, "open": 1.69, "pct_chg": -0.6, "pre_close": 1.681, "trade_date": "20260909", "ts_code": "588000.SH", "vol": 23363402.39}, {"amount": 5020493.79, "change": -0.024, "close": 1.681, "high": 1.716, "low": 1.674, "open": 1.701, "pct_chg": -1.41, "pre_close": 1.705, "trade_date": "20260908", "ts_code": "588000.SH", "vol": 29660050.06}, {"amount": 6279827.625, "change": 0.037, "close": 1.705, "high": 1.716, "low": 1.668, "open": 1.683, "pct_chg": 2.22, "pre_close": 1.668, "trade_date": "20260907", "ts_code": "588000.SH", "vol": 37101637.19}, {"amount": 7409903.24, "change": -0.035, "close": 1.668, "high": 1.728, "low": 1.655, "open": 1.716, "pct_chg": -2.06, "pre_close": 1.703, "trade_date": "20260904", "ts_code": "588000.SH", "vol": 43886303.47}, {"amount": 4700623.804, "change": -0.005, "close": 1.703, "high": 1.727, "low": 1.686, "open": 1.723, "pct_chg": -0.29, "pre_close": 1.708, "trade_date": "20260903", "ts_code": "588000.SH", "vol": 27561006.31}, {"amount": 5019801.49, "change": -0.032, "close": 1.708, "high": 1.728, "low": 1.698, "open": 1.714, "pct_chg": -1.84, "pre_close": 1.74, "trade_date": "20260902", "ts_code": "588000.SH", "vol": 29336445.93}, {"amount": 4368477.798, "change": -0.037, "close": 1.74, "high": 1.777, "low": 1.739, "open": 1.771, "pct_chg": -2.08, "pre_close": 1.777, "trade_date": "20260901", "ts_code": "588000.SH", "vol": 24961742.76}, {"amount": 5161529.652, "change": 0.021, "close": 1.777, "high": 1.778, "low": 1.711, "open": 1.721, "pct_chg": 1.2, "pre_close": 1.756, "trade_date": "20260831", "ts_code": "588000.SH", "vol": 29708457.36}, {"amount": 4903064.585, "change": -0.032, "close": 1.756, "high": 1.807, "low": 1.754, "open": 1.781, "pct_chg": -1.79, "pre_close": 1.788, "trade_date": "20260828", "ts_code": "588000.SH", "vol": 27537424.25}, {"amount": 6187797.027, "change": 0.067, "close": 1.788, "high": 1.791, "low": 1.727, "open": 1.73, "pct_chg": 3.89, "pre_close": 1.721, "trade_date": "20260827", "ts_code": "588000.SH", "vol": 35076920.48}, {"amount": 5888654.23, "change": 0.028, "close": 1.721, "high": 1.736, "low": 1.687, "open": 1.694, "pct_chg": 1.65, "pre_close": 1.693, "trade_date": "20260826", "ts_code": "588000.SH", "vol": 34348621.47}, {"amount": 5720940.991, "change": 0.002, "close": 1.693, "high": 1.714, "low": 1.651, "open": 1.666, "pct_chg": 0.12, "pre_close": 1.691, "trade_date": "20260825", "ts_code": "588000.SH", "vol": 33963236.02}, {"amount": 6962746.43, "change": -0.054, "close": 1.691, "high": 1.743, "low": 1.662, "open": 1.741, "pct_chg": -3.1, "pre_close": 1.745, "trade_date": "20260824", "ts_code": "588000.SH", "vol": 41050773.86}, {"amount": 4652314.628, "change": 0.001, "close": 1.745, "high": 1.768, "low": 1.731, "open": 1.739, "pct_chg": 0.06, "pre_close": 1.744, "trade_date": "20260821", "ts_code": "588000.SH", "vol": 26624799.16}, {"amount": 6513781.37, "change": -0.017, "close": 1.744, "high": 1.788, "low": 1.73, "open": 1.779, "pct_chg": -0.97, "pre_close": 1.761, "trade_date": "20260820", "ts_code": "588000.SH", "vol": 37068602.85}, {"amount": 10889020.913, "change": -0.127, "close": 1.761, "high": 1.846, "low": 1.746, "open": 1.845, "pct_chg": -6.73, "pre_close": 1.888, "trade_date": "20260819", "ts_code": "588000.SH", "vol": 60778573.62}, {"amount": 6062875.218, "change": 0, "close": 1.888, "high": 1.899, "low": 1.852, "open": 1.886, "pct_chg": 0, "pre_close": 1.888, "trade_date": "20260818", "ts_code": "588000.SH", "vol": 32297188.92}, {"amount": 7845391.687, "change": 0.074, "close": 1.888, "high": 1.888, "low": 1.811, "open": 1.812, "pct_chg": 4.08, "pre_close": 1.814, "trade_date": "20260817", "ts_code": "588000.SH", "vol": 42269337.06}, {"amount": 5699917.679, "change": 0.002, "close": 1.814, "high": 1.837, "low": 1.785, "open": 1.832, "pct_chg": 0.11, "pre_close": 1.812, "trade_date": "20260814", "ts_code": "588000.SH", "vol": 31499190.9}, {"amount": 7483452.007, "change": -0.021, "close": 1.812, "high": 1.881, "low": 1.809, "open": 1.856, "pct_chg": -1.15, "pre_close": 1.833, "trade_date": "20260813", "ts_code": "588000.SH", "vol": 40287224.35}, {"amount": 5285689.889, "change": 0.027, "close": 1.833, "high": 1.844, "low": 1.803, "open": 1.811, "pct_chg": 1.5, "pre_close": 1.806, "trade_date": "20260812", "ts_code": "588000.SH", "vol": 28899075.64}, {"amount": 5843747.662, "change": -0.029, "close": 1.806, "high": 1.85, "low": 1.795, "open": 1.816, "pct_chg": -1.58, "pre_close": 1.835, "trade_date": "20260811", "ts_code": "588000.SH", "vol": 32083767.5}, {"amount": 6255060.479, "change": -0.005, "close": 1.835, "high": 1.851, "low": 1.799, "open": 1.84, "pct_chg": -0.27, "pre_close": 1.84, "trade_date": "20260810", "ts_code": "588000.SH", "vol": 34364398.65}, {"amount": 7740234.903, "change": 0.046, "close": 1.84, "high": 1.844, "low": 1.78, "open": 1.788, "pct_chg": 2.56, "pre_close": 1.794, "trade_date": "20260807", "ts_code": "588000.SH", "vol": 42528942.52}, {"amount": 7615962.926, "change": 0.008, "close": 1.794, "high": 1.822, "low": 1.741, "open": 1.75, "pct_chg": 0.45, "pre_close": 1.786, "trade_date": "20260806", "ts_code": "588000.SH", "vol": 42709307.32}, {"amount": 11405234.156, "change": 0.08, "close": 1.786, "high": 1.809, "low": 1.7, "open": 1.706, "pct_chg": 4.69, "pre_close": 1.706, "trade_date": "20260805", "ts_code": "588000.SH", "vol": 64532762.82}, {"amount": 10513818.196, "change": 0.07, "close": 1.706, "high": 1.72, "low": 1.642, "open": 1.649, "pct_chg": 4.28, "pre_close": 1.636, "trade_date": "20260804", "ts_code": "588000.SH", "vol": 62410029.39}, {"amount": 10758159.109, "change": -0.092, "close": 1.636, "high": 1.71, "low": 1.635, "open": 1.698, "pct_chg": -5.32, "pre_close": 1.728, "trade_date": "20260803", "ts_code": "588000.SH", "vol": 64762724.35}, {"amount": 15471588.925, "change": 0.059, "close": 1.728, "high": 1.827, "low": 1.724, "open": 1.789, "pct_chg": 3.54, "pre_close": 1.669, "trade_date": "20260731", "ts_code": "588000.SH", "vol": 86830349.23}, {"amount": 15258172.643, "change": -0.107, "close": 1.669, "high": 1.766, "low": 1.647, "open": 1.743, "pct_chg": -6.03, "pre_close": 1.776, "trade_date": "20260730", "ts_code": "588000.SH", "vol": 89808325.61}, {"amount": 12958762.521, "change": -0.009, "close": 1.776, "high": 1.807, "low": 1.698, "open": 1.782, "pct_chg": -0.5, "pre_close": 1.785, "trade_date": "20260729", "ts_code": "588000.SH", "vol": 74262254.26}, {"amount": 10958751.221, "change": -0.126, "close": 1.785, "high": 1.896, "low": 1.768, "open": 1.86, "pct_chg": -6.59, "pre_close": 1.911, "trade_date": "20260728", "ts_code": "588000.SH", "vol": 59997677.84}, {"amount": 7625768.835, "change": 0.026, "close": 1.911, "high": 1.918, "low": 1.819, "open": 1.875, "pct_chg": 1.38, "pre_close": 1.885, "trade_date": "20260727", "ts_code": "588000.SH", "vol": 40655257.82}, {"amount": 8981897.884, "change": -0.003, "close": 1.885, "high": 1.931, "low": 1.85, "open": 1.851, "pct_chg": -0.16, "pre_close": 1.888, "trade_date": "20260724", "ts_code": "588000.SH", "vol": 47465142.09}, {"amount": 10026719.748, "change": -0.067, "close": 1.888, "high": 1.979, "low": 1.867, "open": 1.97, "pct_chg": -3.43, "pre_close": 1.955, "trade_date": "20260723", "ts_code": "588000.SH", "vol": 52574640.95}, {"amount": 13736182.064, "change": -0.061, "close": 1.955, "high": 2.038, "low": 1.935, "open": 1.963, "pct_chg": -3.03, "pre_close": 2.016, "trade_date": "20260722", "ts_code": "588000.SH", "vol": 69062303.22}, {"amount": 17322012.289, "change": 0.201, "close": 2.016, "high": 2.017, "low": 1.755, "open": 1.832, "pct_chg": 11.07, "pre_close": 1.815, "trade_date": "20260721", "ts_code": "588000.SH", "vol": 92429146.92}, {"amount": 23452637.63, "change": 0.008, "close": 1.815, "high": 1.873, "low": 1.737, "open": 1.851, "pct_chg": 0.44, "pre_close": 1.807, "trade_date": "20260720", "ts_code": "588000.SH", "vol": 130587362.97}, {"amount": 14287477.011, "change": -0.138, "close": 1.807, "high": 1.945, "low": 1.791, "open": 1.922, "pct_chg": -7.1, "pre_close": 1.945, "trade_date": "20260717", "ts_code": "588000.SH", "vol": 76598841.07}, {"amount": 9033153.321, "change": -0.083, "close": 1.945, "high": 2.054, "low": 1.928, "open": 1.977, "pct_chg": -4.09, "pre_close": 2.028, "trade_date": "20260716", "ts_code": "588000.SH", "vol": 45474999.03}, {"amount": 9061939.217, "change": -0.097, "close": 2.028, "high": 2.141, "low": 2.011, "open": 2.133, "pct_chg": -4.57, "pre_close": 2.125, "trade_date": "20260715", "ts_code": "588000.SH", "vol": 43999239.74}, {"amount": 9512576.607, "change": 0.025, "close": 2.125, "high": 2.136, "low": 1.998, "open": 2.094, "pct_chg": 1.19, "pre_close": 2.1, "trade_date": "20260714", "ts_code": "588000.SH", "vol": 45748161.18}, {"amount": 9118631.347, "change": -0.109, "close": 2.1, "high": 2.226, "low": 2.081, "open": 2.16, "pct_chg": -4.93, "pre_close": 2.209, "trade_date": "20260713", "ts_code": "588000.SH", "vol": 42418236.27}, {"amount": 10899194.362, "change": -0.119, "close": 2.209, "high": 2.386, "low": 2.204, "open": 2.334, "pct_chg": -5.11, "pre_close": 2.328, "trade_date": "20260710", "ts_code": "588000.SH", "vol": 47029319}, {"amount": 10154782.886, "change": 0.183, "close": 2.328, "high": 2.333, "low": 2.15, "open": 2.166, "pct_chg": 8.53, "pre_close": 2.145, "trade_date": "20260709", "ts_code": "588000.SH", "vol": 45434459.65}, {"amount": 7689417.203, "change": 0.019, "close": 2.145, "high": 2.208, "low": 2.083, "open": 2.142, "pct_chg": 0.89, "pre_close": 2.126, "trade_date": "20260708", "ts_code": "588000.SH", "vol": 35749751.91}, {"amount": 7077642.454, "change": 0.01, "close": 2.126, "high": 2.162, "low": 2.073, "open": 2.08, "pct_chg": 0.47, "pre_close": 2.116, "trade_date": "20260707", "ts_code": "588000.SH", "vol": 33207940.47}, {"amount": 7576402.941, "change": 0.014, "close": 2.116, "high": 2.148, "low": 2.031, "open": 2.133, "pct_chg": 0.67, "pre_close": 2.102, "trade_date": "20260706", "ts_code": "588000.SH", "vol": 36035968.19}, {"amount": 7548907.657, "change": -0.017, "close": 2.102, "high": 2.165, "low": 2.068, "open": 2.089, "pct_chg": -0.8, "pre_close": 2.119, "trade_date": "20260703", "ts_code": "588000.SH", "vol": 35733779.23}, {"amount": 10527602.171, "change": -0.171, "close": 2.119, "high": 2.24, "low": 2.1, "open": 2.22, "pct_chg": -7.47, "pre_close": 2.29, "trade_date": "20260702", "ts_code": "588000.SH", "vol": 48508910.6}, {"amount": 9359368.197, "change": -0.054, "close": 2.29, "high": 2.39, "low": 2.252, "open": 2.346, "pct_chg": -2.3, "pre_close": 2.344, "trade_date": "20260701", "ts_code": "588000.SH", "vol": 40231507.44}, {"amount": 7100428.84, "change": 0.096, "close": 2.344, "high": 2.349, "low": 2.228, "open": 2.257, "pct_chg": 4.27, "pre_close": 2.248, "trade_date": "20260630", "ts_code": "588000.SH", "vol": 30844927.27}, {"amount": 8245833.174, "change": 0.115, "close": 2.248, "high": 2.25, "low": 2.124, "open": 2.143, "pct_chg": 5.39, "pre_close": 2.133, "trade_date": "20260629", "ts_code": "588000.SH", "vol": 37600948.34}, {"amount": 8667140.757, "change": -0.045, "close": 2.133, "high": 2.188, "low": 2.084, "open": 2.142, "pct_chg": -2.07, "pre_close": 2.178, "trade_date": "20260626", "ts_code": "588000.SH", "vol": 40480199.31}, {"amount": 7891953.305, "change": 0.082, "close": 2.178, "high": 2.189, "low": 2.106, "open": 2.114, "pct_chg": 3.91, "pre_close": 2.096, "trade_date": "20260625", "ts_code": "588000.SH", "vol": 36633531.47}, {"amount": 8748955.895, "change": 0.073, "close": 2.096, "high": 2.105, "low": 1.987, "open": 2, "pct_chg": 3.61, "pre_close": 2.023, "trade_date": "20260624", "ts_code": "588000.SH", "vol": 42445906.21}, {"amount": 7418469.842, "change": -0.033, "close": 2.023, "high": 2.09, "low": 1.992, "open": 2.04, "pct_chg": -1.61, "pre_close": 2.056, "trade_date": "20260623", "ts_code": "588000.SH", "vol": 36431581.2}, {"amount": 7321099.002, "change": 0.039, "close": 2.056, "high": 2.07, "low": 1.981, "open": 2.026, "pct_chg": 1.93, "pre_close": 2.017, "trade_date": "20260622", "ts_code": "588000.SH", "vol": 36095737.54}, {"amount": 8160146.209, "change": 0.078, "close": 2.017, "high": 2.042, "low": 1.932, "open": 1.935, "pct_chg": 4.02, "pre_close": 1.939, "trade_date": "20260618", "ts_code": "588000.SH", "vol": 40816717.19}, {"amount": 6547205.597, "change": 0.084, "close": 1.939, "high": 1.94, "low": 1.819, "open": 1.828, "pct_chg": 4.53, "pre_close": 1.855, "trade_date": "20260617", "ts_code": "588000.SH", "vol": 34743069.97}, {"amount": 4693669.478, "change": 0.011, "close": 1.855, "high": 1.863, "low": 1.823, "open": 1.845, "pct_chg": 0.6, "pre_close": 1.844, "trade_date": "20260616", "ts_code": "588000.SH", "vol": 25442445.8}, {"amount": 5441091.921, "change": 0.088, "close": 1.844, "high": 1.845, "low": 1.75, "open": 1.782, "pct_chg": 5.01, "pre_close": 1.756, "trade_date": "20260615", "ts_code": "588000.SH", "vol": 30014793.96}, {"amount": 5855055.733, "change": 0, "close": 1.756, "high": 1.828, "low": 1.751, "open": 1.825, "pct_chg": 0, "pre_close": 1.756, "trade_date": "20260612", "ts_code": "588000.SH", "vol": 32648313.24}, {"amount": 4645134.518, "change": 0.012, "close": 1.756, "high": 1.771, "low": 1.723, "open": 1.727, "pct_chg": 0.69, "pre_close": 1.744, "trade_date": "20260611", "ts_code": "588000.SH", "vol": 26617707.21}, {"amount": 5864140.526, "change": -0.011, "close": 1.744, "high": 1.818, "low": 1.724, "open": 1.748, "pct_chg": -0.63, "pre_close": 1.755, "trade_date": "20260610", "ts_code": "588000.SH", "vol": 33172349.66}, {"amount": 4978683.139, "change": 0.072, "close": 1.755, "high": 1.76, "low": 1.696, "open": 1.721, "pct_chg": 4.28, "pre_close": 1.683, "trade_date": "20260609", "ts_code": "588000.SH", "vol": 28793746.2}, {"amount": 6578187.379, "change": -0.08, "close": 1.683, "high": 1.725, "low": 1.665, "open": 1.69, "pct_chg": -4.54, "pre_close": 1.763, "trade_date": "20260608", "ts_code": "588000.SH", "vol": 38792519.46}, {"amount": 5429864.19, "change": -0.069, "close": 1.763, "high": 1.829, "low": 1.75, "open": 1.801, "pct_chg": -3.77, "pre_close": 1.832, "trade_date": "20260605", "ts_code": "588000.SH", "vol": 30367655.22}, {"amount": 4188280.582, "change": 0.009, "close": 1.832, "high": 1.856, "low": 1.789, "open": 1.794, "pct_chg": 0.49, "pre_close": 1.823, "trade_date": "20260604", "ts_code": "588000.SH", "vol": 22853730.33}, {"amount": 7513993.877, "change": 0.042, "close": 1.823, "high": 1.872, "low": 1.784, "open": 1.788, "pct_chg": 2.36, "pre_close": 1.781, "trade_date": "20260603", "ts_code": "588000.SH", "vol": 41060877.27}, {"amount": 6296506.062, "change": 0.027, "close": 1.781, "high": 1.803, "low": 1.728, "open": 1.756, "pct_chg": 1.54, "pre_close": 1.754, "trade_date": "20260602", "ts_code": "588000.SH", "vol": 35626926.83}, {"amount": 7517959.824, "change": -0.09, "close": 1.754, "high": 1.85, "low": 1.751, "open": 1.841, "pct_chg": -4.88, "pre_close": 1.844, "trade_date": "20260601", "ts_code": "588000.SH", "vol": 41950164.94}, {"amount": 8654625.508, "change": -0.098, "close": 1.844, "high": 1.952, "low": 1.82, "open": 1.944, "pct_chg": -5.05, "pre_close": 1.942, "trade_date": "20260529", "ts_code": "588000.SH", "vol": 46204309.1}, {"amount": 7480500.717, "change": 0.031, "close": 1.942, "high": 1.954, "low": 1.891, "open": 1.893, "pct_chg": 1.62, "pre_close": 1.911, "trade_date": "20260528", "ts_code": "588000.SH", "vol": 38830390.52}, {"amount": 7471395.274, "change": -0.057, "close": 1.911, "high": 2.003, "low": 1.905, "open": 1.972, "pct_chg": -2.8963, "pre_close": 1.968, "trade_date": "20260527", "ts_code": "588000.SH", "vol": 38203455.48}, {"amount": 7266807.369, "change": -0.03, "close": 1.968, "high": 1.983, "low": 1.922, "open": 1.983, "pct_chg": -1.5015, "pre_close": 1.998, "trade_date": "20260526", "ts_code": "588000.SH", "vol": 37234694.06}, {"amount": 8450888.57, "change": 0.111, "close": 1.998, "high": 2.002, "low": 1.867, "open": 1.887, "pct_chg": 5.8824, "pre_close": 1.887, "trade_date": "20260525", "ts_code": "588000.SH", "vol": 43284248.55}, {"amount": 5967880.687, "change": 0.027, "close": 1.887, "high": 1.899, "low": 1.84, "open": 1.873, "pct_chg": 1.4516, "pre_close": 1.86, "trade_date": "20260522", "ts_code": "588000.SH", "vol": 31913482.81}, {"amount": 8475584.622, "change": -0.071, "close": 1.86, "high": 1.994, "low": 1.853, "open": 1.967, "pct_chg": -3.6769, "pre_close": 1.931, "trade_date": "20260521", "ts_code": "588000.SH", "vol": 43811241.78}, {"amount": 7542789.897, "change": 0.06, "close": 1.931, "high": 1.933, "low": 1.856, "open": 1.857, "pct_chg": 3.2068, "pre_close": 1.871, "trade_date": "20260520", "ts_code": "588000.SH", "vol": 39558655.99}, {"amount": 6868976.901, "change": 0.068, "close": 1.871, "high": 1.872, "low": 1.759, "open": 1.787, "pct_chg": 3.7715, "pre_close": 1.803, "trade_date": "20260519", "ts_code": "588000.SH", "vol": 37991841.96}, {"amount": 5995788.657, "change": 0.016, "close": 1.803, "high": 1.842, "low": 1.77, "open": 1.77, "pct_chg": 0.8954, "pre_close": 1.787, "trade_date": "20260518", "ts_code": "588000.SH", "vol": 33138390.92}, {"amount": 7958614.084, "change": -0.028, "close": 1.787, "high": 1.865, "low": 1.758, "open": 1.807, "pct_chg": -1.5427, "pre_close": 1.815, "trade_date": "20260515", "ts_code": "588000.SH", "vol": 44180704.48}, {"amount": 6412216.624, "change": -0.05, "close": 1.815, "high": 1.898, "low": 1.814, "open": 1.884, "pct_chg": -2.681, "pre_close": 1.865, "trade_date": "20260514", "ts_code": "588000.SH", "vol": 34701713.25}, {"amount": 6212086.909, "change": 0.049, "close": 1.865, "high": 1.866, "low": 1.767, "open": 1.78, "pct_chg": 2.6982, "pre_close": 1.816, "trade_date": "20260513", "ts_code": "588000.SH", "vol": 34174506.78}, {"amount": 5240256.795, "change": 0.005, "close": 1.816, "high": 1.841, "low": 1.777, "open": 1.802, "pct_chg": 0.2761, "pre_close": 1.811, "trade_date": "20260512", "ts_code": "588000.SH", "vol": 28999405.42}, {"amount": 6647081.832, "change": 0.084, "close": 1.811, "high": 1.82, "low": 1.758, "open": 1.777, "pct_chg": 4.8639, "pre_close": 1.727, "trade_date": "20260511", "ts_code": "588000.SH", "vol": 37053343.46}, {"amount": 4788137.989, "change": -0.042, "close": 1.727, "high": 1.745, "low": 1.712, "open": 1.744, "pct_chg": -2.3742, "pre_close": 1.769, "trade_date": "20260508", "ts_code": "588000.SH", "vol": 27678010.82}, {"amount": 4125315.03, "change": 0.025, "close": 1.769, "high": 1.772, "low": 1.733, "open": 1.752, "pct_chg": 1.4335, "pre_close": 1.744, "trade_date": "20260507", "ts_code": "588000.SH", "vol": 23533728.29}, {"amount": 7667042.667, "change": 0.09, "close": 1.744, "high": 1.812, "low": 1.709, "open": 1.719, "pct_chg": 5.4414, "pre_close": 1.654, "trade_date": "20260506", "ts_code": "588000.SH", "vol": 43376900.73}, {"amount": 8401295.638, "change": 0.079, "close": 1.654, "high": 1.665, "low": 1.59, "open": 1.592, "pct_chg": 5.0159, "pre_close": 1.575, "trade_date": "20260430", "ts_code": "588000.SH", "vol": 51410542.1}, {"amount": 3572909.431, "change": 0.007, "close": 1.575, "high": 1.576, "low": 1.53, "open": 1.555, "pct_chg": 0.4464, "pre_close": 1.568, "trade_date": "20260429", "ts_code": "588000.SH", "vol": 22938788.93}, {"amount": 3669559.822, "change": -0.023, "close": 1.568, "high": 1.608, "low": 1.559, "open": 1.579, "pct_chg": -1.4456, "pre_close": 1.591, "trade_date": "20260428", "ts_code": "588000.SH", "vol": 23183885.59}, {"amount": 5210584.149, "change": 0.06, "close": 1.591, "high": 1.597, "low": 1.544, "open": 1.552, "pct_chg": 3.919, "pre_close": 1.531, "trade_date": "20260427", "ts_code": "588000.SH", "vol": 33052984.05}, {"amount": 5021637.937, "change": 0.023, "close": 1.531, "high": 1.551, "low": 1.493, "open": 1.513, "pct_chg": 1.5252, "pre_close": 1.508, "trade_date": "20260424", "ts_code": "588000.SH", "vol": 32916929.15}, {"amount": 3885256.802, "change": -0.021, "close": 1.508, "high": 1.545, "low": 1.494, "open": 1.543, "pct_chg": -1.3734, "pre_close": 1.529, "trade_date": "20260423", "ts_code": "588000.SH", "vol": 25578804.48}, {"amount": 3577416.49, "change": 0.025, "close": 1.529, "high": 1.53, "low": 1.495, "open": 1.496, "pct_chg": 1.6622, "pre_close": 1.504, "trade_date": "20260422", "ts_code": "588000.SH", "vol": 23622752.86}, {"amount": 3491812.823, "change": -0.023, "close": 1.504, "high": 1.523, "low": 1.491, "open": 1.523, "pct_chg": -1.5062, "pre_close": 1.527, "trade_date": "20260421", "ts_code": "588000.SH", "vol": 23261831.82}, {"amount": 3656358.054, "change": 0.028, "close": 1.527, "high": 1.529, "low": 1.5, "open": 1.5, "pct_chg": 1.8679, "pre_close": 1.499, "trade_date": "20260420", "ts_code": "588000.SH", "vol": 24059226.01}, {"amount": 2957520.389, "change": 0.001, "close": 1.499, "high": 1.51, "low": 1.489, "open": 1.492, "pct_chg": 0.0668, "pre_close": 1.498, "trade_date": "20260417", "ts_code": "588000.SH", "vol": 19717725.78}, {"amount": 3386250.007, "change": 0.016, "close": 1.498, "high": 1.501, "low": 1.477, "open": 1.485, "pct_chg": 1.0796, "pre_close": 1.482, "trade_date": "20260416", "ts_code": "588000.SH", "vol": 22724962.24}, {"amount": 4537686.041, "change": 0.003, "close": 1.482, "high": 1.509, "low": 1.474, "open": 1.491, "pct_chg": 0.2028, "pre_close": 1.479, "trade_date": "20260415", "ts_code": "588000.SH", "vol": 30381206.42}, {"amount": 3879672.253, "change": 0.031, "close": 1.479, "high": 1.484, "low": 1.461, "open": 1.473, "pct_chg": 2.1409, "pre_close": 1.448, "trade_date": "20260414", "ts_code": "588000.SH", "vol": 26318924.77}, {"amount": 3761476.975, "change": 0.011, "close": 1.448, "high": 1.47, "low": 1.424, "open": 1.428, "pct_chg": 0.7655, "pre_close": 1.437, "trade_date": "20260413", "ts_code": "588000.SH", "vol": 25929380.81}, {"amount": 3677645.27, "change": 0.022, "close": 1.437, "high": 1.457, "low": 1.434, "open": 1.434, "pct_chg": 1.5548, "pre_close": 1.415, "trade_date": "20260410", "ts_code": "588000.SH", "vol": 25457067.48}, {"amount": 3829053.222, "change": -0.01, "close": 1.415, "high": 1.435, "low": 1.403, "open": 1.408, "pct_chg": -0.7018, "pre_close": 1.425, "trade_date": "20260409", "ts_code": "588000.SH", "vol": 26955848.89}, {"amount": 5626200.492, "change": 0.083, "close": 1.425, "high": 1.425, "low": 1.385, "open": 1.385, "pct_chg": 6.1848, "pre_close": 1.342, "trade_date": "20260408", "ts_code": "588000.SH", "vol": 40044889.18}, {"amount": 3032705.15, "change": 0.018, "close": 1.342, "high": 1.355, "low": 1.327, "open": 1.33, "pct_chg": 1.3595, "pre_close": 1.324, "trade_date": "20260407", "ts_code": "588000.SH", "vol": 22552140.04}, {"amount": 2329375.556, "change": -0.006, "close": 1.324, "high": 1.339, "low": 1.321, "open": 1.335, "pct_chg": -0.4511, "pre_close": 1.33, "trade_date": "20260403", "ts_code": "588000.SH", "vol": 17516048.14}, {"amount": 3580727.077, "change": -0.039, "close": 1.33, "high": 1.365, "low": 1.322, "open": 1.363, "pct_chg": -2.8488, "pre_close": 1.369, "trade_date": "20260402", "ts_code": "588000.SH", "vol": 26758063.42}, {"amount": 4101942.798, "change": 0.045, "close": 1.369, "high": 1.372, "low": 1.35, "open": 1.354, "pct_chg": 3.3988, "pre_close": 1.324, "trade_date": "20260401", "ts_code": "588000.SH", "vol": 30138540.64}, {"amount": 3457849.471, "change": -0.035, "close": 1.324, "high": 1.366, "low": 1.322, "open": 1.356, "pct_chg": -2.5754, "pre_close": 1.359, "trade_date": "20260331", "ts_code": "588000.SH", "vol": 25732667.13}, {"amount": 2823974.003, "change": -0.011, "close": 1.359, "high": 1.364, "low": 1.337, "open": 1.347, "pct_chg": -0.8029, "pre_close": 1.37, "trade_date": "20260330", "ts_code": "588000.SH", "vol": 20907749.97}, {"amount": 3123347.992, "change": 0.013, "close": 1.37, "high": 1.379, "low": 1.332, "open": 1.334, "pct_chg": 0.958, "pre_close": 1.357, "trade_date": "20260327", "ts_code": "588000.SH", "vol": 22946931.53}, {"amount": 2679012.873, "change": -0.028, "close": 1.357, "high": 1.386, "low": 1.353, "open": 1.383, "pct_chg": -2.0217, "pre_close": 1.385, "trade_date": "20260326", "ts_code": "588000.SH", "vol": 19585266.72}, {"amount": 4615127.83, "change": 0.026, "close": 1.385, "high": 1.397, "low": 1.362, "open": 1.363, "pct_chg": 1.9132, "pre_close": 1.359, "trade_date": "20260325", "ts_code": "588000.SH", "vol": 33326170.2}, {"amount": 4532922.643, "change": 0.03, "close": 1.359, "high": 1.36, "low": 1.316, "open": 1.345, "pct_chg": 2.2573, "pre_close": 1.329, "trade_date": "20260324", "ts_code": "588000.SH", "vol": 33850995.44}, {"amount": 5804576.583, "change": -0.059, "close": 1.329, "high": 1.375, "low": 1.32, "open": 1.366, "pct_chg": -4.2507, "pre_close": 1.388, "trade_date": "20260323", "ts_code": "588000.SH", "vol": 43090289.03}, {"amount": 4081321.332, "change": -0.023, "close": 1.388, "high": 1.419, "low": 1.387, "open": 1.414, "pct_chg": -1.63, "pre_close": 1.411, "trade_date": "20260320", "ts_code": "588000.SH", "vol": 29045899.42}, {"amount": 3915746.632, "change": -0.034, "close": 1.411, "high": 1.427, "low": 1.405, "open": 1.424, "pct_chg": -2.3529, "pre_close": 1.445, "trade_date": "20260319", "ts_code": "588000.SH", "vol": 27651174.13}, {"amount": 3132632.859, "change": 0.017, "close": 1.445, "high": 1.447, "low": 1.42, "open": 1.432, "pct_chg": 1.1905, "pre_close": 1.428, "trade_date": "20260318", "ts_code": "588000.SH", "vol": 21825054.07}, {"amount": 3274839.868, "change": -0.03, "close": 1.428, "high": 1.464, "low": 1.425, "open": 1.459, "pct_chg": -2.0576, "pre_close": 1.458, "trade_date": "20260317", "ts_code": "588000.SH", "vol": 22693971.14}, {"amount": 3824586.637, "change": 0.008, "close": 1.458, "high": 1.46, "low": 1.42, "open": 1.446, "pct_chg": 0.5517, "pre_close": 1.45, "trade_date": "20260316", "ts_code": "588000.SH", "vol": 26602629.68}, {"amount": 2688547.407, "change": -0.006, "close": 1.45, "high": 1.466, "low": 1.438, "open": 1.446, "pct_chg": -0.4121, "pre_close": 1.456, "trade_date": "20260313", "ts_code": "588000.SH", "vol": 18550261.53}, {"amount": 2749948.102, "change": -0.021, "close": 1.456, "high": 1.487, "low": 1.445, "open": 1.472, "pct_chg": -1.4218, "pre_close": 1.477, "trade_date": "20260312", "ts_code": "588000.SH", "vol": 18806319.16}, {"amount": 3001544.779, "change": -0.018, "close": 1.477, "high": 1.503, "low": 1.474, "open": 1.497, "pct_chg": -1.204, "pre_close": 1.495, "trade_date": "20260311", "ts_code": "588000.SH", "vol": 20202138.02}, {"amount": 3238284.866, "change": 0.03, "close": 1.495, "high": 1.501, "low": 1.477, "open": 1.49, "pct_chg": 2.0478, "pre_close": 1.465, "trade_date": "20260310", "ts_code": "588000.SH", "vol": 21709733.27}, {"amount": 4155974.564, "change": -0.026, "close": 1.465, "high": 1.473, "low": 1.424, "open": 1.463, "pct_chg": -1.7438, "pre_close": 1.491, "trade_date": "20260309", "ts_code": "588000.SH", "vol": 28813339.25}, {"amount": 2513816.551, "change": 0.009, "close": 1.491, "high": 1.498, "low": 1.471, "open": 1.472, "pct_chg": 0.6073, "pre_close": 1.482, "trade_date": "20260306", "ts_code": "588000.SH", "vol": 16888854.1}, {"amount": 4059743.641, "change": 0.023, "close": 1.482, "high": 1.499, "low": 1.471, "open": 1.489, "pct_chg": 1.5764, "pre_close": 1.459, "trade_date": "20260305", "ts_code": "588000.SH", "vol": 27278480.96}, {"amount": 3131209.129, "change": -0.005, "close": 1.459, "high": 1.483, "low": 1.45, "open": 1.454, "pct_chg": -0.3415, "pre_close": 1.464, "trade_date": "20260304", "ts_code": "588000.SH", "vol": 21378491.53}, {"amount": 5908628.13, "change": -0.08, "close": 1.464, "high": 1.549, "low": 1.459, "open": 1.544, "pct_chg": -5.1813, "pre_close": 1.544, "trade_date": "20260303", "ts_code": "588000.SH", "vol": 39614957.32}, {"amount": 3336530.501, "change": -0.022, "close": 1.544, "high": 1.565, "low": 1.535, "open": 1.54, "pct_chg": -1.4049, "pre_close": 1.566, "trade_date": "20260302", "ts_code": "588000.SH", "vol": 21558207.03}, {"amount": 3004798.568, "change": 0.002, "close": 1.566, "high": 1.572, "low": 1.541, "open": 1.55, "pct_chg": 0.1279, "pre_close": 1.564, "trade_date": "20260227", "ts_code": "588000.SH", "vol": 19287909.47}, {"amount": 3838931.688, "change": 0.012, "close": 1.564, "high": 1.574, "low": 1.533, "open": 1.554, "pct_chg": 0.7732, "pre_close": 1.552, "trade_date": "20260226", "ts_code": "588000.SH", "vol": 24689208.01}, {"amount": 3222265.881, "change": 0.009, "close": 1.552, "high": 1.559, "low": 1.531, "open": 1.547, "pct_chg": 0.5833, "pre_close": 1.543, "trade_date": "20260225", "ts_code": "588000.SH", "vol": 20828566.35}, {"amount": 2721380.168, "change": -0.005, "close": 1.543, "high": 1.572, "low": 1.53, "open": 1.57, "pct_chg": -0.323, "pre_close": 1.548, "trade_date": "20260224", "ts_code": "588000.SH", "vol": 17604917.62}, {"amount": 2891253.227, "change": -0.011, "close": 1.548, "high": 1.57, "low": 1.544, "open": 1.55, "pct_chg": -0.7056, "pre_close": 1.559, "trade_date": "20260213", "ts_code": "588000.SH", "vol": 18557536.7}, {"amount": 3117995.539, "change": 0.026, "close": 1.559, "high": 1.56, "low": 1.531, "open": 1.538, "pct_chg": 1.696, "pre_close": 1.533, "trade_date": "20260212", "ts_code": "588000.SH", "vol": 20166445.43}, {"amount": 2323690.699, "change": -0.017, "close": 1.533, "high": 1.545, "low": 1.53, "open": 1.54, "pct_chg": -1.0968, "pre_close": 1.55, "trade_date": "20260211", "ts_code": "588000.SH", "vol": 15122498.69}, {"amount": 3192807.461, "change": 0.015, "close": 1.55, "high": 1.564, "low": 1.537, "open": 1.538, "pct_chg": 0.9772, "pre_close": 1.535, "trade_date": "20260210", "ts_code": "588000.SH", "vol": 20582683.06}, {"amount": 3505964.862, "change": 0.036, "close": 1.535, "high": 1.536, "low": 1.517, "open": 1.527, "pct_chg": 2.4016, "pre_close": 1.499, "trade_date": "20260209", "ts_code": "588000.SH", "vol": 22936044.45}, {"amount": 3830757.714, "change": -0.009, "close": 1.499, "high": 1.517, "low": 1.48, "open": 1.49, "pct_chg": -0.5968, "pre_close": 1.508, "trade_date": "20260206", "ts_code": "588000.SH", "vol": 25538288.57}, {"amount": 4409807.72, "change": -0.023, "close": 1.508, "high": 1.517, "low": 1.491, "open": 1.508, "pct_chg": -1.5023, "pre_close": 1.531, "trade_date": "20260205", "ts_code": "588000.SH", "vol": 29316101.89}, {"amount": 3974614.009, "change": -0.017, "close": 1.531, "high": 1.534, "low": 1.509, "open": 1.532, "pct_chg": -1.0982, "pre_close": 1.548, "trade_date": "20260204", "ts_code": "588000.SH", "vol": 26139171.8}, {"amount": 4724253.964, "change": 0.02, "close": 1.548, "high": 1.556, "low": 1.504, "open": 1.551, "pct_chg": 1.3089, "pre_close": 1.528, "trade_date": "20260203", "ts_code": "588000.SH", "vol": 30771356.13}, {"amount": 4870464.007, "change": -0.06, "close": 1.528, "high": 1.588, "low": 1.526, "open": 1.576, "pct_chg": -3.7783, "pre_close": 1.588, "trade_date": "20260202", "ts_code": "588000.SH", "vol": 31371581.58}, {"amount": 5110410.662, "change": -0.001, "close": 1.588, "high": 1.602, "low": 1.542, "open": 1.579, "pct_chg": -0.0629, "pre_close": 1.589, "trade_date": "20260130", "ts_code": "588000.SH", "vol": 32420221.11}, {"amount": 5391204.852, "change": -0.046, "close": 1.589, "high": 1.642, "low": 1.587, "open": 1.631, "pct_chg": -2.8135, "pre_close": 1.635, "trade_date": "20260129", "ts_code": "588000.SH", "vol": 33503487.81}, {"amount": 5139277.872, "change": -0.005, "close": 1.635, "high": 1.651, "low": 1.616, "open": 1.645, "pct_chg": -0.3049, "pre_close": 1.64, "trade_date": "20260128", "ts_code": "588000.SH", "vol": 31487052.48}, {"amount": 4723057.831, "change": 0.025, "close": 1.64, "high": 1.645, "low": 1.586, "open": 1.606, "pct_chg": 1.548, "pre_close": 1.615, "trade_date": "20260127", "ts_code": "588000.SH", "vol": 29188920.61}, {"amount": 4314890.536, "change": -0.02, "close": 1.615, "high": 1.658, "low": 1.609, "open": 1.635, "pct_chg": -1.2232, "pre_close": 1.635, "trade_date": "20260126", "ts_code": "588000.SH", "vol": 26545156.08}, {"amount": 4304130.319, "change": 0.013, "close": 1.635, "high": 1.635, "low": 1.604, "open": 1.61, "pct_chg": 0.8015, "pre_close": 1.622, "trade_date": "20260123", "ts_code": "588000.SH", "vol": 26559722.98}, {"amount": 4457108.756, "change": 0.006, "close": 1.622, "high": 1.658, "low": 1.61, "open": 1.643, "pct_chg": 0.3713, "pre_close": 1.616, "trade_date": "20260122", "ts_code": "588000.SH", "vol": 27411756.13}, {"amount": 6828229.478, "change": 0.056, "close": 1.616, "high": 1.627, "low": 1.556, "open": 1.556, "pct_chg": 3.5897, "pre_close": 1.56, "trade_date": "20260121", "ts_code": "588000.SH", "vol": 42455551.84}, {"amount": 6029017.434, "change": -0.026, "close": 1.56, "high": 1.605, "low": 1.55, "open": 1.585, "pct_chg": -1.6393, "pre_close": 1.586, "trade_date": "20260120", "ts_code": "588000.SH", "vol": 38373761.09}, {"amount": 4504340.92, "change": -0.007, "close": 1.586, "high": 1.606, "low": 1.582, "open": 1.59, "pct_chg": -0.4394, "pre_close": 1.593, "trade_date": "20260119", "ts_code": "588000.SH", "vol": 28306064.89}, {"amount": 5866924.842, "change": 0.02, "close": 1.593, "high": 1.609, "low": 1.574, "open": 1.587, "pct_chg": 1.2715, "pre_close": 1.573, "trade_date": "20260116", "ts_code": "588000.SH", "vol": 36915235.21}, {"amount": 5459161.638, "change": -0.006, "close": 1.573, "high": 1.584, "low": 1.545, "open": 1.571, "pct_chg": -0.38, "pre_close": 1.579, "trade_date": "20260115", "ts_code": "588000.SH", "vol": 34921946.61}, {"amount": 8066746.841, "change": 0.03, "close": 1.579, "high": 1.613, "low": 1.552, "open": 1.552, "pct_chg": 1.9367, "pre_close": 1.549, "trade_date": "20260114", "ts_code": "588000.SH", "vol": 50893093.02}, {"amount": 6940475.744, "change": -0.045, "close": 1.549, "high": 1.594, "low": 1.536, "open": 1.594, "pct_chg": -2.8231, "pre_close": 1.594, "trade_date": "20260113", "ts_code": "588000.SH", "vol": 44369110.64}, {"amount": 5649496.769, "change": 0.04, "close": 1.594, "high": 1.596, "low": 1.556, "open": 1.566, "pct_chg": 2.574, "pre_close": 1.554, "trade_date": "20260112", "ts_code": "588000.SH", "vol": 35761745.48}, {"amount": 5346744.937, "change": 0.023, "close": 1.554, "high": 1.555, "low": 1.514, "open": 1.52, "pct_chg": 1.5023, "pre_close": 1.531, "trade_date": "20260109", "ts_code": "588000.SH", "vol": 34737566.1}, {"amount": 5518490.197, "change": 0.011, "close": 1.531, "high": 1.562, "low": 1.514, "open": 1.514, "pct_chg": 0.7237, "pre_close": 1.52, "trade_date": "20260108", "ts_code": "588000.SH", "vol": 35820333.26}, {"amount": 5238497.815, "change": 0.015, "close": 1.52, "high": 1.529, "low": 1.503, "open": 1.517, "pct_chg": 0.9967, "pre_close": 1.505, "trade_date": "20260107", "ts_code": "588000.SH", "vol": 34568205.44}, {"amount": 5821559.14, "change": 0.026, "close": 1.505, "high": 1.52, "low": 1.476, "open": 1.481, "pct_chg": 1.7579, "pre_close": 1.479, "trade_date": "20260106", "ts_code": "588000.SH", "vol": 38803417.91}, {"amount": 6082622.572, "change": 0.062, "close": 1.479, "high": 1.479, "low": 1.428, "open": 1.433, "pct_chg": 4.3754, "pre_close": 1.417, "trade_date": "20260105", "ts_code": "588000.SH", "vol": 41593732.69}, {"amount": 3129346.537, "change": -0.016, "close": 1.417, "high": 1.441, "low": 1.415, "open": 1.438, "pct_chg": -1.1165, "pre_close": 1.433, "trade_date": "20251231", "ts_code": "588000.SH", "vol": 21973104.94}, {"amount": 3480884.076, "change": 0.016, "close": 1.433, "high": 1.439, "low": 1.414, "open": 1.414, "pct_chg": 1.1291, "pre_close": 1.417, "trade_date": "20251230", "ts_code": "588000.SH", "vol": 24321604.03}, {"amount": 3674565.643, "change": -0.002, "close": 1.417, "high": 1.436, "low": 1.412, "open": 1.419, "pct_chg": -0.1409, "pre_close": 1.419, "trade_date": "20251229", "ts_code": "588000.SH", "vol": 25775162.34}, {"amount": 2951303.795, "change": -0.003, "close": 1.419, "high": 1.429, "low": 1.411, "open": 1.423, "pct_chg": -0.211, "pre_close": 1.422, "trade_date": "20251226", "ts_code": "588000.SH", "vol": 20774294.68}, {"amount": 2561518.984, "change": -0.001, "close": 1.422, "high": 1.429, "low": 1.411, "open": 1.422, "pct_chg": -0.0703, "pre_close": 1.423, "trade_date": "20251225", "ts_code": "588000.SH", "vol": 18034800.36}, {"amount": 2926560.133, "change": 0.013, "close": 1.423, "high": 1.424, "low": 1.403, "open": 1.412, "pct_chg": 0.922, "pre_close": 1.41, "trade_date": "20251224", "ts_code": "588000.SH", "vol": 20679893.34}, {"amount": 2985639.356, "change": 0.005, "close": 1.41, "high": 1.419, "low": 1.401, "open": 1.405, "pct_chg": 0.3559, "pre_close": 1.405, "trade_date": "20251223", "ts_code": "588000.SH", "vol": 21139979.4}, {"amount": 3714805.834, "change": 0.025, "close": 1.405, "high": 1.41, "low": 1.384, "open": 1.385, "pct_chg": 1.8116, "pre_close": 1.38, "trade_date": "20251222", "ts_code": "588000.SH", "vol": 26521071.9}, {"amount": 2511958.098, "change": 0.004, "close": 1.38, "high": 1.394, "low": 1.377, "open": 1.382, "pct_chg": 0.2907, "pre_close": 1.376, "trade_date": "20251219", "ts_code": "588000.SH", "vol": 18152553.66}, {"amount": 2538867.376, "change": -0.02, "close": 1.376, "high": 1.394, "low": 1.375, "open": 1.384, "pct_chg": -1.4327, "pre_close": 1.396, "trade_date": "20251218", "ts_code": "588000.SH", "vol": 18351196.96}, {"amount": 3948199.141, "change": 0.035, "close": 1.396, "high": 1.397, "low": 1.356, "open": 1.361, "pct_chg": 2.5716, "pre_close": 1.361, "trade_date": "20251217", "ts_code": "588000.SH", "vol": 28637707.2}, {"amount": 3987566.188, "change": -0.028, "close": 1.361, "high": 1.386, "low": 1.353, "open": 1.382, "pct_chg": -2.0158, "pre_close": 1.389, "trade_date": "20251216", "ts_code": "588000.SH", "vol": 29156084.09}, {"amount": 3027695.251, "change": -0.029, "close": 1.389, "high": 1.414, "low": 1.387, "open": 1.405, "pct_chg": -2.0451, "pre_close": 1.418, "trade_date": "20251215", "ts_code": "588000.SH", "vol": 21632669.12}, {"amount": 3940369.213, "change": 0.026, "close": 1.418, "high": 1.42, "low": 1.381, "open": 1.393, "pct_chg": 1.8678, "pre_close": 1.392, "trade_date": "20251212", "ts_code": "588000.SH", "vol": 28064294.63}, {"amount": 2903209.643, "change": -0.023, "close": 1.392, "high": 1.42, "low": 1.392, "open": 1.417, "pct_chg": -1.6254, "pre_close": 1.415, "trade_date": "20251211", "ts_code": "588000.SH", "vol": 20671220.85}, {"amount": 3643142.319, "change": 0.001, "close": 1.415, "high": 1.42, "low": 1.385, "open": 1.404, "pct_chg": 0.0707, "pre_close": 1.414, "trade_date": "20251210", "ts_code": "588000.SH", "vol": 26009625.54}, {"amount": 3507996.536, "change": -0.006, "close": 1.414, "high": 1.428, "low": 1.406, "open": 1.41, "pct_chg": -0.4225, "pre_close": 1.42, "trade_date": "20251209", "ts_code": "588000.SH", "vol": 24780065.91}, {"amount": 4890831.628, "change": 0.026, "close": 1.42, "high": 1.43, "low": 1.394, "open": 1.397, "pct_chg": 1.8651, "pre_close": 1.394, "trade_date": "20251208", "ts_code": "588000.SH", "vol": 34585195.44}, {"amount": 3595788.632, "change": 0, "close": 1.394, "high": 1.398, "low": 1.37, "open": 1.39, "pct_chg": 0, "pre_close": 1.394, "trade_date": "20251205", "ts_code": "588000.SH", "vol": 25923618.12}, {"amount": 3066497.9, "change": 0.019, "close": 1.394, "high": 1.395, "low": 1.36, "open": 1.37, "pct_chg": 1.3818, "pre_close": 1.375, "trade_date": "20251204", "ts_code": "588000.SH", "vol": 22208468.57}, {"amount": 2517188.798, "change": -0.012, "close": 1.375, "high": 1.391, "low": 1.369, "open": 1.385, "pct_chg": -0.8652, "pre_close": 1.387, "trade_date": "20251203", "ts_code": "588000.SH", "vol": 18244641.88}, {"amount": 2844686.57, "change": -0.017, "close": 1.387, "high": 1.403, "low": 1.381, "open": 1.401, "pct_chg": -1.2108, "pre_close": 1.404, "trade_date": "20251202", "ts_code": "588000.SH", "vol": 20474498.01}, {"amount": 3010747.361, "change": 0.011, "close": 1.404, "high": 1.409, "low": 1.376, "open": 1.395, "pct_chg": 0.7897, "pre_close": 1.393, "trade_date": "20251201", "ts_code": "588000.SH", "vol": 21620183.05}, {"amount": 2898802.447, "change": 0.014, "close": 1.393, "high": 1.396, "low": 1.368, "open": 1.374, "pct_chg": 1.0152, "pre_close": 1.379, "trade_date": "20251128", "ts_code": "588000.SH", "vol": 20923996.6}, {"amount": 3833058.631, "change": -0.003, "close": 1.379, "high": 1.419, "low": 1.374, "open": 1.392, "pct_chg": -0.2171, "pre_close": 1.382, "trade_date": "20251127", "ts_code": "588000.SH", "vol": 27397137.68}, {"amount": 4063817.324, "change": 0.013, "close": 1.382, "high": 1.394, "low": 1.358, "open": 1.362, "pct_chg": 0.9496, "pre_close": 1.369, "trade_date": "20251126", "ts_code": "588000.SH", "vol": 29404973.46}, {"amount": 4088957.172, "change": 0.009, "close": 1.369, "high": 1.39, "low": 1.363, "open": 1.374, "pct_chg": 0.6618, "pre_close": 1.36, "trade_date": "20251125", "ts_code": "588000.SH", "vol": 29745119.82}, {"amount": 3781489.671, "change": 0.009, "close": 1.36, "high": 1.372, "low": 1.337, "open": 1.357, "pct_chg": 0.6662, "pre_close": 1.351, "trade_date": "20251124", "ts_code": "588000.SH", "vol": 27925268.17}, {"amount": 5943377.66, "change": -0.045, "close": 1.351, "high": 1.378, "low": 1.347, "open": 1.371, "pct_chg": -3.2235, "pre_close": 1.396, "trade_date": "20251121", "ts_code": "588000.SH", "vol": 43616303.01}, {"amount": 3317229.786, "change": -0.017, "close": 1.396, "high": 1.432, "low": 1.393, "open": 1.428, "pct_chg": -1.2031, "pre_close": 1.413, "trade_date": "20251120", "ts_code": "588000.SH", "vol": 23587624.53}, {"amount": 2673481.827, "change": -0.014, "close": 1.413, "high": 1.432, "low": 1.405, "open": 1.422, "pct_chg": -0.9811, "pre_close": 1.427, "trade_date": "20251119", "ts_code": "588000.SH", "vol": 18858371.53}, {"amount": 3172128.833, "change": 0.005, "close": 1.427, "high": 1.443, "low": 1.415, "open": 1.417, "pct_chg": 0.3516, "pre_close": 1.422, "trade_date": "20251118", "ts_code": "588000.SH", "vol": 22191538.51}, {"amount": 3330961.49, "change": -0.009, "close": 1.422, "high": 1.446, "low": 1.412, "open": 1.43, "pct_chg": -0.6289, "pre_close": 1.431, "trade_date": "20251117", "ts_code": "588000.SH", "vol": 23364415.24}, {"amount": 3408869.927, "change": -0.038, "close": 1.431, "high": 1.46, "low": 1.43, "open": 1.451, "pct_chg": -2.5868, "pre_close": 1.469, "trade_date": "20251114", "ts_code": "588000.SH", "vol": 23604961.65}, {"amount": 3416314.067, "change": 0.02, "close": 1.469, "high": 1.475, "low": 1.442, "open": 1.446, "pct_chg": 1.3803, "pre_close": 1.449, "trade_date": "20251113", "ts_code": "588000.SH", "vol": 23389876.95}, {"amount": 4011347.039, "change": -0.008, "close": 1.449, "high": 1.458, "low": 1.431, "open": 1.446, "pct_chg": -0.5491, "pre_close": 1.457, "trade_date": "20251112", "ts_code": "588000.SH", "vol": 27788389.12}, {"amount": 3059095.392, "change": -0.021, "close": 1.457, "high": 1.495, "low": 1.453, "open": 1.488, "pct_chg": -1.4208, "pre_close": 1.478, "trade_date": "20251111", "ts_code": "588000.SH", "vol": 20834143.28}, {"amount": 3377807.577, "change": -0.009, "close": 1.478, "high": 1.504, "low": 1.459, "open": 1.491, "pct_chg": -0.6052, "pre_close": 1.487, "trade_date": "20251110", "ts_code": "588000.SH", "vol": 22918062.2}, {"amount": 3096498.032, "change": -0.022, "close": 1.487, "high": 1.506, "low": 1.484, "open": 1.494, "pct_chg": -1.4579, "pre_close": 1.509, "trade_date": "20251107", "ts_code": "588000.SH", "vol": 20727006.95}, {"amount": 4873307.251, "change": 0.049, "close": 1.509, "high": 1.514, "low": 1.472, "open": 1.476, "pct_chg": 3.3562, "pre_close": 1.46, "trade_date": "20251106", "ts_code": "588000.SH", "vol": 32599116.62}, {"amount": 4024168.178, "change": 0.005, "close": 1.46, "high": 1.468, "low": 1.425, "open": 1.43, "pct_chg": 0.3436, "pre_close": 1.455, "trade_date": "20251105", "ts_code": "588000.SH", "vol": 27846241.31}, {"amount": 4285797.772, "change": -0.015, "close": 1.455, "high": 1.487, "low": 1.446, "open": 1.475, "pct_chg": -1.0204, "pre_close": 1.47, "trade_date": "20251104", "ts_code": "588000.SH", "vol": 29209639.59}, {"amount": 4486923.589, "change": -0.018, "close": 1.47, "high": 1.478, "low": 1.441, "open": 1.474, "pct_chg": -1.2097, "pre_close": 1.488, "trade_date": "20251103", "ts_code": "588000.SH", "vol": 30738365.96}, {"amount": 5698843.369, "change": -0.047, "close": 1.488, "high": 1.53, "low": 1.487, "open": 1.525, "pct_chg": -3.0619, "pre_close": 1.535, "trade_date": "20251031", "ts_code": "588000.SH", "vol": 37988933.84}, {"amount": 5212506.865, "change": -0.03, "close": 1.535, "high": 1.568, "low": 1.533, "open": 1.561, "pct_chg": -1.9169, "pre_close": 1.565, "trade_date": "20251030", "ts_code": "588000.SH", "vol": 33635181.36}, {"amount": 4806606.04, "change": 0.02, "close": 1.565, "high": 1.569, "low": 1.538, "open": 1.541, "pct_chg": 1.2945, "pre_close": 1.545, "trade_date": "20251029", "ts_code": "588000.SH", "vol": 30936872.09}, {"amount": 4316089.945, "change": -0.013, "close": 1.545, "high": 1.57, "low": 1.537, "open": 1.545, "pct_chg": -0.8344, "pre_close": 1.558, "trade_date": "20251028", "ts_code": "588000.SH", "vol": 27791905.3}, {"amount": 6350609.478, "change": 0.023, "close": 1.558, "high": 1.567, "low": 1.53, "open": 1.562, "pct_chg": 1.4984, "pre_close": 1.535, "trade_date": "20251027", "ts_code": "588000.SH", "vol": 40944372.21}, {"amount": 5522170.176, "change": 0.062, "close": 1.535, "high": 1.54, "low": 1.486, "open": 1.492, "pct_chg": 4.2091, "pre_close": 1.473, "trade_date": "20251024", "ts_code": "588000.SH", "vol": 36445171.45}, {"amount": 3357554.417, "change": -0.003, "close": 1.473, "high": 1.475, "low": 1.445, "open": 1.465, "pct_chg": -0.2033, "pre_close": 1.476, "trade_date": "20251023", "ts_code": "588000.SH", "vol": 23038137.63}, {"amount": 4383191.269, "change": -0.001, "close": 1.476, "high": 1.496, "low": 1.449, "open": 1.466, "pct_chg": -0.0677, "pre_close": 1.477, "trade_date": "20251022", "ts_code": "588000.SH", "vol": 29785469.49}, {"amount": 5842705.467, "change": 0.04, "close": 1.477, "high": 1.48, "low": 1.436, "open": 1.443, "pct_chg": 2.7836, "pre_close": 1.437, "trade_date": "20251021", "ts_code": "588000.SH", "vol": 39915336.56}, {"amount": 4977710.329, "change": 0.004, "close": 1.437, "high": 1.468, "low": 1.425, "open": 1.456, "pct_chg": 0.2791, "pre_close": 1.433, "trade_date": "20251020", "ts_code": "588000.SH", "vol": 34407300.77}, {"amount": 6025472.153, "change": -0.054, "close": 1.433, "high": 1.487, "low": 1.428, "open": 1.478, "pct_chg": -3.6315, "pre_close": 1.487, "trade_date": "20251017", "ts_code": "588000.SH", "vol": 41576260}, {"amount": 4867655.982, "change": -0.014, "close": 1.487, "high": 1.516, "low": 1.479, "open": 1.487, "pct_chg": -0.9327, "pre_close": 1.501, "trade_date": "20251016", "ts_code": "588000.SH", "vol": 32537159.21}, {"amount": 5478957.593, "change": 0.02, "close": 1.501, "high": 1.502, "low": 1.46, "open": 1.479, "pct_chg": 1.3504, "pre_close": 1.481, "trade_date": "20251015", "ts_code": "588000.SH", "vol": 36973207.45}, {"amount": 7544364.868, "change": -0.065, "close": 1.481, "high": 1.563, "low": 1.47, "open": 1.56, "pct_chg": -4.2044, "pre_close": 1.546, "trade_date": "20251014", "ts_code": "588000.SH", "vol": 49899987.57}, {"amount": 7305670.037, "change": 0.018, "close": 1.546, "high": 1.55, "low": 1.47, "open": 1.47, "pct_chg": 1.178, "pre_close": 1.528, "trade_date": "20251013", "ts_code": "588000.SH", "vol": 47710599.16}, {"amount": 8808071.627, "change": -0.09, "close": 1.528, "high": 1.592, "low": 1.516, "open": 1.585, "pct_chg": -5.5624, "pre_close": 1.618, "trade_date": "20251010", "ts_code": "588000.SH", "vol": 56901910.22}, {"amount": 7141315.117, "change": 0.048, "close": 1.618, "high": 1.667, "low": 1.596, "open": 1.607, "pct_chg": 3.0573, "pre_close": 1.57, "trade_date": "20251009", "ts_code": "588000.SH", "vol": 43775259.11}, {"amount": 5187228.703, "change": 0.027, "close": 1.57, "high": 1.58, "low": 1.552, "open": 1.561, "pct_chg": 1.7498, "pre_close": 1.543, "trade_date": "20250930", "ts_code": "588000.SH", "vol": 33039385.54}, {"amount": 5345277.132, "change": 0.019, "close": 1.543, "high": 1.544, "low": 1.495, "open": 1.516, "pct_chg": 1.2467, "pre_close": 1.524, "trade_date": "20250929", "ts_code": "588000.SH", "vol": 35142978.19}, {"amount": 4446468.253, "change": -0.024, "close": 1.524, "high": 1.559, "low": 1.521, "open": 1.538, "pct_chg": -1.5504, "pre_close": 1.548, "trade_date": "20250926", "ts_code": "588000.SH", "vol": 28873902.53}, {"amount": 5086024.942, "change": 0.018, "close": 1.548, "high": 1.56, "low": 1.521, "open": 1.524, "pct_chg": 1.1765, "pre_close": 1.53, "trade_date": "20250925", "ts_code": "588000.SH", "vol": 32909937.07}, {"amount": 7413184.366, "change": 0.055, "close": 1.53, "high": 1.553, "low": 1.462, "open": 1.464, "pct_chg": 3.7288, "pre_close": 1.475, "trade_date": "20250924", "ts_code": "588000.SH", "vol": 49015222.16}, {"amount": 6567997.272, "change": -0.004, "close": 1.475, "high": 1.49, "low": 1.432, "open": 1.478, "pct_chg": -0.2705, "pre_close": 1.479, "trade_date": "20250923", "ts_code": "588000.SH", "vol": 45043340.98}, {"amount": 6201628.557, "change": 0.048, "close": 1.479, "high": 1.494, "low": 1.42, "open": 1.426, "pct_chg": 3.3543, "pre_close": 1.431, "trade_date": "20250922", "ts_code": "588000.SH", "vol": 42499073.77}, {"amount": 6359671.437, "change": -0.019, "close": 1.431, "high": 1.471, "low": 1.426, "open": 1.456, "pct_chg": -1.3103, "pre_close": 1.45, "trade_date": "20250919", "ts_code": "588000.SH", "vol": 43922276.52}] \ No newline at end of file diff --git a/labs/analysis/etf/capital.py b/labs/analysis/etf/capital.py new file mode 100644 index 0000000..f9050e9 --- /dev/null +++ b/labs/analysis/etf/capital.py @@ -0,0 +1,41 @@ +"""读仓库 _etf.yaml 后,先定"要多大的账户才铺得开",再跑基准回测。 + +用法: py -3.14 -B analysis/etf/capital.py +""" + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import backtest as B # noqa: E402 +from backtest import SYMBOL_PARAMS, SYMBOLS, SYMBOL_PARAMS, analyze, fetch_daily, simulate # noqa: E402 + +data = {code: fetch_daily(code) for code in SYMBOLS} + +print("== 新配置的资金需求(按区间最低价估算)==") +need = {} +for code, p in SYMBOL_PARAMS.items(): + low = min(bar["low"] for bar in data[code]) + rung = p["buy_shares"] * low + need[code] = rung + print(f" {code} 每档={p['buy_shares']:>6} 股 区间最低价={low:6.3f} " + f"1 档≈{rung:9.0f} 10 档≈{rung * 10:10.0f} 单标上限={p['max_shares']} 股") +print(f" 三只各铺 1 档 ≈ {sum(need.values()):,.0f};三只铺满 10 档 ≈ {sum(need.values()) * 10:,.0f}") + +print() +print("== 不同起始资金(参数完全不变,只改账户规模)==") +print(f"{'起始资金':>10} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'平均占用':>8} " + f"{'峰值占用':>8} {'资金拒绝':>8} {'底仓':>4} {'补仓':>4} {'主出口':>5}") +for cash in (200_000, 300_000, 500_000, 800_000, 1_200_000, 1_500_000): + result = simulate(data, start_cash=cash) + stats = analyze(result) + delta = stats["final_equity"] - cash + print(f"{cash:10,.0f} {delta:10.2f} {stats['return_pct']:6.2f}% {stats['max_dd_pct']:7.2f}% " + f"{stats['avg_deployed'] / cash * 100:7.2f}% {stats['max_deployed'] / cash * 100:7.2f}% " + f"{'':>8} {sum(1 for f in result['fills'] if f.kind == 'base'):4} " + f"{sum(1 for f in result['fills'] if f.kind == 'add'):4} " + f"{sum(1 for f in result['fills'] if f.kind == 'exit'):5}") +print() +print("说明:'资金拒绝' 未单独统计;底仓/补仓次数随资金上升而增加,说明低资金时被预算挡住。") diff --git a/labs/analysis/etf/compare.py b/labs/analysis/etf/compare.py new file mode 100644 index 0000000..f2070e2 --- /dev/null +++ b/labs/analysis/etf/compare.py @@ -0,0 +1,104 @@ +"""旧配置 vs 新配置(etc/_etf.yaml)对照,并给出新配置下的资金需求与规模敏感性。 + +用法: py -3.14 -B analysis/etf/compare.py +""" + +import sys +from dataclasses import replace +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from backtest import ( # noqa: E402 + REPO_DEFAULTS, SYMBOLS, SYMBOL_PARAMS, EtfSymbolConfig, analyze, fetch_daily, + precondition, simulate, +) + +data = {code: fetch_daily(code) for code in SYMBOLS} + +# 旧配置(本报告第一版的 etc/_etf.yaml:三只都是 1000 股 / 10000 股上限) +OLD_PARAMS = { + "588000.SH": dict(is_t0=False, buy_shares=1000, max_shares=10000, atr_multiplier=0.5, inner_step=0.9), + "510300.SH": dict(is_t0=False, buy_shares=1000, max_shares=10000, atr_multiplier=1.0, inner_step=0.7), + "518880.SH": dict(is_t0=True, buy_shares=1000, max_shares=10000, atr_multiplier=1.0, inner_step=0.8), +} +NEW_PARAMS = SYMBOL_PARAMS + + +def total_rung_notional(params, cash_scale=None): + """三只各一档的名义金额(按区间最低价估)与铺满 10 档的总需求。""" + one = sum(p["buy_shares"] * min(b["low"] for b in data[c]) for c, p in params.items()) + return one, one * 10 + + +def run(params, cash): + pre = {c: precondition(data[c], EtfSymbolConfig(**params[c]), REPO_DEFAULTS) for c in data} + result = simulate(data, symbol_params=params, precomputed=pre, start_cash=cash) + stats = analyze(result) + return result, stats + + +print("=" * 120) +print("A. 两版配置的资金需求(按各标的区间最低价估算)") +print("=" * 120) +for label, params in (("旧(1000 股/档)", OLD_PARAMS), ("新(10000/4000/2000 股/档)", NEW_PARAMS)): + one, full = total_rung_notional(params) + detail = " ".join( + f"{c[:6]}={p['buy_shares']}股×{min(b['low'] for b in data[c]):.2f}≈{p['buy_shares'] * min(b['low'] for b in data[c]):,.0f}" + for c, p in params.items() + ) + print(f"{label:28} 三只各 1 档 ≈ {one:>10,.0f} 铺满 10 档 ≈ {full:>10,.0f}") + print(f"{'':28} {detail}") + +print() +print("=" * 120) +print("B. 旧 vs 新:同一资金 50 万,同一天数据、同一套逻辑") +print("=" * 120) +print(f"{'配置':24} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'轮次':>5} {'胜率':>6} " + f"{'单轮均利':>8} {'平均占用':>8} {'峰值占用':>8} {'佣金':>7} {'名义周转':>8}") +for label, params in (("旧(1000 股/档)", OLD_PARAMS), ("新(现 _etf.yaml)", NEW_PARAMS)): + result, stats = run(params, 500_000.0) + trips = [f for f in result["fills"] if f.kind == "exit"] + delta = stats["final_equity"] - 500_000.0 + util = stats["avg_deployed"] / 500_000 * 100 + max_util = stats["max_deployed"] / 500_000 * 100 + print(f"{label:24} {delta:10.2f} {stats['return_pct']:6.2f}% {stats['max_dd_pct']:7.2f}% " + f"{len(trips):5} {'':>6} {'':>8} {util:7.2f}% {max_util:7.2f}% {stats['fees']:7.2f} " + f"{stats['buy_amount'] / 500_000:7.2f}x") + print(f"{'':24} 已了结盈亏={stats['net']:>10.2f} 买入名义={stats['buy_amount']:>10.2f} " + f"占用ROI={delta / stats['avg_deployed'] * 100 if stats['avg_deployed'] else 0:.2f}%") + +print() +print("=" * 120) +print("C. 新配置:绝对盈亏与账户规模无关(按固定股数下单),但收益率会摊薄") +print("=" * 120) +print(f"{'起始资金':>10} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'平均占用':>8} {'峰值占用':>8} {'底仓':>4} {'补仓':>4}") +for cash in (200_000, 300_000, 500_000, 800_000, 1_200_000): + result, stats = run(NEW_PARAMS, cash) + print(f"{cash:10,.0f} {stats['final_equity'] - cash:10.2f} {stats['return_pct']:6.2f}% " + f"{stats['max_dd_pct']:7.2f}% {stats['avg_deployed'] / cash * 100:7.2f}% " + f"{stats['max_deployed'] / cash * 100:7.2f}% " + f"{sum(1 for f in result['fills'] if f.kind == 'base'):4} " + f"{sum(1 for f in result['fills'] if f.kind == 'add'):4}") + +print() +print("=" * 120) +print("D. 新配置下再放大/缩小单档股数(资金 50 万不变)") +print("=" * 120) +print(f"{'场景':26} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'平均占用':>8} {'峰值占用':>8} {'占用ROI':>8} {'收益/回撤':>8}") +for factor in (0.25, 0.5, 1.0, 2.0, 4.0): + params = { + c: {**NEW_PARAMS[c], + "buy_shares": max(100, int(NEW_PARAMS[c]["buy_shares"] * factor)), + "max_shares": max(1000, int(NEW_PARAMS[c]["max_shares"] * factor))} + for c in SYMBOLS + } + result, stats = run(params, 500_000.0) + delta = stats["final_equity"] - 500_000.0 + roi = delta / stats["avg_deployed"] * 100 if stats["avg_deployed"] else 0.0 + ratio = stats["return_pct"] / stats["max_dd_pct"] if stats["max_dd_pct"] > 0.01 else 0.0 + label = "基准(现 _etf.yaml)" if factor == 1.0 else f"buy_shares×{factor}" + print(f"{label:26} {delta:10.2f} {stats['return_pct']:6.2f}% {stats['max_dd_pct']:7.2f}% " + f"{stats['avg_deployed'] / 500_000 * 100:7.2f}% {stats['max_deployed'] / 500_000 * 100:7.2f}% " + f"{roi:7.2f}% {ratio:8.2f}") diff --git a/labs/analysis/etf/drawdown.py b/labs/analysis/etf/drawdown.py new file mode 100644 index 0000000..1dd36fc --- /dev/null +++ b/labs/analysis/etf/drawdown.py @@ -0,0 +1,120 @@ +"""未了结仓位的浮动亏损轨迹 + "时间止损"反事实对照(只做分析,不改策略代码)。 + +用法: py -3.14 -B analysis/etf/drawdown.py +""" + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +# Windows 控制台默认 GBK,报告里用了「−」等字符,统一切到 UTF-8 输出。 +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +from analysis import round_trips # noqa: E402 +from backtest import SYMBOLS, analyze, fetch_daily, simulate # noqa: E402 + +data = {code: fetch_daily(code) for code in SYMBOLS} +result = simulate(data) +stats = analyze(result) +by_date = {code: {bar["date"]: bar for bar in data[code]} for code in data} + +# 逐日还原:每个标的的"本轮累计净买入成本"与"当前持仓量" +cost = {code: 0.0 for code in data} +volume = {code: 0 for code in data} +fills_by_day = {} +for fill in result["fills"]: + fills_by_day.setdefault(fill.day.strftime("%Y%m%d"), []).append(fill) +track = {code: [] for code in data} +for day, equity, market_value, cash in result["curve"]: + stamp = day.strftime("%Y%m%d") + for fill in fills_by_day.get(stamp, []): + amount = fill.price * fill.volume + if fill.side == "BUY": + cost[fill.code] += amount + volume[fill.code] += fill.volume + else: + # 卖出按比例冲减成本基数(与均价法一致) + if volume[fill.code] > 0: + ratio = fill.volume / volume[fill.code] + cost[fill.code] *= max(0.0, 1 - ratio) + volume[fill.code] -= fill.volume + for code in data: + bar = by_date[code].get(stamp) + if bar is None: + continue + track[code].append((day, cost[code], bar["close"], volume[code])) + +print("== 持仓期间的浮动盈亏(均价法:市值 − 本轮累计净买入成本)==") +print(f"{'标的':11} {'持仓天数':>8} {'最长连续浮亏天数':>16} {'最深浮亏':>10} {'最深浮亏%':>10} {'期末浮亏':>10}") +for code in data: + rows = [(day, c, close, vol) for day, c, close, vol in track[code] if vol > 0 and c > 0] + if not rows: + print(f"{code:11} {0:8} {'-':>16} {'-':>10} {'-':>10} {'-':>10}") + continue + pnl = [(day, close * vol - c, (close * vol - c) / c * 100) for day, c, close, vol in rows] + longest = cur = 0 + for _, amount, _ in pnl: + cur = cur + 1 if amount < 0 else 0 + longest = max(longest, cur) + worst_amt = min(p[1] for p in pnl) + worst_pct = min(p[2] for p in pnl) + print(f"{code:11} {len(rows):8} {longest:16} {worst_amt:10.0f} {worst_pct:9.2f}% {pnl[-1][1]:10.0f}") + +print() +print("== 持有天数分布(完整轮次)==") +trips = round_trips(result["fills"]) +buckets = {"≤1天": 0, "2-3天": 0, "4-7天": 0, "8-15天": 0, ">15天": 0} +for t in trips: + d = t.days + key = "≤1天" if d <= 1 else "2-3天" if d <= 3 else "4-7天" if d <= 7 else "8-15天" if d <= 15 else ">15天" + buckets[key] += 1 +print(" ", buckets) + +print() +print("== 时间止损反事实:把浮亏且持有超过 N 天的仓位按当日收盘价平掉 ==") +print(f"{'规则':22} {'权益变动':>10} {'收益率':>7} {'最大回撤':>8} {'占用ROI':>8} {'佣金':>8} {'强平次数':>8}") + + +def with_time_stop(limit: int) -> tuple[float, int]: + """在没有时间止损的结果上,找出"浮亏且连续持有 > limit 天"的仓位并按其后的实际 + 主出口价格之差估算影响(近似:直接扣掉该仓位到期末的浮亏差额)。""" + stops = 0 + delta = 0.0 + for code in data: + rows = [(day, c, close, vol) for day, c, close, vol in track[code] if vol > 0 and c > 0] + if not rows: + continue + start = rows[0][0] + for index, (day, c, close, vol) in enumerate(rows): + held = (day - start).days + if held > limit and close * vol - c < 0: + # 反事实:在当天以收盘价平掉,之后不再持有该标的 + loss = close * vol - c + final_close = rows[-1][2] + avoid = (final_close - close) * vol + delta += loss * 0 - avoid # 平仓后避免了后续价格变动 + stops += 1 + break + return delta, stops + + +for limit in (0, 5, 10, 20): + if limit == 0: + print(f"{'不止损(现配置)':22} {stats['final_equity'] - 500000:10.2f} {stats['return_pct']:6.2f}% " + f"{stats['max_dd_pct']:7.2f}% " + f"{(stats['final_equity'] - 500000) / stats['avg_deployed'] * 100:7.2f}% " + f"{stats['fees']:8.2f} {0:8}") + continue + delta, stops = with_time_stop(limit) + equity = stats["final_equity"] - 500000 + delta + print(f"{'持有>' + str(limit) + '天且浮亏即平':22} {equity:10.2f} {equity / 500000 * 100:6.2f}% " + f"{stats['max_dd_pct']:7.2f}% {equity / stats['avg_deployed'] * 100:7.2f}% " + f"{stats['fees']:8.2f} {stops:8}") +print() +print("注:上表是「平仓后不再持有该标的」的粗略反事实,只用于判断时间止损的方向性收益,") +print(" 不是精确回测(未重算后续轮次与资金复用)。") diff --git a/labs/analysis/etf/entries.py b/labs/analysis/etf/entries.py new file mode 100644 index 0000000..4d5116b --- /dev/null +++ b/labs/analysis/etf/entries.py @@ -0,0 +1,40 @@ +"""入场机会频率:日线上"进入门槛 + 当日收回门槛以上"到底出现多少次,以及 MA60 上限的影响。 + +用法: py -3.14 -B analysis/etf/entries.py +""" + +import sys +from datetime import date +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from backtest import SYMBOLS, SYMBOL_PARAMS, EtfDefaults, EtfSymbolConfig, fetch_daily, precondition + +BASE = EtfDefaults() +data = {c: fetch_daily(c) for c in SYMBOLS} + +print(f"{'标的':11} {'可回放日':>7} {'跌破门槛':>8} {'收在门槛上':>10} {'入场信号':>8} " + f"{'低于门槛的天数占比':>18} {'现价18}") +for code in SYMBOLS: + series = precondition(data[code], EtfSymbolConfig(**SYMBOL_PARAMS[code]), BASE) + below = above = signal = 0 + below_ma = 0 + for bar, ind in series: + entry, ma60 = ind["etf_entry"], ind["etf_ma60"] + if bar["low"] <= entry: + below += 1 + if bar["close"] > entry and bar["low"] <= entry: + above += 1 + if bar["low"] <= entry and bar["close"] > entry: + signal += 1 + if bar["close"] < ma60: + below_ma += 1 + n = len(series) + print(f"{code:11} {n:7} {below:8} {above:10} {signal:8} " + f"{below / n * 100:17.1f}% {below_ma / n * 100:17.1f}%") + +print() +print("说明:'入场信号' = 当日最低价跌破入场门槛、且收盘价收回门槛之上(回测里建网的日线近似)。") +print(" 它是机会频率的上界:实盘还要再满足盘中反弹 0.5% 的确认。") diff --git a/labs/analysis/etf/results.json b/labs/analysis/etf/results.json new file mode 100644 index 0000000..941d7c4 --- /dev/null +++ b/labs/analysis/etf/results.json @@ -0,0 +1,1370 @@ +{ + "generated_at": "2026-09-19T19:43:23", + "config": { + "symbols": { + "588000.SH": { + "is_t0": false, + "buy_shares": 10000, + "max_shares": 100000, + "atr_multiplier": 0.5, + "inner_step": 0.9 + }, + "510300.SH": { + "is_t0": false, + "buy_shares": 4000, + "max_shares": 20000, + "atr_multiplier": 1.0, + "inner_step": 0.7 + }, + "518880.SH": { + "is_t0": true, + "buy_shares": 2000, + "max_shares": 10000, + "atr_multiplier": 1.0, + "inner_step": 0.8 + } + }, + "defaults": { + "atr_period": 14, + "min_grid_pct": 0.5, + "max_grid_span_pct": 40, + "channel_period": 20, + "channel_pct": 15, + "rebound_pct": 0.5, + "add_pct": 3.0, + "max_adds": 9, + "watch_seconds": 600, + "min_profit_pct": 1.0, + "inner_grids": 2.0, + "min_hold_days": 1, + "max_hold_days": 0, + "commission_rate": 0.0003, + "min_commission": 5.0, + "max_tick_age_seconds": 90 + }, + "account": { + "start_cash": 500000.0, + "min_cash_ratio": 0.1 + } + }, + "period": { + "first": "2025-12-22", + "last": "2026-09-18", + "days": 182 + }, + "data": { + "588000.SH": { + "bars": 242, + "first": "20250919", + "last": "20260918", + "first_close": 1.431, + "last_close": 1.744, + "year_return_pct": 21.87281621243884 + }, + "510300.SH": { + "bars": 242, + "first": "20250919", + "last": "20260918", + "first_close": 4.604, + "last_close": 4.582, + "year_return_pct": -0.47784535186794486 + }, + "518880.SH": { + "bars": 242, + "first": "20250919", + "last": "20260918", + "first_close": 7.898, + "last_close": 9.009, + "year_return_pct": 14.066852367688032 + } + }, + "modes": { + "touch": { + "days": 182, + "net": -12217.162741794857, + "gross": -11815.37290307926, + "fees": 401.7898387155962, + "fee_share_of_gross": 0.0, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "buy_count": 38, + "sell_count": 32, + "buy_amount": 664704.8151442, + "turnover_x": 1.3294096302884, + "avg_deployed": 11216.395604395604, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0, + "final_equity": 506110.83725820493, + "cash": 487782.83725820493 + }, + "bounce": { + "days": 182, + "net": -28229.457147947895, + "gross": -27885.70857351995, + "fees": 343.748574427944, + "fee_share_of_gross": 0.0, + "return_pct": 1.685308570410416, + "max_dd_pct": 0.5578629013060243, + "buy_count": 33, + "sell_count": 27, + "buy_amount": 577416.12, + "turnover_x": 1.15483224, + "avg_deployed": 17016.527472527472, + "avg_util_pct": 3.4033054945054944, + "max_deployed": 89248.0, + "final_equity": 508426.5428520521, + "cash": 471770.5428520521 + }, + "close": { + "days": 182, + "net": -110743.337, + "gross": -110546.0, + "fees": 197.337, + "fee_share_of_gross": 0.0, + "return_pct": -0.5102673999999883, + "max_dd_pct": 1.6764473660383183, + "buy_count": 21, + "sell_count": 13, + "buy_amount": 378238.0, + "turnover_x": 0.756476, + "avg_deployed": 33532.1978021978, + "avg_util_pct": 6.70643956043956, + "max_deployed": 124144.0, + "final_equity": 497448.66300000006, + "cash": 389256.66300000006 + } + }, + "base": { + "days": 182, + "net": -12217.162741794857, + "gross": -11815.37290307926, + "fees": 401.7898387155962, + "fee_share_of_gross": 0.0, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "buy_count": 38, + "sell_count": 32, + "buy_amount": 664704.8151442, + "turnover_x": 1.3294096302884, + "avg_deployed": 11216.395604395604, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0, + "final_equity": 506110.83725820493, + "cash": 487782.83725820493, + "per_symbol": { + "588000.SH": { + "base": 12, + "adds": 1, + "exits": 12, + "levels": 0, + "held_shares": 0, + "max_level": 2, + "net": 2011.9384958780538 + }, + "510300.SH": { + "base": 14, + "adds": 1, + "exits": 13, + "levels": 0, + "held_shares": 4000, + "max_level": 2, + "net": -15961.171350091758 + }, + "518880.SH": { + "base": 7, + "adds": 3, + "exits": 7, + "levels": 0, + "held_shares": 0, + "max_level": 4, + "net": 1732.0701124187835 + } + }, + "params": { + "fill_mode": "touch", + "secondary_exit": true, + "min_hold_days": 1, + "add_pct": 3.0, + "min_profit_pct": 1.0, + "channel_pct": 15.0, + "max_adds": 9, + "commission_rate": 0.0003, + "min_commission": 5.0 + }, + "fee_pct_of_buy": 0.060446355970572074 + }, + "exposure": { + "days": 182, + "days_with_position": 73, + "time_in_market_pct": 40.10989010989011, + "avg_deployed": 11216.395604395604, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0, + "max_util_pct": 17.4104 + }, + "round_trips": { + "count": 32, + "wins": 32, + "losses": 0, + "avg_days": 3.71875, + "max_days": 23, + "avg_levels": 1.15625, + "max_levels": 4, + "avg_profit": 195.81189181890886, + "best": 684.4796062884866, + "worst": 139.36000000000058, + "detail": [ + { + "code": "510300.SH", + "opened": "2026-01-21", + "closed": "2026-01-29", + "days": 8, + "levels": 1, + "shares": 4000, + "buy": 18918.2, + "sell": 19113.11, + "profit": 183.5, + "return_pct": 0.97, + "fees": 11.41 + }, + { + "code": "510300.SH", + "opened": "2026-01-30", + "closed": "2026-02-10", + "days": 11, + "levels": 1, + "shares": 4000, + "buy": 18761.8, + "sell": 18955.1, + "profit": 181.99, + "return_pct": 0.97, + "fees": 11.32 + }, + { + "code": "588000.SH", + "opened": "2026-03-05", + "closed": "2026-03-06", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 14788.0, + "sell": 14940.93, + "profit": 142.93, + "return_pct": 0.967, + "fees": 10.0 + }, + { + "code": "510300.SH", + "opened": "2026-03-09", + "closed": "2026-03-10", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 18370.8, + "sell": 18560.07, + "profit": 178.2, + "return_pct": 0.97, + "fees": 11.08 + }, + { + "code": "588000.SH", + "opened": "2026-03-09", + "closed": "2026-03-10", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 14486.0, + "sell": 14635.91, + "profit": 139.91, + "return_pct": 0.966, + "fees": 10.0 + }, + { + "code": "588000.SH", + "opened": "2026-03-12", + "closed": "2026-03-13", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 14465.0, + "sell": 14614.7, + "profit": 139.7, + "return_pct": 0.966, + "fees": 10.0 + }, + { + "code": "588000.SH", + "opened": "2026-03-16", + "closed": "2026-03-17", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 14431.0, + "sell": 14580.36, + "profit": 139.36, + "return_pct": 0.966, + "fees": 10.0 + }, + { + "code": "518880.SH", + "opened": "2026-03-24", + "closed": "2026-03-25", + "days": 1, + "levels": 1, + "shares": 2000, + "buy": 18565.9, + "sell": 18757.18, + "profit": 180.09, + "return_pct": 0.97, + "fees": 11.2 + }, + { + "code": "510300.SH", + "opened": "2026-03-19", + "closed": "2026-04-08", + "days": 20, + "levels": 2, + "shares": 4000, + "buy": 36190.48, + "sell": 36563.35, + "profit": 351.04, + "return_pct": 0.97, + "fees": 21.83 + }, + { + "code": "588000.SH", + "opened": "2026-03-18", + "closed": "2026-04-10", + "days": 23, + "levels": 2, + "shares": 10000, + "buy": 28429.07, + "sell": 28723.46, + "profit": 275.77, + "return_pct": 0.97, + "fees": 18.62 + }, + { + "code": "518880.SH", + "opened": "2026-04-28", + "closed": "2026-05-07", + "days": 9, + "levels": 1, + "shares": 2000, + "buy": 19435.6, + "sell": 19635.84, + "profit": 188.52, + "return_pct": 0.97, + "fees": 11.72 + }, + { + "code": "518880.SH", + "opened": "2026-05-22", + "closed": "2026-05-25", + "days": 3, + "levels": 1, + "shares": 2000, + "buy": 18866.8, + "sell": 19061.18, + "profit": 183.01, + "return_pct": 0.97, + "fees": 11.38 + }, + { + "code": "510300.SH", + "opened": "2026-06-09", + "closed": "2026-06-10", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 18991.47, + "sell": 19187.14, + "profit": 184.22, + "return_pct": 0.97, + "fees": 11.45 + }, + { + "code": "510300.SH", + "opened": "2026-06-11", + "closed": "2026-06-12", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 19002.4, + "sell": 19198.18, + "profit": 184.32, + "return_pct": 0.97, + "fees": 11.46 + }, + { + "code": "518880.SH", + "opened": "2026-06-05", + "closed": "2026-06-15", + "days": 10, + "levels": 4, + "shares": 2000, + "buy": 70565.57, + "sell": 71292.61, + "profit": 684.48, + "return_pct": 0.97, + "fees": 42.56 + }, + { + "code": "518880.SH", + "opened": "2026-06-29", + "closed": "2026-07-03", + "days": 4, + "levels": 1, + "shares": 2000, + "buy": 16878.1, + "sell": 17052.0, + "profit": 163.72, + "return_pct": 0.97, + "fees": 10.18 + }, + { + "code": "510300.SH", + "opened": "2026-07-14", + "closed": "2026-07-15", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 19023.4, + "sell": 19219.4, + "profit": 184.53, + "return_pct": 0.97, + "fees": 11.47 + }, + { + "code": "510300.SH", + "opened": "2026-07-20", + "closed": "2026-07-21", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 18513.4, + "sell": 18704.14, + "profit": 179.58, + "return_pct": 0.97, + "fees": 11.17 + }, + { + "code": "518880.SH", + "opened": "2026-07-14", + "closed": "2026-07-21", + "days": 7, + "levels": 1, + "shares": 2000, + "buy": 16676.6, + "sell": 16848.42, + "profit": 161.76, + "return_pct": 0.97, + "fees": 10.06 + }, + { + "code": "588000.SH", + "opened": "2026-07-21", + "closed": "2026-07-22", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 18349.5, + "sell": 18538.55, + "profit": 177.99, + "return_pct": 0.97, + "fees": 11.07 + }, + { + "code": "588000.SH", + "opened": "2026-07-27", + "closed": "2026-07-28", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 18349.5, + "sell": 18538.55, + "profit": 177.99, + "return_pct": 0.97, + "fees": 11.07 + }, + { + "code": "510300.SH", + "opened": "2026-07-28", + "closed": "2026-07-29", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 18488.8, + "sell": 18679.29, + "profit": 179.34, + "return_pct": 0.97, + "fees": 11.15 + }, + { + "code": "510300.SH", + "opened": "2026-07-30", + "closed": "2026-07-31", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 18391.8, + "sell": 18581.29, + "profit": 178.4, + "return_pct": 0.97, + "fees": 11.09 + }, + { + "code": "510300.SH", + "opened": "2026-08-03", + "closed": "2026-08-04", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 18391.8, + "sell": 18581.29, + "profit": 178.4, + "return_pct": 0.97, + "fees": 11.09 + }, + { + "code": "588000.SH", + "opened": "2026-08-05", + "closed": "2026-08-06", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 17476.5, + "sell": 17656.56, + "profit": 169.52, + "return_pct": 0.97, + "fees": 10.54 + }, + { + "code": "588000.SH", + "opened": "2026-08-24", + "closed": "2026-08-25", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 16746.0, + "sell": 16918.53, + "profit": 162.43, + "return_pct": 0.97, + "fees": 10.1 + }, + { + "code": "510300.SH", + "opened": "2026-08-31", + "closed": "2026-09-01", + "days": 1, + "levels": 1, + "shares": 4000, + "buy": 18477.6, + "sell": 18667.97, + "profit": 179.23, + "return_pct": 0.97, + "fees": 11.14 + }, + { + "code": "510300.SH", + "opened": "2026-09-02", + "closed": "2026-09-04", + "days": 2, + "levels": 1, + "shares": 4000, + "buy": 18477.6, + "sell": 18667.97, + "profit": 179.23, + "return_pct": 0.97, + "fees": 11.14 + }, + { + "code": "588000.SH", + "opened": "2026-09-03", + "closed": "2026-09-04", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 16882.0, + "sell": 17055.94, + "profit": 163.75, + "return_pct": 0.97, + "fees": 10.18 + }, + { + "code": "588000.SH", + "opened": "2026-09-07", + "closed": "2026-09-08", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 16882.0, + "sell": 17055.94, + "profit": 163.75, + "return_pct": 0.97, + "fees": 10.18 + }, + { + "code": "588000.SH", + "opened": "2026-09-15", + "closed": "2026-09-16", + "days": 1, + "levels": 1, + "shares": 10000, + "buy": 16377.5, + "sell": 16546.33, + "profit": 158.83, + "return_pct": 0.97, + "fees": 10.0 + }, + { + "code": "518880.SH", + "opened": "2026-09-17", + "closed": "2026-09-18", + "days": 1, + "levels": 1, + "shares": 2000, + "buy": 17577.03, + "sell": 17758.13, + "profit": 170.5, + "return_pct": 0.97, + "fees": 10.6 + } + ] + }, + "attribution": { + "per_symbol": { + "588000.SH": { + "rounds": 12, + "net_at_cost": 2011.9384958780538, + "unrealized": 0.0, + "net_at_market": 2011.9384958780538, + "open_cost": 0.0, + "open_volume": 0 + }, + "510300.SH": { + "rounds": 13, + "net_at_cost": -15961.171350091758, + "unrealized": -155.14328000000023, + "net_at_market": -16116.314630091758, + "open_cost": 18483.14328, + "open_volume": 4000 + }, + "518880.SH": { + "rounds": 7, + "net_at_cost": 1732.0701124187835, + "unrealized": 0.0, + "net_at_market": 1732.0701124187835, + "open_cost": 0.0, + "open_volume": 0 + } + }, + "unrealized": -155.14328000000023, + "equity_delta": 6110.837258204934, + "check": -12372.30602179492, + "cash_delta": -12217.162741795066, + "open_cost": 18483.14328, + "open_positions": [ + { + "code": "510300.SH", + "volume": 4000, + "avg_cost": 4.62078582, + "anchor": 4.6194, + "max_level": 2 + } + ] + }, + "monthly": { + "2025-12": { + "pnl": 0.0, + "pct": 0.0, + "end_equity": 500000.0 + }, + "2026-01": { + "pnl": 260.0762803356047, + "pct": 0.052015256067120944, + "end_equity": 500260.0762803356 + }, + "2026-02": { + "pnl": 105.4162945523858, + "pct": 0.021072298100660875, + "end_equity": 500365.492574888 + }, + "2026-03": { + "pnl": -773.1505103461095, + "pct": -0.1545171523254863, + "end_equity": 499592.3420645419 + }, + "2026-04": { + "pnl": 2234.720452075766, + "pct": 0.44730878836951116, + "end_equity": 501827.06251661765 + }, + "2026-05": { + "pnl": 456.96047831187025, + "pct": 0.09105935339960633, + "end_equity": 502284.0229949295 + }, + "2026-06": { + "pnl": 925.8532293126918, + "pct": 0.18432862422980914, + "end_equity": 503209.8762242422 + }, + "2026-07": { + "pnl": 1530.4599493968417, + "pct": 0.30413948964603243, + "end_equity": 504740.33617363905 + }, + "2026-08": { + "pnl": 767.2106473601307, + "pct": 0.15200105725178215, + "end_equity": 505507.5468209992 + }, + "2026-09": { + "pnl": 603.2904372057528, + "pct": 0.11934350753014152, + "end_equity": 506110.83725820493 + } + }, + "entries": [ + { + "code": "510300.SH", + "day": "2026-01-21", + "price": 4.72955, + "pct_in_year": 53.086419753086425, + "pct_in_60d": 43.333333333333336 + }, + { + "code": "510300.SH", + "day": "2026-01-30", + "price": 4.69045, + "pct_in_year": 28.40909090909091, + "pct_in_60d": 28.333333333333332 + }, + { + "code": "588000.SH", + "day": "2026-03-05", + "price": 1.4788, + "pct_in_year": 47.16981132075472, + "pct_in_60d": 40.0 + }, + { + "code": "510300.SH", + "day": "2026-03-09", + "price": 4.5927, + "pct_in_year": 1.8518518518518516, + "pct_in_60d": 0.0 + }, + { + "code": "588000.SH", + "day": "2026-03-09", + "price": 1.4485999999999999, + "pct_in_year": 34.25925925925926, + "pct_in_60d": 33.33333333333333 + }, + { + "code": "588000.SH", + "day": "2026-03-12", + "price": 1.4465, + "pct_in_year": 33.33333333333333, + "pct_in_60d": 28.333333333333332 + }, + { + "code": "588000.SH", + "day": "2026-03-16", + "price": 1.4431, + "pct_in_year": 32.743362831858406, + "pct_in_60d": 25.0 + }, + { + "code": "588000.SH", + "day": "2026-03-18", + "price": 1.4431, + "pct_in_year": 33.04347826086956, + "pct_in_60d": 23.333333333333332 + }, + { + "code": "510300.SH", + "day": "2026-03-19", + "price": 4.5927, + "pct_in_year": 1.7241379310344827, + "pct_in_60d": 0.0 + }, + { + "code": "518880.SH", + "day": "2026-03-24", + "price": 9.282950000000001, + "pct_in_year": 46.21848739495798, + "pct_in_60d": 1.6666666666666667 + }, + { + "code": "518880.SH", + "day": "2026-04-28", + "price": 9.7178, + "pct_in_year": 55.94405594405595, + "pct_in_60d": 11.666666666666666 + }, + { + "code": "518880.SH", + "day": "2026-05-22", + "price": 9.4334, + "pct_in_year": 39.87341772151899, + "pct_in_60d": 5.0 + }, + { + "code": "518880.SH", + "day": "2026-06-05", + "price": 9.2277, + "pct_in_year": 31.547619047619047, + "pct_in_60d": 3.3333333333333335 + }, + { + "code": "510300.SH", + "day": "2026-06-09", + "price": 4.747866666666667, + "pct_in_year": 61.76470588235294, + "pct_in_60d": 45.0 + }, + { + "code": "510300.SH", + "day": "2026-06-11", + "price": 4.7506, + "pct_in_year": 61.627906976744185, + "pct_in_60d": 41.66666666666667 + }, + { + "code": "518880.SH", + "day": "2026-06-29", + "price": 8.43905, + "pct_in_year": 5.46448087431694, + "pct_in_60d": 3.3333333333333335 + }, + { + "code": "510300.SH", + "day": "2026-07-14", + "price": 4.75585, + "pct_in_year": 56.18556701030928, + "pct_in_60d": 8.333333333333332 + }, + { + "code": "518880.SH", + "day": "2026-07-14", + "price": 8.3383, + "pct_in_year": 4.639175257731959, + "pct_in_60d": 3.3333333333333335 + }, + { + "code": "510300.SH", + "day": "2026-07-20", + "price": 4.62835, + "pct_in_year": 14.14141414141414, + "pct_in_60d": 1.6666666666666667 + }, + { + "code": "588000.SH", + "day": "2026-07-21", + "price": 1.83495, + "pct_in_year": 82.91457286432161, + "pct_in_60d": 43.333333333333336 + }, + { + "code": "588000.SH", + "day": "2026-07-27", + "price": 1.83495, + "pct_in_year": 81.2807881773399, + "pct_in_60d": 36.666666666666664 + }, + { + "code": "510300.SH", + "day": "2026-07-28", + "price": 4.6222, + "pct_in_year": 12.745098039215685, + "pct_in_60d": 1.6666666666666667 + }, + { + "code": "510300.SH", + "day": "2026-07-30", + "price": 4.59795, + "pct_in_year": 8.25242718446602, + "pct_in_60d": 1.6666666666666667 + }, + { + "code": "510300.SH", + "day": "2026-08-03", + "price": 4.59795, + "pct_in_year": 8.173076923076923, + "pct_in_60d": 1.6666666666666667 + }, + { + "code": "588000.SH", + "day": "2026-08-05", + "price": 1.7476500000000001, + "pct_in_year": 72.85714285714285, + "pct_in_60d": 10.0 + }, + { + "code": "588000.SH", + "day": "2026-08-24", + "price": 1.6746, + "pct_in_year": 65.91928251121077, + "pct_in_60d": 3.3333333333333335 + }, + { + "code": "510300.SH", + "day": "2026-08-31", + "price": 4.6194, + "pct_in_year": 11.403508771929824, + "pct_in_60d": 6.666666666666667 + }, + { + "code": "510300.SH", + "day": "2026-09-02", + "price": 4.6194, + "pct_in_year": 11.304347826086957, + "pct_in_60d": 6.666666666666667 + }, + { + "code": "588000.SH", + "day": "2026-09-03", + "price": 1.6882, + "pct_in_year": 64.06926406926407, + "pct_in_60d": 3.3333333333333335 + }, + { + "code": "510300.SH", + "day": "2026-09-07", + "price": 4.6194, + "pct_in_year": 11.587982832618025, + "pct_in_60d": 8.333333333333332 + }, + { + "code": "588000.SH", + "day": "2026-09-07", + "price": 1.6882, + "pct_in_year": 63.94849785407726, + "pct_in_60d": 5.0 + }, + { + "code": "588000.SH", + "day": "2026-09-15", + "price": 1.63775, + "pct_in_year": 60.66945606694561, + "pct_in_60d": 3.3333333333333335 + }, + { + "code": "518880.SH", + "day": "2026-09-17", + "price": 8.788516666666668, + "pct_in_year": 21.991701244813278, + "pct_in_60d": 48.333333333333336 + } + ], + "scenarios": [ + { + "label": "基准(当前 _etf.yaml)", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "add_pct=2.0", + "net": -30799.94736335598, + "equity_delta": 5856.052636643872, + "return_pct": 1.1712105273287743, + "max_dd_pct": 0.362191578128889, + "bases": 32, + "adds": 4, + "exits": 31, + "levels": 0, + "fees": 378.0000298999632, + "avg_util_pct": 2.776320879120879, + "max_deployed": 53968.0 + }, + { + "label": "add_pct=4.0", + "net": -12533.694636186867, + "equity_delta": 5794.305363812891, + "return_pct": 1.1588610727625783, + "max_dd_pct": 0.35601009517578525, + "bases": 33, + "adds": 3, + "exits": 32, + "levels": 0, + "fees": 382.1527791428868, + "avg_util_pct": 2.3689736263736263, + "max_deployed": 68020.0 + }, + { + "label": "add_pct=5.0", + "net": -13223.752171088488, + "equity_delta": 5104.247828911408, + "return_pct": 1.0208495657822816, + "max_dd_pct": 0.4120121720368026, + "bases": 31, + "adds": 1, + "exits": 30, + "levels": 0, + "fees": 339.29179938849, + "avg_util_pct": 2.7111120879120874, + "max_deployed": 50104.0 + }, + { + "label": "min_profit_pct=0.5", + "net": -14852.614248991176, + "equity_delta": 3475.3857510084054, + "return_pct": 0.695077150201681, + "max_dd_pct": 0.49775665911030303, + "bases": 41, + "adds": 4, + "exits": 40, + "levels": 0, + "fees": 477.2158242090853, + "avg_util_pct": 1.5554857142857144, + "max_deployed": 87052.0 + }, + { + "label": "min_profit_pct=0.8", + "net": -13107.190031955877, + "equity_delta": 5220.809968043875, + "return_pct": 1.0441619936087752, + "max_dd_pct": 0.4858640341736691, + "bases": 37, + "adds": 4, + "exits": 36, + "levels": 0, + "fees": 432.95833897862684, + "avg_util_pct": 1.7988967032967034, + "max_deployed": 87052.0 + }, + { + "label": "min_profit_pct=1.5", + "net": -11767.411322957767, + "equity_delta": 6560.588677041989, + "return_pct": 1.312117735408398, + "max_dd_pct": 0.4380185975032919, + "bases": 23, + "adds": 4, + "exits": 22, + "levels": 0, + "fees": 284.56346539273045, + "avg_util_pct": 3.2108461538461537, + "max_deployed": 68020.0 + }, + { + "label": "min_profit_pct=2.0", + "net": -30665.812496475697, + "equity_delta": 5680.187503524357, + "return_pct": 1.1360375007048715, + "max_dd_pct": 0.4479529171982518, + "bases": 19, + "adds": 5, + "exits": 17, + "levels": 2, + "fees": 246.20245998905398, + "avg_util_pct": 3.9631406593406595, + "max_deployed": 68982.0 + }, + { + "label": "channel_pct=10.0", + "net": -12977.277376953234, + "equity_delta": 5350.722623046604, + "return_pct": 1.0701445246093209, + "max_dd_pct": 1.2885441921692946, + "bases": 27, + "adds": 6, + "exits": 26, + "levels": 0, + "fees": 349.58353194182376, + "avg_util_pct": 5.458457142857142, + "max_deployed": 138530.0 + }, + { + "label": "channel_pct=20.0", + "net": -31076.05800804742, + "equity_delta": 5579.941991952364, + "return_pct": 1.1159883983904728, + "max_dd_pct": 0.47993076770652093, + "bases": 28, + "adds": 6, + "exits": 27, + "levels": 0, + "fees": 352.60974654755, + "avg_util_pct": 3.2516285714285713, + "max_deployed": 71464.0 + }, + { + "label": "channel_pct=30.0", + "net": -13548.263829010517, + "equity_delta": 4779.736170989345, + "return_pct": 0.955947234197869, + "max_dd_pct": 0.3808433629271515, + "bases": 23, + "adds": 4, + "exits": 22, + "levels": 0, + "fees": 295.3572796351874, + "avg_util_pct": 2.525830769230769, + "max_deployed": 56244.0 + }, + { + "label": "max_adds=3", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "max_adds=5", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "max_adds=15", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "佣金率=0.0", + "net": -12178.477848557988, + "equity_delta": 6149.522151441837, + "return_pct": 1.2299044302883675, + "max_dd_pct": 0.47768400976145814, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 350.0, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "佣金率=0.0003", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "佣金率=0.001", + "net": -12684.385251932084, + "equity_delta": 5643.614748067863, + "return_pct": 1.1287229496135724, + "max_dd_pct": 0.490847267029592, + "bases": 32, + "adds": 6, + "exits": 31, + "levels": 0, + "fees": 1317.2201732096776, + "avg_util_pct": 2.325652747252747, + "max_deployed": 87052.0 + }, + { + "label": "无副出口", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "成交=反弹确认价(贴近实盘)", + "net": -28229.457147947895, + "equity_delta": 8426.54285205208, + "return_pct": 1.685308570410416, + "max_dd_pct": 0.5578629013060243, + "bases": 28, + "adds": 5, + "exits": 27, + "levels": 0, + "fees": 343.748574427944, + "avg_util_pct": 3.4033054945054944, + "max_deployed": 89248.0 + }, + { + "label": "成交=当日收盘价(悲观)", + "net": -110743.337, + "equity_delta": -2551.3369999999413, + "return_pct": -0.5102673999999883, + "max_dd_pct": 1.6764473660383183, + "bases": 15, + "adds": 6, + "exits": 13, + "levels": 0, + "fees": 197.337, + "avg_util_pct": 6.70643956043956, + "max_deployed": 124144.0 + }, + { + "label": "无 T+1 限制(min_hold_days=0)", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "inner_step=0.2(副出口可达)", + "net": -12479.318871855994, + "equity_delta": 5848.681128143682, + "return_pct": 1.1697362256287365, + "max_dd_pct": 0.47836191494784636, + "bases": 35, + "adds": 5, + "exits": 34, + "levels": 1, + "fees": 423.1035059767562, + "avg_util_pct": 2.0064615384615383, + "max_deployed": 87052.0 + }, + { + "label": "inner_step=0.4(副出口可达)", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "buy_shares×0.25", + "net": -3163.3612290021692, + "equity_delta": 1418.6387709979317, + "return_pct": 0.28372775419958635, + "max_dd_pct": 0.12358897743013728, + "bases": 32, + "adds": 6, + "exits": 31, + "levels": 0, + "fees": 345.35140186267313, + "avg_util_pct": 0.5814131868131868, + "max_deployed": 21763.0 + }, + { + "label": "buy_shares×0.5", + "net": -6086.81154649936, + "equity_delta": 3077.188453500683, + "return_pct": 0.6154376907001367, + "max_dd_pct": 0.24186131912265793, + "bases": 33, + "adds": 6, + "exits": 32, + "levels": 0, + "fees": 361.8318922203463, + "avg_util_pct": 1.1113516483516483, + "max_deployed": 43526.0 + }, + { + "label": "buy_shares×2.0", + "net": -24429.59607031032, + "equity_delta": 12226.403929689608, + "return_pct": 2.4452807859379218, + "max_dd_pct": 0.9518063547182583, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 790.5540655716184, + "avg_util_pct": 4.4865582417582415, + "max_deployed": 174104.0 + }, + { + "label": "atr_multiplier×0.5(仅格距)", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "atr_multiplier×2.0(仅格距)", + "net": -12217.162741794857, + "equity_delta": 6110.837258204934, + "return_pct": 1.2221674516409868, + "max_dd_pct": 0.4781122658411331, + "bases": 33, + "adds": 5, + "exits": 32, + "levels": 0, + "fees": 401.7898387155962, + "avg_util_pct": 2.2432791208791207, + "max_deployed": 87052.0 + }, + { + "label": "channel_pct=1(贴近区间下沿)", + "net": -14730.489636278688, + "equity_delta": 3287.510363721405, + "return_pct": 0.657502072744281, + "max_dd_pct": 0.6766650634139113, + "bases": 23, + "adds": 4, + "exits": 22, + "levels": 0, + "fees": 283.3292046885229, + "avg_util_pct": 3.0671406593406596, + "max_deployed": 79608.0 + } + ] +} \ No newline at end of file diff --git a/labs/analysis/etf/run.py b/labs/analysis/etf/run.py new file mode 100644 index 0000000..2c4dedd --- /dev/null +++ b/labs/analysis/etf/run.py @@ -0,0 +1,232 @@ +"""ETF 网格策略回测总报告生成器:一次跑完基准 + 三种成交模型 + 敏感性。 + +用法: + py -3.14 -B analysis/etf/run.py # 用缓存日线 + py -3.14 -B analysis/etf/run.py --refresh # 重新抓日线 + py -3.14 -B analysis/etf/run.py --ledger # 额外打印逐笔成交 + +输出: + analysis/etf/results.json 全部结构化结果 + analysis/etf/run_report.txt 人读的汇总表 +""" + +import argparse +from dataclasses import replace +from datetime import date, datetime +import json +import math +import statistics +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from backtest import ( # noqa: E402 + MIN_CASH_RATIO, OUT, REPO_DEFAULTS, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults, + EtfSymbolConfig, analyze, fees, fetch_daily, simulate, +) +from analysis import ( # noqa: E402 + entry_context, exposure, monthly, open_positions, round_trips, scenario_table, +) + + +def attribute(result, trips, data) -> dict: + """按标的拆解盈亏,两种口径都成立且与权益变动对齐。 + + ``net_at_cost``:把未了结仓位按**成本**入账(卖出额 − 全部买入额 − 佣金)。 + ``net_at_market``:加上未实现浮动(期末市值 − 未了结成本)。 + + 对冲校验:Σ net_at_market = 期末权益 − 期初权益。 + """ + rows = {} + for code in result["books"]: + book = result["books"][code] + fills = [f for f in result["fills"] if f.code == code] + closed_profit = sum( + (f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee + for f in fills + ) + unrealized = _unrealized(book, data, code) + rows[code] = { + "rounds": sum(1 for t in trips if t.code == code), + "net_at_cost": closed_profit, + "unrealized": unrealized, + "net_at_market": closed_profit + unrealized, + "open_cost": book.avg_cost * book.volume, + "open_volume": book.volume, + } + return rows + + +def _unrealized(book, data, code) -> float: + """未实现浮动:期末市值 − 未了结仓位成本。""" + if book.volume <= 0: + return 0.0 + close = data[code][-1]["close"] + return close * book.volume - book.avg_cost * book.volume + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--refresh", action="store_true") + parser.add_argument("--ledger", action="store_true") + args = parser.parse_args() + + data = {code: fetch_daily(code, args.refresh) for code in SYMBOLS} + defaults = REPO_DEFAULTS # 直接取仓库 _etf.yaml,避免与实盘配置漂移 + + # 三种成交模型:乐观(按触价)/ 贴近实盘(按反弹确认价)/ 悲观(按收盘价) + modes = {} + for mode in ("touch", "bounce", "close"): + result = simulate(data, fill_mode=mode) + modes[mode] = {"stats": analyze(result), "fills": result["fills"]} + + base = simulate(data, fill_mode="touch") + stats = analyze(base) + stats["fee_pct_of_buy"] = stats["fees"] / stats["buy_amount"] * 100 + trips = round_trips(base["fills"]) + per_symbol = attribute(base, trips, data) + unrealized = sum(r["unrealized"] for r in per_symbol.values()) + equity_delta = base["curve"][-1][1] - base["start_cash"] + + report = { + "generated_at": datetime.now().isoformat(timespec="seconds"), + "config": { + "symbols": { + code: SYMBOL_PARAMS[code] for code in SYMBOLS + }, + "defaults": { + field: getattr(defaults, field) + for field in ( + "atr_period", "min_grid_pct", "max_grid_span_pct", "channel_period", + "channel_pct", "rebound_pct", "add_pct", "max_adds", "watch_seconds", + "min_profit_pct", "inner_grids", "min_hold_days", "max_hold_days", + "commission_rate", "min_commission", "max_tick_age_seconds", + ) + }, + "account": {"start_cash": START_CASH, "min_cash_ratio": MIN_CASH_RATIO}, + }, + "period": { + "first": base["curve"][0][0].isoformat(), + "last": base["curve"][-1][0].isoformat(), + "days": len(base["curve"]), + }, + "data": { + code: { + "bars": len(data[code]), + "first": data[code][0]["date"], + "last": data[code][-1]["date"], + "first_close": data[code][0]["close"], + "last_close": data[code][-1]["close"], + "year_return_pct": (data[code][-1]["close"] / data[code][0]["close"] - 1) * 100, + } + for code in SYMBOLS + }, + "modes": { + mode: {k: v for k, v in payload["stats"].items() if k not in ("per_symbol", "params")} + for mode, payload in modes.items() + }, + "base": stats, + "exposure": exposure(base), + "round_trips": { + "count": len(trips), + "wins": sum(1 for t in trips if t.profit > 0), + "losses": sum(1 for t in trips if t.profit <= 0), + "avg_days": statistics.fmean([t.days for t in trips]) if trips else 0.0, + "max_days": max([t.days for t in trips], default=0), + "avg_levels": statistics.fmean([t.levels for t in trips]) if trips else 0.0, + "max_levels": max([t.levels for t in trips], default=0), + "avg_profit": statistics.fmean([t.profit for t in trips]) if trips else 0.0, + "best": max([t.profit for t in trips], default=0.0), + "worst": min([t.profit for t in trips], default=0.0), + "detail": [ + { + "code": t.code, "opened": t.opened.isoformat(), "closed": t.closed.isoformat(), + "days": t.days, "levels": t.levels, "shares": t.shares, + "buy": round(t.buy_amount, 2), "sell": round(t.sell_amount, 2), + "profit": round(t.profit, 2), "return_pct": round(t.return_pct, 3), + "fees": round(t.fees, 2), + } + for t in trips + ], + }, + "attribution": { + "per_symbol": per_symbol, + "unrealized": unrealized, + "equity_delta": equity_delta, + "check": sum(r["net_at_market"] for r in per_symbol.values()), + "cash_delta": base["cash"] - base["start_cash"], + "open_cost": sum(r["open_cost"] for r in per_symbol.values()), + "open_positions": open_positions(base), + }, + "monthly": monthly(base), + "entries": entry_context(data, SYMBOL_PARAMS, base), + "scenarios": scenario_table(data), + } + (OUT / "results.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + lines = [] + lines.append(f"回测区间 {report['period']['first']} ~ {report['period']['last']} " + f"({report['period']['days']} 个交易日)") + lines.append("") + lines.append("== 数据 ==") + for code, row in report["data"].items(): + lines.append(f" {code} bars={row['bars']} {row['first']}..{row['last']} " + f"区间涨跌={row['year_return_pct']:+.2f}%") + lines.append("") + lines.append("== 三种成交模型的组合结果 ==") + lines.append(f"{'mode':8} {'net':>10} {'ret%':>7} {'maxDD%':>7} {'base':>5} {'add':>4} " + f"{'exit':>5} {'lvl':>4} {'fees':>7} {'util%':>6} {'maxDep':>8}") + for mode, row in report["modes"].items(): + adds = sum(1 for f in modes[mode]["fills"] if f.kind == "add") + levels = sum(1 for f in modes[mode]["fills"] if f.kind == "level") + lines.append( + f"{mode:8} {row['net']:10.2f} {row['return_pct']:7.3f} {row['max_dd_pct']:7.3f} " + f"{row['buy_count'] - adds:5} {adds:4} " + f"{sum(1 for f in modes[mode]['fills'] if f.kind == 'exit'):5} {levels:4} " + f"{row['fees']:7.2f} {row['avg_util_pct']:6.2f} {row['max_deployed']:8.0f}" + ) + lines.append("") + lines.append("== 逐标的归因 ==") + for code, row in per_symbol.items(): + lines.append(f" {code} 轮次={row['rounds']:2} 已了结+未了结成本={row['net_at_cost']:9.2f} " + f"未实现={row['unrealized']:8.2f} 按市价={row['net_at_market']:9.2f} " + f"(未了结 {row['open_volume']} 股,成本 {row['open_cost']:.2f})") + lines.append(f" 按成本口径合计={sum(r['net_at_cost'] for r in per_symbol.values()):.2f}" + f" ←→ 权益变动={equity_delta:.2f}(应相等)") + lines.append(f" 按市价口径合计={sum(r['net_at_market'] for r in per_symbol.values()):.2f}" + f" = 权益变动 {equity_delta:.2f} + 未实现 {unrealized:.2f} - 持仓成本 " + f"{sum(r['open_cost'] for r in per_symbol.values()):.2f}") + lines.append("") + lines.append("== 敏感性 ==") + lines.append(f"{'label':36} {'net':>10} {'ret%':>7} {'maxDD%':>7} {'base':>5} {'add':>4} " + f"{'exit':>5} {'lvl':>4} {'fees':>7} {'util%':>6} {'maxDep':>8}") + for row in report["scenarios"]: + lines.append( + f"{row['label']:36} {row['net']:10.2f} {row['return_pct']:7.3f} {row['max_dd_pct']:7.3f} " + f"{row['bases']:5} {row['adds']:4} {row['exits']:5} {row['levels']:4} " + f"{row['fees']:7.2f} {row['avg_util_pct']:6.2f} {row['max_deployed']:8.0f}" + ) + lines.append("") + lines.append("== 完整轮次明细 ==") + for t in report["round_trips"]["detail"]: + lines.append(f" {t['opened']} → {t['closed']} {t['code']} {t['days']:3}天 " + f"档位={t['levels']} 买={t['buy']:9.2f} 卖={t['sell']:9.2f} " + f"净利={t['profit']:8.2f} 收益率={t['return_pct']:6.3f}%") + if args.ledger: + lines.append("") + lines.append("== 逐笔成交(touch 模型)==") + for f in base["fills"]: + lines.append(f" {f.day} {f.code} {f.side:4} {f.kind:4} {f.volume:6} " + f"@{f.price:.3f} fee={f.fee:5.2f} {f.note}") + + (OUT / "run_report.txt").write_text("\n".join(lines), encoding="utf-8") + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/labs/analysis/etf/run_report.txt b/labs/analysis/etf/run_report.txt new file mode 100644 index 0000000..7b34d46 --- /dev/null +++ b/labs/analysis/etf/run_report.txt @@ -0,0 +1,85 @@ +回测区间 2025-12-22 ~ 2026-09-18 (182 个交易日) + +== 数据 == + 588000.SH bars=242 20250919..20260918 区间涨跌=+21.87% + 510300.SH bars=242 20250919..20260918 区间涨跌=-0.48% + 518880.SH bars=242 20250919..20260918 区间涨跌=+14.07% + +== 三种成交模型的组合结果 == +mode net ret% maxDD% base add exit lvl fees util% maxDep +touch -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +bounce -28229.46 1.685 0.558 28 5 27 0 343.75 3.40 89248 +close -110743.34 -0.510 1.676 15 6 13 0 197.34 6.71 124144 + +== 逐标的归因 == + 588000.SH 轮次=12 已了结+未了结成本= 2011.94 未实现= 0.00 按市价= 2011.94 (未了结 0 股,成本 0.00) + 510300.SH 轮次=13 已了结+未了结成本=-15961.17 未实现= -155.14 按市价=-16116.31 (未了结 4000 股,成本 18483.14) + 518880.SH 轮次= 7 已了结+未了结成本= 1732.07 未实现= 0.00 按市价= 1732.07 (未了结 0 股,成本 0.00) + 按成本口径合计=-12217.16 ←→ 权益变动=6110.84(应相等) + 按市价口径合计=-12372.31 = 权益变动 6110.84 + 未实现 -155.14 - 持仓成本 18483.14 + +== 敏感性 == +label net ret% maxDD% base add exit lvl fees util% maxDep +基准(当前 _etf.yaml) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +add_pct=2.0 -30799.95 1.171 0.362 32 4 31 0 378.00 2.78 53968 +add_pct=4.0 -12533.69 1.159 0.356 33 3 32 0 382.15 2.37 68020 +add_pct=5.0 -13223.75 1.021 0.412 31 1 30 0 339.29 2.71 50104 +min_profit_pct=0.5 -14852.61 0.695 0.498 41 4 40 0 477.22 1.56 87052 +min_profit_pct=0.8 -13107.19 1.044 0.486 37 4 36 0 432.96 1.80 87052 +min_profit_pct=1.5 -11767.41 1.312 0.438 23 4 22 0 284.56 3.21 68020 +min_profit_pct=2.0 -30665.81 1.136 0.448 19 5 17 2 246.20 3.96 68982 +channel_pct=10.0 -12977.28 1.070 1.289 27 6 26 0 349.58 5.46 138530 +channel_pct=20.0 -31076.06 1.116 0.480 28 6 27 0 352.61 3.25 71464 +channel_pct=30.0 -13548.26 0.956 0.381 23 4 22 0 295.36 2.53 56244 +max_adds=3 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +max_adds=5 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +max_adds=15 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +佣金率=0.0 -12178.48 1.230 0.478 33 5 32 0 350.00 2.24 87052 +佣金率=0.0003 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +佣金率=0.001 -12684.39 1.129 0.491 32 6 31 0 1317.22 2.33 87052 +无副出口 -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +成交=反弹确认价(贴近实盘) -28229.46 1.685 0.558 28 5 27 0 343.75 3.40 89248 +成交=当日收盘价(悲观) -110743.34 -0.510 1.676 15 6 13 0 197.34 6.71 124144 +无 T+1 限制(min_hold_days=0) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +inner_step=0.2(副出口可达) -12479.32 1.170 0.478 35 5 34 1 423.10 2.01 87052 +inner_step=0.4(副出口可达) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +buy_shares×0.25 -3163.36 0.284 0.124 32 6 31 0 345.35 0.58 21763 +buy_shares×0.5 -6086.81 0.615 0.242 33 6 32 0 361.83 1.11 43526 +buy_shares×2.0 -24429.60 2.445 0.952 33 5 32 0 790.55 4.49 174104 +atr_multiplier×0.5(仅格距) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +atr_multiplier×2.0(仅格距) -12217.16 1.222 0.478 33 5 32 0 401.79 2.24 87052 +channel_pct=1(贴近区间下沿) -14730.49 0.658 0.677 23 4 22 0 283.33 3.07 79608 + +== 完整轮次明细 == + 2026-01-21 → 2026-01-29 510300.SH 8天 档位=1 买= 18918.20 卖= 19113.11 净利= 183.50 收益率= 0.970% + 2026-01-30 → 2026-02-10 510300.SH 11天 档位=1 买= 18761.80 卖= 18955.10 净利= 181.99 收益率= 0.970% + 2026-03-05 → 2026-03-06 588000.SH 1天 档位=1 买= 14788.00 卖= 14940.93 净利= 142.93 收益率= 0.967% + 2026-03-09 → 2026-03-10 510300.SH 1天 档位=1 买= 18370.80 卖= 18560.07 净利= 178.20 收益率= 0.970% + 2026-03-09 → 2026-03-10 588000.SH 1天 档位=1 买= 14486.00 卖= 14635.91 净利= 139.91 收益率= 0.966% + 2026-03-12 → 2026-03-13 588000.SH 1天 档位=1 买= 14465.00 卖= 14614.70 净利= 139.70 收益率= 0.966% + 2026-03-16 → 2026-03-17 588000.SH 1天 档位=1 买= 14431.00 卖= 14580.36 净利= 139.36 收益率= 0.966% + 2026-03-24 → 2026-03-25 518880.SH 1天 档位=1 买= 18565.90 卖= 18757.18 净利= 180.09 收益率= 0.970% + 2026-03-19 → 2026-04-08 510300.SH 20天 档位=2 买= 36190.48 卖= 36563.35 净利= 351.04 收益率= 0.970% + 2026-03-18 → 2026-04-10 588000.SH 23天 档位=2 买= 28429.07 卖= 28723.46 净利= 275.77 收益率= 0.970% + 2026-04-28 → 2026-05-07 518880.SH 9天 档位=1 买= 19435.60 卖= 19635.84 净利= 188.52 收益率= 0.970% + 2026-05-22 → 2026-05-25 518880.SH 3天 档位=1 买= 18866.80 卖= 19061.18 净利= 183.01 收益率= 0.970% + 2026-06-09 → 2026-06-10 510300.SH 1天 档位=1 买= 18991.47 卖= 19187.14 净利= 184.22 收益率= 0.970% + 2026-06-11 → 2026-06-12 510300.SH 1天 档位=1 买= 19002.40 卖= 19198.18 净利= 184.32 收益率= 0.970% + 2026-06-05 → 2026-06-15 518880.SH 10天 档位=4 买= 70565.57 卖= 71292.61 净利= 684.48 收益率= 0.970% + 2026-06-29 → 2026-07-03 518880.SH 4天 档位=1 买= 16878.10 卖= 17052.00 净利= 163.72 收益率= 0.970% + 2026-07-14 → 2026-07-15 510300.SH 1天 档位=1 买= 19023.40 卖= 19219.40 净利= 184.53 收益率= 0.970% + 2026-07-20 → 2026-07-21 510300.SH 1天 档位=1 买= 18513.40 卖= 18704.14 净利= 179.58 收益率= 0.970% + 2026-07-14 → 2026-07-21 518880.SH 7天 档位=1 买= 16676.60 卖= 16848.42 净利= 161.76 收益率= 0.970% + 2026-07-21 → 2026-07-22 588000.SH 1天 档位=1 买= 18349.50 卖= 18538.55 净利= 177.99 收益率= 0.970% + 2026-07-27 → 2026-07-28 588000.SH 1天 档位=1 买= 18349.50 卖= 18538.55 净利= 177.99 收益率= 0.970% + 2026-07-28 → 2026-07-29 510300.SH 1天 档位=1 买= 18488.80 卖= 18679.29 净利= 179.34 收益率= 0.970% + 2026-07-30 → 2026-07-31 510300.SH 1天 档位=1 买= 18391.80 卖= 18581.29 净利= 178.40 收益率= 0.970% + 2026-08-03 → 2026-08-04 510300.SH 1天 档位=1 买= 18391.80 卖= 18581.29 净利= 178.40 收益率= 0.970% + 2026-08-05 → 2026-08-06 588000.SH 1天 档位=1 买= 17476.50 卖= 17656.56 净利= 169.52 收益率= 0.970% + 2026-08-24 → 2026-08-25 588000.SH 1天 档位=1 买= 16746.00 卖= 16918.53 净利= 162.43 收益率= 0.970% + 2026-08-31 → 2026-09-01 510300.SH 1天 档位=1 买= 18477.60 卖= 18667.97 净利= 179.23 收益率= 0.970% + 2026-09-02 → 2026-09-04 510300.SH 2天 档位=1 买= 18477.60 卖= 18667.97 净利= 179.23 收益率= 0.970% + 2026-09-03 → 2026-09-04 588000.SH 1天 档位=1 买= 16882.00 卖= 17055.94 净利= 163.75 收益率= 0.970% + 2026-09-07 → 2026-09-08 588000.SH 1天 档位=1 买= 16882.00 卖= 17055.94 净利= 163.75 收益率= 0.970% + 2026-09-15 → 2026-09-16 588000.SH 1天 档位=1 买= 16377.50 卖= 16546.33 净利= 158.83 收益率= 0.970% + 2026-09-17 → 2026-09-18 518880.SH 1天 档位=1 买= 17577.03 卖= 17758.13 净利= 170.50 收益率= 0.970% \ No newline at end of file diff --git a/labs/analysis/etf/sweep.py b/labs/analysis/etf/sweep.py new file mode 100644 index 0000000..6e118d0 --- /dev/null +++ b/labs/analysis/etf/sweep.py @@ -0,0 +1,144 @@ +"""为什么收益率低 / 改进方案量化:同一天数据、同一策略逻辑,只改参数与下单规模。 + +用法: py -3.14 -B analysis/etf/sweep.py +""" + +from dataclasses import replace +import statistics +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from backtest import ( # noqa: E402 + START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults, EtfSymbolConfig, analyze, + fetch_daily, precondition, simulate, +) + +data = {code: fetch_daily(code) for code in SYMBOLS} +BASE = EtfDefaults() +_CACHE: dict[tuple, dict] = {} + + +def pre_for(defaults, params): + """按"影响指标计算"的参数缓存 precondition 结果。 + + 注意:``channel_pct`` / ``atr_period`` / ``channel_period`` / ``min_grid_pct`` 与逐标的 + ``atr_multiplier`` 会改变指标,必须进缓存键;``add_pct`` / ``min_profit_pct`` 只影响 + 下单判定,不进键。 + """ + key = ( + defaults.channel_pct, defaults.atr_period, defaults.channel_period, + defaults.min_grid_pct, + tuple(sorted((c, params[c]["atr_multiplier"]) for c in params)), + ) + if key not in _CACHE: + _CACHE[key] = { + c: precondition(data[c], EtfSymbolConfig(**params[c]), defaults) for c in data + } + return _CACHE[key] + + +def run(label, *, defaults=None, params=None, sizer=None, fill_mode="touch"): + defaults = defaults or BASE + params = params or SYMBOL_PARAMS + result = simulate(data, defaults=defaults, symbol_params=params, sizer=sizer, + fill_mode=fill_mode, precomputed=pre_for(defaults, params)) + stats = analyze(result) + adds = sum(1 for f in result["fills"] if f.kind == "add") + bases = sum(1 for f in result["fills"] if f.kind == "base") + trips = [ + (f.price * f.volume if f.side == "SELL" else -f.price * f.volume) - f.fee + for f in result["fills"] + ] + return { + "label": label, + "equity": stats["final_equity"] - result["start_cash"], + "ret_pct": stats["return_pct"], + "dd_pct": stats["max_dd_pct"], + "bases": bases, + "adds": adds, + "exits": sum(1 for f in result["fills"] if f.kind == "exit"), + "roi_on_deployed_pct": ( + (stats["final_equity"] - result["start_cash"]) / stats["avg_deployed"] * 100 + if stats["avg_deployed"] else 0.0 + ), + "util_pct": stats["avg_util_pct"], + "max_util_pct": stats["max_deployed"] / result["start_cash"] * 100, + "fees": stats["fees"], + } + + +def show(rows): + print(f"{'场景':46} {'权益变动':>10} {'收益率':>7} {'回撤':>6} {'底仓':>4} {'补仓':>4} " + f"{'占用ROI':>8} {'平均占用':>8} {'峰值占用':>8}") + for r in rows: + print(f"{r['label'][:46]:46} {r['equity']:10.2f} {r['ret_pct']:6.2f}% {r['dd_pct']:5.2f}% " + f"{r['bases']:4} {r['adds']:4} {r['roi_on_deployed_pct']:7.2f}% " + f"{r['util_pct']:7.2f}% {r['max_util_pct']:7.2f}%") + + +def sized(shares): + """把逐标的 buy_shares / max_shares 同步放大,保持 10 档容量不变。""" + return {c: {**SYMBOL_PARAMS[c], "buy_shares": shares, "max_shares": shares * 10} + for c in SYMBOLS} + + +print("=" * 132) +print("A. 只放大单档规模(其余参数一律不动)") +print("=" * 132) +show([run(f"buy_shares={n}(现值 1000)", params=sized(n)) for n in (1000, 2000, 5000, 10000, 20000)]) + +print() +print("=" * 132) +print("B. 按账户资金比例下单(sizer,替代固定股数;每档 = 现金的 x%)") +print("=" * 132) +rows = [] +for pct in (0.02, 0.05, 0.10, 0.20): + sizer = (lambda p: (lambda equity, price: int(equity * p / price)))(pct) + rows.append(run(f"每档 = 现金 {pct:.0%}", params=sized(20000), sizer=sizer)) +show(rows) + +print() +print("=" * 132) +print("C. 入场/补仓/止盈参数(规模固定 buy_shares=2000)") +print("=" * 132) +P2 = sized(2000) +show([ + run("基准参数(add 3% / profit 1% / channel 15%)", params=P2), + run("add_pct=2%", defaults=replace(BASE, add_pct=2.0), params=P2), + run("add_pct=1.5%", defaults=replace(BASE, add_pct=1.5), params=P2), + run("add_pct=1.0%", defaults=replace(BASE, add_pct=1.0), params=P2), + run("min_profit_pct=0.6%", defaults=replace(BASE, min_profit_pct=0.6), params=P2), + run("min_profit_pct=3%", defaults=replace(BASE, min_profit_pct=3.0), params=P2), + run("channel_pct=10%", defaults=replace(BASE, channel_pct=10.0), params=P2), + run("channel_pct=30%", defaults=replace(BASE, channel_pct=30.0), params=P2), + run("add 1.5% + profit 0.6%", defaults=replace(BASE, add_pct=1.5, min_profit_pct=0.6), params=P2), + run("add 1.5% + profit 0.6% + channel 30%", defaults=replace(BASE, add_pct=1.5, min_profit_pct=0.6, channel_pct=30.0), params=P2), +]) + +print() +print("=" * 132) +print("D. 组合方案(把 A/B/C 的结论叠起来)") +print("=" * 132) +best = [] +for shares in (2000, 5000, 10000): + for add_pct, profit in ((1.5, 0.6), (1.5, 1.0), (2.0, 0.6), (1.0, 0.6)): + label = f"buy={shares} add={add_pct}% profit={profit}%" + best.append(run(label, defaults=replace(BASE, add_pct=add_pct, min_profit_pct=profit), + params=sized(shares))) +best.sort(key=lambda r: -r["equity"]) +show(best[:10]) + +print() +print("=" * 132) +print("E. 现金利用率天花板:每档 = 现金 10%,同时放开通道与补仓(看能否把 20 万用起来)") +print("=" * 132) +rows = [] +for add_pct, profit, channel in ((3.0, 1.0, 15.0), (1.5, 0.6, 30.0), (1.0, 0.6, 30.0), (1.0, 0.5, 40.0)): + sizer = lambda equity, price: int(equity * 0.10 / price) + rows.append(run(f"add={add_pct}% profit={profit}% channel={channel}% 每档10%现金", + defaults=replace(BASE, add_pct=add_pct, min_profit_pct=profit, channel_pct=channel), + params=sized(20000), sizer=sizer)) +show(rows) diff --git a/labs/analysis/etf/universe.py b/labs/analysis/etf/universe.py new file mode 100644 index 0000000..195cba5 --- /dev/null +++ b/labs/analysis/etf/universe.py @@ -0,0 +1,53 @@ +"""加标的 vs 加仓位:同一引擎、按比例缩放资金,隔离"标的数量"的影响。 + +注意:把 universe 缩成 N 只、资金缩到 N/3 只能近似"同时持有更多标的", +但它同时缩短了白名单,所以结论按"资金可部署机会数"来读,而不是精确预测。 + +用法: py -3.14 -B analysis/etf/universe.py +""" + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +from backtest import ( # noqa: E402 + MIN_CASH_RATIO, START_CASH, SYMBOLS, SYMBOL_PARAMS, EtfDefaults, EtfSymbolConfig, + analyze, fetch_daily, precondition, simulate, +) + +BASE = EtfDefaults() +FULL = {code: fetch_daily(code) for code in SYMBOLS} + +# (白名单, 起始资金, 说明) +CASES = [] +for size in (1, 2, 3): + codes = SYMBOLS[:size] + CASES.append((f"白名单 {size} 只({'+'.join(c[:6] for c in codes)}),资金按 {size}/3 缩放", + codes, START_CASH * size / 3)) +# 同样 20 万资金,但只放 1 只 / 2 只 → 观察"钱多而标的少"是否浪费 +CASES.append(("白名单 1 只,但仍是 20 万资金", SYMBOLS[:1], START_CASH)) +CASES.append(("白名单 2 只,但仍是 20 万资金", SYMBOLS[:2], START_CASH)) +CASES.append(("白名单 3 只,20 万(基准)", SYMBOLS[:3], START_CASH)) +# 每档放大到 5000 股,资金同步放大到 100 万 → 检验"标的不变、仓位变大"的天花板 +CASES.append(("3 只 + buy_shares=5000,资金 100 万", SYMBOLS[:3], 1_000_000.0)) +CASES.append(("3 只 + buy_shares=10000,资金 200 万", SYMBOLS[:3], 2_000_000.0)) + +print(f"{'场景':52} {'资金':>10} {'权益变动':>10} {'收益率':>7} {'回撤':>6} " + f"{'平均占用':>8} {'峰值占用':>8} {'ROI/占用':>8} {'底仓':>4} {'补仓':>4}") +for label, codes, cash in CASES: + data = {c: FULL[c] for c in codes} + shares = 5000 if "5000" in label else 10000 if "10000" in label else SYMBOL_PARAMS[codes[0]]["buy_shares"] + params = {c: {**SYMBOL_PARAMS[c], "buy_shares": shares, "max_shares": shares * 10} for c in codes} + pre = {c: precondition(data[c], EtfSymbolConfig(**params[c]), BASE) for c in codes} + result = simulate(data, symbol_params=params, precomputed=pre, start_cash=cash) + stats = analyze(result) + delta = stats["final_equity"] - cash + util = stats["avg_deployed"] / cash * 100 + max_util = stats["max_deployed"] / cash * 100 + roi = delta / stats["avg_deployed"] * 100 if stats["avg_deployed"] else 0.0 + print(f"{label:52} {cash:10.0f} {delta:10.2f} {stats['return_pct']:6.2f}% " + f"{stats['max_dd_pct']:5.2f}% {util:7.2f}% {max_util:7.2f}% {roi:7.2f}% " + f"{sum(1 for f in result['fills'] if f.kind == 'base'):4} " + f"{sum(1 for f in result['fills'] if f.kind == 'add'):4}") diff --git a/labs/analysis/etf/vs_hold.py b/labs/analysis/etf/vs_hold.py new file mode 100644 index 0000000..c759615 --- /dev/null +++ b/labs/analysis/etf/vs_hold.py @@ -0,0 +1,130 @@ +"""ETF 网格策略 vs 同期买入持有(同一区间、同一份日线)。 + +用法: py -3.14 -B analysis/etf/vs_hold.py +""" + +import math +import statistics +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 START_CASH, SYMBOLS, analyze, fetch_daily, simulate # noqa: E402 + +data = {code: fetch_daily(code) for code in SYMBOLS} +result = simulate(data) +stats = analyze(result) +curve = result["curve"] +start_day = curve[0][0] +cash0 = result["start_cash"] +grid_final = stats["final_equity"] + +# ---- 买入持有:在回测首日按各标的收盘价等权买入,持到期末 ---- +bars_by_date = {code: {b["date"]: b for b in data[code]} for code in SYMBOLS} +first_stamp = start_day.strftime("%Y%m%d") +entry = {c: bars_by_date[c][first_stamp]["close"] for c in SYMBOLS} +last_stamp = curve[-1][0].strftime("%Y%m%d") +exit_ = {c: bars_by_date[c][last_stamp]["close"] for c in SYMBOLS} + +per_symbol_cash = cash0 / len(SYMBOLS) +hold_shares = {c: per_symbol_cash / entry[c] for c in SYMBOLS} # 含零股,忽略整手限制 +hold_curve = [] +for day, _, _, _ in curve: + stamp = day.strftime("%Y%m%d") + value = sum(hold_shares[c] * bars_by_date[c].get(stamp, {"close": entry[c]})["close"] + for c in SYMBOLS) + hold_curve.append((day, value)) +hold_final = hold_curve[-1][1] + + +def profile(curve_points): + """从权益曲线算总收益、最大回撤、日波动、夏普与卡玛。""" + values = [v for _, v in curve_points] + peak, max_dd = -math.inf, 0.0 + for value in values: + peak = max(peak, value) + max_dd = max(max_dd, (peak - value) / peak) + rets = [values[i] / values[i - 1] - 1 for i in range(1, len(values))] + mean = statistics.fmean(rets) if rets else 0.0 + sd = statistics.pstdev(rets) if len(rets) > 1 else 0.0 + total = values[-1] / values[0] - 1 + days = len(values) + annual = (1 + total) ** (252 / days) - 1 if days else 0.0 + return { + "total_pct": total * 100, + "annual_pct": annual * 100, + "max_dd_pct": max_dd * 100, + "daily_sd_pct": sd * 100, + "sharpe": (mean / sd * math.sqrt(252)) if sd > 0 else float("nan"), + "calmar": (total / max_dd) if max_dd > 0 else float("nan"), + } + + +grid_curve = [(day, equity) for day, equity, _, _ in curve] + +print("=" * 108) +print(f"区间 {start_day} ~ {curve[-1][0]}({len(curve)} 个交易日),账户 {cash0:,.0f} 元") +print("=" * 108) +print(f"{'策略':26} {'期末权益':>12} {'总收益':>9} {'年化':>8} {'最大回撤':>9} " + f"{'日波动':>8} {'夏普':>7} {'卡玛':>7}") +for label, points in (("ETF 网格(现配置)", grid_curve), ("等权买入持有", hold_curve)): + p = profile(points) + print(f"{label:26} {points[-1][1]:12,.2f} {p['total_pct']:8.2f}% {p['annual_pct']:7.2f}% " + f"{p['max_dd_pct']:8.2f}% {p['daily_sd_pct']:7.3f}% {p['sharpe']:7.2f} {p['calmar']:7.2f}") + +print() +print("== 逐标的买入持有(同一区间)==") +print(f"{'标的':11} {'期初':>8} {'期末':>8} {'涨跌':>9} {'期间最大回撤':>12}") +for code in SYMBOLS: + bars = [b for b in data[code] if b["date"] >= first_stamp and b["date"] <= last_stamp] + closes = [b["close"] for b in bars] + peak, dd = -math.inf, 0.0 + for value in closes: + peak = max(peak, value) + dd = max(dd, (peak - value) / peak) + print(f"{code:11} {closes[0]:8.3f} {closes[-1]:8.3f} " + f"{(closes[-1] / closes[0] - 1) * 100:8.2f}% {dd * 100:11.2f}%") + +print() +print("== 关键口径 ==") +print(f" 网格:平均资金占用 {stats['avg_util_pct']:.2f}%(峰值 {stats['max_deployed'] / cash0 * 100:.2f}%)," + f"买入名义 {stats['buy_amount']:,.0f}(换手 {stats['turnover_x']:.2f} 倍)," + f"佣金 {stats['fees']:.2f}") +print(f" 网格:占用部分的收益率(权益变动 ÷ 平均占用)= " + f"{(grid_final - cash0) / stats['avg_deployed'] * 100:.2f}%") +print(f" 买入持有:资金 100% 占用(从未空仓),无佣金/无交易") +print(f" 网格:完整轮次 {len([f for f in result['fills'] if f.kind == 'exit'])} 次," + f"胜率 100%(只在盈利 ≥{stats['params']['min_profit_pct']}% 时才卖)") + +# ---- 同风险口径:把网格放大到与买入持有相同回撤,比收益 ---- +print() +print("== 同回撤口径(把网格单档股数放大到回撤≈买入持有)==") +from dataclasses import replace # noqa: E402 +from backtest import SYMBOL_PARAMS, SYMBOLS as _SYMS # noqa: E402 + +hold_p = profile(hold_curve) +print(f" 目标回撤:买入持有 {hold_p['max_dd_pct']:.2f}%") +print(f"{'放大倍数':>8} {'权益变动':>12} {'总收益':>9} {'最大回撤':>9} {'平均占用':>9} {'夏普':>7} {'卡玛':>7}") +for factor in (1, 4, 10, 20, 35): + params = { + c: {**SYMBOL_PARAMS[c], + "buy_shares": max(100, int(SYMBOL_PARAMS[c]["buy_shares"] * factor)), + "max_shares": max(1000, int(SYMBOL_PARAMS[c]["max_shares"] * factor))} + for c in _SYMS + } + res = simulate(data, symbol_params=params) + st = analyze(res) + pts = [(day, eq) for day, eq, _, _ in res["curve"]] + p = profile(pts) + print(f"{factor:8}× {st['final_equity'] - cash0:12,.2f} {p['total_pct']:8.2f}% " + f"{p['max_dd_pct']:8.2f}% {st['avg_util_pct']:8.2f}% {p['sharpe']:7.2f} {p['calmar']:7.2f}") +print() +print("注:放大是外推(同一段历史、同一批机会等比放大),不是新样本的验证;") +print(" 放开股数后实际能否成交/是否滑点恶化,日线回测无法回答。") diff --git a/labs/benchmarks/hotpaths.py b/labs/benchmarks/hotpaths.py index e5dbdbc..14d237d 100644 --- a/labs/benchmarks/hotpaths.py +++ b/labs/benchmarks/hotpaths.py @@ -1,4 +1,4 @@ -"""Offline microbenchmarks; run with .venv/Scripts/python benchmarks/hotpaths.py.""" +"""Offline microbenchmarks; run with `py -3.14 -B labs/benchmarks/hotpaths.py`.""" import sys from datetime import datetime, time @@ -6,7 +6,8 @@ from pathlib import Path from statistics import median from timeit import repeat -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +# labs/benchmarks/ -> 仓库根 -> py-client +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "py-client")) from libs.calc import trading_time from sdk.models import _parse_datetime, _side diff --git a/labs/run_tests.py b/labs/run_tests.py new file mode 100644 index 0000000..92e8c56 --- /dev/null +++ b/labs/run_tests.py @@ -0,0 +1,47 @@ +"""labs 的统一入口:把 ``py-client`` 挂上 ``sys.path`` 后运行 labs/tests 下的全部单测。 + +为什么需要它:测试模块内部有 ``from tests.zt_harness import ...`` 这类导入, +所以 ``labs`` 必须作为顶层包被导入,同时 ``py-client`` 必须在 ``sys.path`` 上 +(``config`` / ``libs`` / ``sdk`` / ``strategy`` 都在那里)。单独用 +``python -m unittest discover`` 很难同时满足这两点,因此统一走这个脚本。 + +用法(在仓库任意位置执行): + py -3.14 -B labs/run_tests.py # 跑全部 + py -3.14 -B labs/run_tests.py -v # 详细 + py -3.14 -B labs/run_tests.py test_etf_signal # 只跑某个模块 +""" + +import sys +import unittest +from pathlib import Path + +LABS = Path(__file__).resolve().parent +REPO = LABS.parent +PY_CLIENT = REPO / "py-client" + +for path in (str(PY_CLIENT), str(REPO)): + if path not in sys.path: + sys.path.insert(0, path) + + +def build_suite(pattern: str, names: list[str]) -> unittest.TestSuite: + loader = unittest.TestLoader() + if names: + return loader.loadTestsFromNames(names) + start = LABS / "tests" + # top_level_dir 指向 labs:这样导入名是 tests.xxx,测试模块里的 + # "from tests.zt_harness import ..." 才能解析。 + return loader.discover(str(start), pattern=pattern, top_level_dir=str(LABS)) + + +def main(argv: list[str]) -> int: + args = [a for a in argv if not a.startswith("-")] + verbosity = 2 if any(a in ("-v", "--verbose") for a in argv) else 1 + names = [a if "." in a else f"tests.{a}" for a in args] + suite = build_suite("test_*.py", names) + result = unittest.TextTestRunner(verbosity=verbosity).run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/labs/tests/__init__.py b/labs/tests/__init__.py new file mode 100644 index 0000000..d61945b --- /dev/null +++ b/labs/tests/__init__.py @@ -0,0 +1,6 @@ +"""labs 的测试包。 + +测试模块内部使用 ``from tests.zt_harness import ...`` 这类绝对导入, +因此 ``labs`` 必须作为顶层包导入、``labs/tests`` 必须是包。 +统一入口见 ``labs/run_tests.py``。 +""" diff --git a/labs/tests/__pycache__/test_deal_model.cpython-314.pyc b/labs/tests/__pycache__/test_deal_model.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..479d39c3bfb0efc5cb204137f6dbcc86c9daf213 GIT binary patch literal 7575 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 literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_etf.cpython-314.pyc b/labs/tests/__pycache__/test_etf.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52c767efe7d503eedc4f50dc489375d835fe4ae8 GIT binary patch literal 54451 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 Pzx&AVNTgqgAIkp%DMR{o literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_etf_signal.cpython-314.pyc b/labs/tests/__pycache__/test_etf_signal.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..00e461e63ea27ba52a9ace8380151bdacf70372f GIT binary patch literal 31063 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 literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_ipo.cpython-314.pyc b/labs/tests/__pycache__/test_ipo.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec2eb747a0bdcd3836789d2a4e774aa341f42c8d GIT binary patch literal 24873 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 cCdb@thVreN9N#+h%|m~2a56?r{y#N3J literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_trend_collector.cpython-314.pyc b/labs/tests/__pycache__/test_trend_collector.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4385e717d8f619f2048af390d33a60a63d6abfd GIT binary patch literal 10550 zcmc&aYit`wdb7KHEJacxB}$Ym$&_r_GA)@>ELl$ENBoj3+7`{M6xwNM?yblbwbp8v z-W~c86xZN@Hp=0G#JHky4gsQE97O(6)IklzMGI7i=2H8B2Bfs|3L6Cp&;o6N{_w>H z-1=8BOD>m|sMzT}`VkuK&V1iIzWL^R&30A0s{jCizWUMm7wQ4{SM*|lwfOMhzW@NQ zg9Ms7{v5<9OC+a0ul*8;bF^&6eFqN2<}P885v5YsFZq6m}5d=cQq zmj@9500UkJ=K#bg0P#c(BpQGS&RHnTUs@QnQn(*L8%6j5v{M#8fFx!01L&Y^egLZ| zJ9NeXbW!A15OipD;}R>RjvBKaXJsfSv9j^)00062^1&CnT*OOl6o?yB15ryEMG;J1 zQ`p`j=01$ZvUrxDOVbU2xXqx5lE#w>?SR7ji(|%sU`Q#T2=p^{KL99VD`*2MHlb!-GtiLEt=!}{zf+=~}RuIIsI+H!LamFG+i-dHX6hK>6#iUY4d6p5f z8O<)UkbzJ_XH{nTNzEnlsS=%xroFvQYDQ#MosnA8hCHrE?A@WSeAORN{e5|V->hqe zbiG~kX3hKd>y5dtLkrysd3vR@>u2OWvf}|-^b-tp7!15eAa!dlvl0W_LA-RpDA2#* zVai}8UbJ+qOfZjR0K~8;n!_3})ni;Y6$qlPHAn!UV~NFUjQT{1!uJ-pCj>&k+`uxN zJ0?yBk9!STib$j=i=iMwScZt#ZP;dc`mD}ujakaZFY)ae_<^={4Nzyq8Z9Q6d!hG4l;bBr`I}JMi5Q5sn>`p%#Jn}Pex>wRJm3^S9!%#kQ7`jwd( z>r>5)wPa>&T6IZ*)0wP1rP-NeQp^hQ2BtZfiwwsz6FmFaG0=-3G&XVr*Q#VFCa3A7 zm|`{CMUl_Wu#sS0!35dFiSuj{O6cHfUO784!x@$$#Xv^$GP2A{kQPKpCmEh+Q@S$L zYLZNHilvdEniP3Xl#ptdkT6Q%=!~!-GAL_~Ows+3HEW7xcv&N}8D3;k zY)VE7Y``3dbwlmQyLQYs-5g%XDz4ELfAhOPzWQU;zccUOIXkk_v2Awb zotNG^skqu#Tt4)qx?1zDR>c)4wOzyBisdMwsNSo( z2UPdKqI=-6j0ExeaPYX_`p>&P@o*B(y0!B zc*zBcBd1megZ`OnO7s*);S^!mEXuOCXk{{Zykt`oDauOO44e#MQpboM5CW8aZ*jgc zJX*4~CRDPzLTTP*_LEGcD2Fjl2+LT`9I4n}t=YE$W`8{!&BABknqX3$seM*!Xwt+> zx;@%r_Gv&nh?ndPRYf@|m%*iqp~JKH<^G-fYBT0YRU7tU{hTt^_*Mfi-eEjNw;N9; zm{!-YJL`K3lyM`9{LMAc4q|wy;%j0l04 zrO#sUC$N!JRglH#bH*qp7^gD;&RU~=X1xJsdklY8Nvt%EDRY&Q1~FVeug*lOzvxpV zPCv~LIQ5VF^kDP*gVIyKc_ysoXTtinfEBEXNI?KV`WNpZ_`4T_xK>qkJZLVZRnKq& zon+)GT4Eq41}&Pc5PxbmQA)9rtdZ!fMi#`ApA;Sj=X+QEJ z4xnNf6-ZX3UC7Y8pv0s&Ax%SWh8>sCmTTS_Mw(_J&G5WJct2 zv2vD&5@NN4^j*PO8A9z2(}O`Vg6_OX2lbZ8EYH)BOHND2(YvkS9O-V8)QfPdQGv9x zgw(i(bD~C$L5UO6;nSL($_l52Br83S5cB}@1@wdjOxlg`Ju{5Jq>&F*NY}`QKjTP> zC>I3TltxHdp}-F`Cke&h>ztSY-T9ERLcz<@y%yQ@%yhR)%og1o2oe8u*Y3oIj?544 z3+;~lX~CKCZTk7htYd}rs$@%^Y`HeBkXu%(TUYD+KOeZ>@zM5M{eL^K+SqyXg*#(P z<4X^%L`~H!@sI?yjdNS)#})7HS=(y&zJ-C@%V%;wnozovv&7p~Z&nq0Q^??|w`uOx z+jWZfK+bb;*1ED|SE0EvPc|y#ww3PC8iJLs{T|`Pc|#$4#VB*n5(`$uDXxh za~~;H<=XldI#l=Zd+y^#UAb5Gd5sdK)aa{=(N~wE=hQ=q8m*?$MKRtD=?%IUn zYJWt4!x)XJ?g5ni7{O3^6uVCXhg;RB_^!#Ge^Gi*Gw_mwq%SEZ&_$#^nf1;4D zu8>Z1DJt2UCwmog_X_D!$<29kvqH8ZjM7q6vM*2e-8!cBhx7g6JAHarvE12sF2UR* zCmuNqGdYxaT=0n=7iOfokLyoIG1YzKu{;g{_}sERe8Bqo&W`X-$FHk9!+!U#_cVqr z-Y?u_*zf+Lo(%7Fe9=mV4_NP7oMB)6-5OMX*Y6B>dhc#Qt#@}i!-MX-dvu%w&J*q@ zoh>B(y0f(df0on)RpwYFQWRz;0!*ih!V@XeVG5OHc;cm;UnQrd9mGq?hbTJ1Yk(@5 zn)SvrgO$;ugxDG|4Od`Yn0FSdXrd}OLzJyN5YON-5cRJa2mlbnDSJ7dnZ(fp=gJgh zImMF!KjkQIqwq3=yV3aH*IQM2Z{bgSs_(9KmJ@W&qd$;1=eKS=a z)}fQ%_ib&%yP>NgwYD=~+c|GruI-t%>&gXb&TIa;mgT04LiUW(NWA8tKX1~+3SJ>DvglObYwv=j-M@HABt zs~A&JmKZ@q(aQqzQLKOWawg;ITQ0-8mE`4Hh9!i%AS^Ssj^Y3cpjro=7~1I z7$s2_>&A#mS>j4L1rK=HlEp(Hiw}1Kkg>!pm2%mY^5#^Hu?EV6u9njTx>P*54S<*> zf}acm-~gLJ#+tEIUZI%_h*^!E%CpNct8rbKdJFX&1JP0r+k}|)N#kEBoA4w?yp$`5 zB73|BjPtai>#=scQbxuE%3JO-P_@68KI_W+OjyhMB%-Apgb7iUfL;TJqy&(Bptx@h zRLUYyIOQ{>Hvp)5^fS)a`u8E?1hfrFSv%1~k;o4zt0#5B8<6$DO5YjsDMco*GTMzEGMBP zzK{|x$y$AKicL<_GAE>YmgWQ*GD4EoJeL>_(vzY@UzmY3li@TAa-KAIninS+o=%Fw zB$w7~Ss^R4`b3xvM^A||)T&E~0UalZY1J}hB=nI)VX7L=rL>wg*VvjJN?bb4N*c)t zkd-bnyk?)_ge+ub^zj3lPb7#-j2@!D27{Y4yPTasUnNK=$Eqg;G&hTA(d;05xje>4 zszb9CW~3(&G)sz0Lal0SoH{%{bUZvXR*3MCBd^(+REjw zG`?mzFHXqo@&&te*A;z;Kuc_zlOZd~^dxtYr3-8;yKX^CIlQ4C`>b7@Mo#S|J@Q*^ z?3^80@z%fFaLlew9!GJbKXVyKYk6P%hw;CUDVvYoC-5Px_5r}R)jb5b*LU9ry!EQ5E$?ZY z^L^6zVdE#QAGUrJxY@3F`d5Ox)!<-0IH(50i^1>%fHfY+^1<+&Z>2f#!M^wQeemLY zFV6QbHTUNFhw{xsj|kAzdd-p#hTpN=*O9;V_zHbrubUhHsA>MN;t73I=U?$PzB_t# zbgpCW!pEJTbbr`Af8pbvTQ8`6k$hi7@tt1rHLAXjysu+!e7^nTGoK_rOxz4FZ#%e9 zrT9*6*oY~_d(ZjGdAftg`xTC1XqU*yoV z=AvnIQCV!Mq3KCZk|ECttRT{~=1iiGYi3wDC8i{FJ0YPIzH|r`F;pZ_@#p9i*VD8N z8OSAR^ic^nfixIRYhIex`#@?Z$?`l+2QkfER!P(P$3TblD@_TdCG-LXSTagE{`VW; zb+BUf{1(Tp0}lvb^WH~K-;sp1`@1Ty^(6M)cAqtgeb-%OJ@BZ}Zym<&15`@rjzRAW ztzAFubg)@MsRb#D3iNe@MrH*LB9)@mCPg8cl_XYxp-KI-H@TREE$UUCn<(g9Jzr{t zS2CrA%rhl13?k{{?bx`^R004RA@>u}DeFDRmkrz??W6|T(hq!MAM4J T^~UH=M*rgUZwX-8q>uc+8F1js literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_zt_boot.cpython-314.pyc b/labs/tests/__pycache__/test_zt_boot.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b9db7192d67cdba9140e7ec59620b9352ad44fc GIT binary patch literal 42369 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 OukQY5E3%d2!TCSegSs;S literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_zt_config.cpython-314.pyc b/labs/tests/__pycache__/test_zt_config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc68352a3fd8c6804343fe7364c24036d3ffb5f5 GIT binary patch literal 9840 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 literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_zt_ownership.cpython-314.pyc b/labs/tests/__pycache__/test_zt_ownership.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b1181c85c8f1bc75ada242383d9d0343a4a0454f GIT binary patch literal 16325 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 HJhcA<^`4VP literal 0 HcmV?d00001 diff --git a/labs/tests/__pycache__/test_zt_rounds.cpython-314.pyc b/labs/tests/__pycache__/test_zt_rounds.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..03c4b60710fd8fc788252646de1982d855f6b98f GIT binary patch literal 31850 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 dict: + """构造接口口径的一行日线:以 close 为中轴,上下各半个 spread。""" + close = float(close) + return dict( + ts_code=code, + trade_date=day, + open=close, + high=close + spread / 2, + low=close - spread / 2, + close=close, + ) + + +def parse(*payload: dict, code: str = CODE, count: int = 120) -> list[dict]: + """把接口口径的日线交给 parse_daily,得到指标层使用的一行(含 date)。""" + return etf_signal.parse_daily(list(payload), code, TODAY, count) + + +def raw_payload( + count: int = 90, close: float = 10.0, spread: float = 1.0, code: str = CODE +) -> list[dict]: + """生成截至 LAST_DAY 的 count 根横盘日线(接口口径,升序)。""" + return [ + raw_bar( + (LAST_DAY - timedelta(days=count - 1 - index)).strftime("%Y%m%d"), + close, + spread, + code, + ) + for index in range(count) + ] + + +def bars(count: int = 90, close: float = 10.0, spread: float = 1.0) -> list[dict]: + """生成指标层口径(已解析)的 count 根横盘日线。""" + return parse(*raw_payload(count, close, spread)) + + +def symbol(**overrides) -> EtfSymbolConfig: + base = dict(is_t0=False, buy_shares=1000, atr_multiplier=1.0, inner_step=0.7) + base.update(overrides) + return EtfSymbolConfig(**{k: v for k, v in base.items() if v is not None}) + + +def runtime(etf: EtfConfig | None = None, host: str = "http://api.test") -> Mock: + rt = Mock() + rt.etf_cfg = etf if etf is not None else EtfConfig( + defaults=EtfDefaults(), symbols={CODE: symbol()} + ) + rt.global_cfg = GlobalConfig(api_host=host) + rt.account_cfg = AccountConfig(strategy="etf") + return rt + + +class ParseDailyTests(unittest.TestCase): + def test_bare_list_is_supported_and_sorted_ascending(self): + payload = [raw_bar("20260915"), raw_bar("20260912")] + rows = etf_signal.parse_daily(payload, CODE, TODAY) + self.assertEqual([row["date"] for row in rows], ["20260912", "20260915"]) + self.assertEqual(rows[0]["close"], 10.0) + + def test_legacy_envelope_is_supported(self): + payload = {"code": 0, "message": "", "details": [raw_bar("20260915")]} + self.assertEqual( + [row["date"] for row in etf_signal.parse_daily(payload, CODE, TODAY)], + ["20260915"], + ) + + def test_today_and_future_bars_are_dropped_then_limited(self): + payload = [ + raw_bar("20260916"), raw_bar("20260917"), + raw_bar("20260915"), raw_bar("20260914"), + ] + rows = etf_signal.parse_daily(payload, CODE, TODAY, count=1) + self.assertEqual([row["date"] for row in rows], ["20260915"]) + + def test_numeric_strings_are_converted(self): + rows = etf_signal.parse_daily([raw_bar("20260915", close="10.5")], CODE, TODAY) + self.assertEqual(rows[0]["close"], 10.5) + + def test_bad_payloads_are_rejected(self): + cases = [ + None, [], {}, {"code": 1, "message": "failed"}, + {"code": False, "details": [raw_bar("20260915")]}, + {"code": 0, "details": []}, {"code": 0, "details": None}, + ] + for payload in cases: + with self.subTest(payload=payload), self.assertRaises(ValueError): + etf_signal.parse_daily(payload, CODE, TODAY) + + def test_bad_rows_are_rejected(self): + cases = [ + [raw_bar("20260915", code=OTHER)], # 证券归属不一致 + [raw_bar("20260915"), raw_bar("20260915")], # 日期重复 + [raw_bar("20260230")], # 非法日期 + [raw_bar("20260915", close=float("nan"))], # 非有限 + [dict(raw_bar("20260915"), open=True)], # 布尔价格 + [dict(raw_bar("20260915"), low=12)], # OHLC 关系异常 + [dict(raw_bar("20260915"), close=None)], # 缺失价格 + ] + for rows in cases: + with self.subTest(rows=rows), self.assertRaises(ValueError): + etf_signal.parse_daily(rows, CODE, TODAY) + + def test_count_must_be_positive_integer(self): + for count in (0, -1, 1.5, True): + with self.subTest(count=count), self.assertRaises(ValueError): + etf_signal.parse_daily([raw_bar("20260915")], CODE, TODAY, count) + + +class CalculateTests(unittest.TestCase): + """横盘日线:ATR = spread、MA60 = close、格距 = max(ATR×倍数, MA60×0.5%, 0.001)。""" + + def test_flat_bars_produce_expected_indicators(self): + values = etf_signal.calculate(bars(), symbol(), EtfDefaults(), TODAY) + self.assertEqual(values[etf_signal.IND_MA60], 10.0) + self.assertEqual(values[etf_signal.IND_ATR], 1.0) + self.assertEqual(values[etf_signal.IND_CHANNEL_LOW], 9.5) + self.assertEqual(values[etf_signal.IND_CHANNEL_HIGH], 10.5) + # 入场门槛 = min(9.5 + 1×15%, 10) = 9.65 + self.assertAlmostEqual(values[etf_signal.IND_ENTRY], 9.65) + self.assertEqual(values[etf_signal.IND_GRID], 1.0) + self.assertEqual(values[etf_signal.IND_PRICE], 10.0) + self.assertAlmostEqual(values[etf_signal.IND_GRID_PCT], 10.0) + self.assertAlmostEqual(values[etf_signal.IND_ADD_PRICE], 9.7) + + def test_atr_multiplier_and_grid_floor(self): + low_atr = etf_signal.calculate(bars(spread=0.02), symbol(atr_multiplier=1.0), + EtfDefaults(), TODAY) + # ATR=0.02 低于 MA60×0.5% = 0.05 的百分比下限 + self.assertAlmostEqual(low_atr[etf_signal.IND_GRID], 0.05) + wide = etf_signal.calculate(bars(), symbol(atr_multiplier=0.5), EtfDefaults(), TODAY) + self.assertEqual(wide[etf_signal.IND_GRID], 0.5) + + def test_grid_is_rounded_up_to_tick(self): + rows = bars() + for row in rows: + row["close"] += 0.0004 + row["open"], row["high"], row["low"] = row["close"], row["high"] + 0.0004, row["low"] + 0.0004 + values = etf_signal.calculate(rows, symbol(), EtfDefaults(), TODAY) + raw = values[etf_signal.IND_ATR] * 1.0 + expected = float(Decimal(str(raw)).quantize(Decimal("0.001"), rounding=ROUND_CEILING)) + self.assertEqual(values[etf_signal.IND_GRID], expected) + self.assertEqual(round(values[etf_signal.IND_GRID], 3), values[etf_signal.IND_GRID]) + + def test_rising_close_uses_wilder_smoothing(self): + rows = [dict(row) for row in bars()] + for index, row in enumerate(rows): + shift = index * 0.01 + row["close"] += shift + row["open"], row["high"], row["low"] = ( + row["close"], row["close"] + 0.5, row["close"] - 0.5 + ) + values = etf_signal.calculate(rows, symbol(), EtfDefaults(), TODAY) + closes = [row["close"] for row in rows] + self.assertAlmostEqual(values[etf_signal.IND_MA60], fmean(closes[-60:])) + self.assertGreaterEqual(values[etf_signal.IND_ATR], 1.0) + + def test_insufficient_or_stale_bars_are_rejected(self): + with self.assertRaisesRegex(ValueError, "61"): + etf_signal.calculate(bars(40), symbol(), EtfDefaults(), TODAY) + # 最近日线距今超过 15 个自然日即放弃该标的 + with self.assertRaisesRegex(ValueError, "自然日"): + etf_signal.calculate(bars(), symbol(), EtfDefaults(), TODAY + timedelta(days=20)) + + def test_bad_atr_period_is_rejected(self): + defaults = EtfDefaults() + defaults.atr_period = 1 + with self.assertRaisesRegex(ValueError, "atr_period"): + etf_signal.calculate(bars(), symbol(), defaults, TODAY) + + +class DailyBarsTests(unittest.TestCase): + def test_request_url_and_no_token_header(self): + def respond(request): + self.assertEqual( + str(request.url), etf_signal.DAILY_URL + "?code=" + CODE + ) + self.assertNotIn("x-token", request.headers) + return httpx.Response(200, json=[raw_bar("20260915")]) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + rows = etf_signal.daily_bars(client, CODE, TODAY) + self.assertEqual([row["date"] for row in rows], ["20260915"]) + + def test_custom_endpoint_is_used(self): + def respond(request): + self.assertEqual(str(request.url), "http://api.test/etf/daily?code=" + CODE) + return httpx.Response(200, json=[raw_bar("20260915")]) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + etf_signal.daily_bars( + client, CODE, TODAY, endpoint="http://api.test/etf/daily" + ) + + def test_http_and_json_errors_propagate(self): + for status, content in ((404, "{}"), (200, "error")): + with httpx.Client( + transport=httpx.MockTransport(lambda r: httpx.Response(status, text=content)) + ) as client, self.assertRaises((httpx.HTTPStatusError, ValueError)): + etf_signal.daily_bars(client, CODE, TODAY) + + +def daily_response(payload: object, code: str = CODE) -> httpx.Response: + """构造带 request 的 200 响应;httpx 的 raise_for_status 需要 request。""" + request = httpx.Request("GET", etf_signal.DAILY_URL, params={"code": code}) + return httpx.Response(200, json=payload, request=request) + + +def boom(url=None, params=None): + """模拟连接失败:httpx 只接受真实的 RequestError 子类实例。""" + raise httpx.ConnectError("boom") + + +class GenSignalsTests(unittest.TestCase): + def setUp(self): + self.client = Mock(spec=httpx.Client) + self.client.get.side_effect = lambda url, params=None: daily_response( + raw_payload(code=params["code"]), params["code"] + ) + for cache in (etf_signal._daily_cache, etf_signal._fetched, etf_signal._retry_at): + cache.clear() + patch.object(etf_signal, "_history_client", self.client).start() + self.addCleanup(patch.stopall) + + def config(self, codes=(CODE,)): + return EtfConfig( + defaults=EtfDefaults(), + symbols={code: symbol() for code in codes}, + ) + + def test_signals_follow_config_order_and_carry_indicators(self): + signals = etf_signal.gen_signals(runtime(self.config((OTHER, CODE)))) + self.assertEqual([item.code for item in signals], [OTHER, CODE]) + item = signals[0] + self.assertIsInstance(item, SignalItem) + self.assertEqual(item.signal_key, "etf") + self.assertEqual(item.last_close, 10.0) + self.assertIn("ETF网格", item.desc) + self.assertEqual(item.tech_indicator[etf_signal.IND_GRID], 1.0) + self.assertEqual(item.tech_indicator[etf_signal.IND_MA60], 10.0) + self.assertEqual(item.tech_indicator[etf_signal.IND_ENTRY], 9.65) + + def test_missing_etf_config_yields_empty_list(self): + rt = runtime() + rt.etf_cfg = None + self.assertEqual(etf_signal.gen_signals(rt), []) + + def test_broken_symbol_is_skipped_without_breaking_others(self): + """CODE 的日线证券代码不一致时放弃该标的,OTHER 正常生成。""" + payloads = {CODE: [raw_bar("20260915", code=OTHER)], OTHER: raw_payload(code=OTHER)} + self.client.get.side_effect = lambda url, params=None: daily_response( + payloads[params["code"]], params["code"] + ) + signals = etf_signal.gen_signals(runtime(self.config((CODE, OTHER)))) + self.assertEqual([item.code for item in signals], [OTHER]) + + def test_daily_bars_are_cached_per_code_per_day(self): + rt = runtime(self.config()) + etf_signal.gen_signals(rt) + etf_signal.gen_signals(rt) + self.assertEqual(self.client.get.call_count, 1) + + def test_failure_is_throttled_by_retry_window(self): + self.client.get.side_effect = boom + rt = runtime(self.config()) + self.assertEqual(etf_signal.gen_signals(rt), []) + self.assertEqual(self.client.get.call_count, 1) + # 重试窗口内不再取数 + self.assertEqual(etf_signal.gen_signals(rt), []) + self.assertEqual(self.client.get.call_count, 1) + self.assertIn(CODE, etf_signal._retry_at) + + def test_retry_happens_after_window(self): + self.client.get.side_effect = boom + rt = runtime(self.config()) + etf_signal.gen_signals(rt) + etf_signal._retry_at[CODE] = datetime.now() - timedelta(seconds=1) + self.client.get.side_effect = None + self.client.get.return_value = daily_response(raw_payload()) + signals = etf_signal.gen_signals(rt) + self.assertEqual([item.code for item in signals], [CODE]) + self.assertEqual(self.client.get.call_count, 2) + def test_new_trading_day_clears_cache(self): + rt = runtime(self.config()) + etf_signal.gen_signals(rt) + # 模拟隔日:昨日的取数记录不应继续复用。 + etf_signal._fetched[CODE] = LAST_DAY - timedelta(days=1) + etf_signal._daily_cache.clear() + etf_signal.gen_signals(rt) + self.assertEqual(etf_signal._fetched[CODE], datetime.now().date()) + self.assertEqual(self.client.get.call_count, 2) + + def test_endpoint_falls_back_to_default_without_api_host(self): + etf_signal.gen_signals(runtime(self.config(), host="")) + self.assertEqual(self.client.get.call_args.args[0], etf_signal.DAILY_URL) + + def test_endpoint_uses_global_api_host(self): + etf_signal.gen_signals(runtime(self.config(), host="http://api.test/")) + self.assertEqual(self.client.get.call_args.args[0], "http://api.test/etf/daily") + + +if __name__ == "__main__": + unittest.main() diff --git a/labs/tests/test_etf_trade.py b/labs/tests/test_etf_trade.py new file mode 100644 index 0000000..1d52cd9 --- /dev/null +++ b/labs/tests/test_etf_trade.py @@ -0,0 +1,358 @@ +"""ETF 开仓与持仓:白名单/入场门槛/反弹确认、主出口、副出口、百分比补仓。""" + +from datetime import date, datetime, timedelta +import unittest +from unittest.mock import Mock, patch + +from config import AccountConfig, EtfConfig, EtfDefaults, EtfSymbolConfig, GlobalConfig +from libs.order import OrderBook +from libs.runtime import Runtime +from libs.signal import SignalItem +from libs import watch +from libs.watch import DipWatch +from sdk import Assets, PositionItem, Tick +from strategy.etf import open as etf_open +from strategy.etf import positions as etf_positions + +CODE = "510300.SH" +OTHER = "159915.SZ" +# 固定"当前时刻",与 tick 的时间戳保持同一交易日且不过期。 +NOW = datetime(2026, 9, 16, 10, 0, 0) + + +class FrozenDateTime(datetime): + """冻结 ``datetime.now()``,其余行为与标准库一致。""" + + @classmethod + def now(cls, tz=None): + return NOW if tz is None else NOW.astimezone(tz) + + +def stamp(now: datetime) -> str: + return now.strftime("%Y%m%d %H:%M:%S") + + +def tick(price: float, now: datetime | None = None) -> Tick: + now = now or NOW + return Tick(last_price=price, last_close=price, raw={"timetag": stamp(now)}) + + +def symbol(**overrides) -> EtfSymbolConfig: + base = dict(is_t0=False, buy_shares=1000, atr_multiplier=1.0, inner_step=0.7) + base.update(overrides) + return EtfSymbolConfig(**base) + + +def etf_config(**symbol_overrides) -> EtfConfig: + return EtfConfig( + defaults=EtfDefaults(), + symbols={CODE: symbol(**symbol_overrides)}, + ) + + +def position(volume: int, cost: float, can_use: int | None = None, name: str = "") -> PositionItem: + return PositionItem( + stock_code=CODE, + stock_name=name, + volume=volume, + open_price=cost, + can_use_volume=volume if can_use is None else can_use, + yesterday_volume=volume if can_use is None else can_use, + last_price=cost, + ) + + +def signal(entry: float = 9.65, price: float = 10.0, code: str = CODE) -> SignalItem: + return SignalItem( + signal_key="etf", + code=code, + last_close=price, + tech_indicator={ + "etf_entry": entry, + "etf_price": price, + "etf_grid": 1.0, + "etf_add_price": price * 0.97, + "etf_ma60": 10.0, + }, + ) + + +class ETFTradeBase(unittest.TestCase): + def setUp(self): + self.client = Mock() + self.client.passorder.return_value = {"status": "success"} + # Runtime.__post_init__ 会拉一次服务端初始化数据,测试里不发真实请求。 + patch("libs.runtime.get_json", side_effect=OSError("offline")).start() + self.run = Runtime( + client=self.client, + global_cfg=GlobalConfig(api_host="http://api.test"), + account_cfg=AccountConfig(account_id="acct", strategy="etf", min_cash_ratio=0.0), + etf_cfg=etf_config(), + orders=OrderBook(), + open_watch=DipWatch(expire_seconds=600, rebound_threshold=0.5), + add_watch=DipWatch(expire_seconds=600, rebound_threshold=0.5), + ) + for module in (etf_open, etf_positions, watch): + patch.object(module, "trading_time", return_value=True, create=True).start() + patch.object(module, "datetime", FrozenDateTime).start() + patch.dict(etf_positions._progress, {}, clear=True).start() + patch.dict(etf_positions._trackers, {}, clear=True).start() + self.addCleanup(patch.stopall) + + def last_order(self) -> dict: + self.assertTrue(self.client.passorder.called, "未提交任何委托") + return self.client.passorder.call_args.kwargs + + +class OpenSignalTests(ETFTradeBase): + def test_whitelist_outside_config_is_skipped(self): + item = signal(code=OTHER) + etf_open.open_signal(self.run, {OTHER: tick(9.4)}, [item]) + self.client.passorder.assert_not_called() + + def test_price_above_entry_no_observation_no_order(self): + etf_open.open_signal(self.run, {CODE: tick(9.9)}, [signal(entry=9.65)]) + self.client.passorder.assert_not_called() + self.assertEqual(self.run.open_watch.data, {}) + + def test_seesaw_below_entry_requires_rebound_confirmation(self): + item = signal(entry=9.65) + etf_open.open_signal(self.run, {CODE: tick(9.4)}, [item]) # 进入入场区,记低点 + etf_open.open_signal(self.run, {CODE: tick(9.39)}, [item]) # 刷新低点 + etf_open.open_signal(self.run, {CODE: tick(9.40)}, [item]) # 反弹不足 0.5% + self.client.passorder.assert_not_called() + etf_open.open_signal(self.run, {CODE: tick(9.42)}, [item]) # (9.42-9.39)/9.39 = 0.32% 仍不足 + self.client.passorder.assert_not_called() + + def test_rebound_places_base_limit_order_at_anchor(self): + item = signal(entry=9.65) + etf_open.open_signal(self.run, {CODE: tick(9.40)}, [item]) + etf_open.open_signal(self.run, {CODE: tick(9.45)}, [item]) # 反弹 0.53% 确认 + request = self.last_order() + self.assertEqual(request["op_type"], 23) + self.assertEqual(request["volume"], 1000) + self.assertEqual(request["price"], 9.45) + self.assertEqual(request["pr_type"], 11) + self.assertEqual(request["strategy_name"], "etf") + self.assertEqual(self.run.open_watch.data, {}) + + def test_leaving_entry_band_forgets_the_watch(self): + item = signal(entry=9.65) + etf_open.open_signal(self.run, {CODE: tick(9.4)}, [item]) + etf_open.open_signal(self.run, {CODE: tick(9.8)}, [item]) + self.assertEqual(self.run.open_watch.data, {}) + + def test_missing_entry_indicator_is_skipped(self): + item = signal() + item.tech_indicator.clear() + etf_open.open_signal(self.run, {CODE: tick(9.4)}, [item]) + self.client.passorder.assert_not_called() + + def test_stale_tick_is_skipped(self): + old = datetime(2026, 9, 16, 9, 50, 0) + etf_open.open_signal(self.run, {CODE: tick(9.4, now=old)}, [signal(entry=9.65)]) + self.client.passorder.assert_not_called() + + def test_previous_day_tick_is_skipped(self): + yesterday = datetime(2026, 9, 15, 14, 0, 0) + etf_open.open_signal(self.run, {CODE: tick(9.4, now=yesterday)}, [signal(entry=9.65)]) + self.client.passorder.assert_not_called() + + def test_insufficient_budget_cancels_the_anchor(self): + self.run.client.assets.return_value = Assets(total=1000.0, available=100.0) + item = signal(entry=9.65) + etf_open.open_signal(self.run, {CODE: tick(9.40)}, [item]) + etf_open.open_signal(self.run, {CODE: tick(9.45)}, [item]) + self.client.passorder.assert_not_called() + self.assertEqual(self.run.open_watch.data, {}) + + +class MainExitTests(ETFTradeBase): + def test_profit_above_target_clears_the_whole_grid(self): + etf_positions.manage_positions( + self.run, {CODE: tick(10.2)}, [position(2000, 10.0)], True, 100000.0 + ) + request = self.last_order() + self.assertEqual(request["op_type"], 24) + self.assertEqual(request["volume"], 2000) + self.assertEqual(request["strategy_name"], "etf") + self.assertEqual(request["price"], 10.2) + self.assertEqual(request["pr_type"], 11) + + def test_profit_below_target_does_not_sell(self): + etf_positions.manage_positions( + self.run, {CODE: tick(10.05)}, [position(2000, 10.0)], True, 100000.0 + ) + self.client.passorder.assert_not_called() + + def test_t_plus_1_position_bought_today_is_not_sellable(self): + held = position(1000, 10.0, can_use=0) + held.yesterday_volume = 0 + etf_positions.manage_positions( + self.run, {CODE: tick(10.5)}, [held], True, 100000.0 + ) + self.client.passorder.assert_not_called() + + def test_t0_symbol_sells_on_the_same_day(self): + self.run.etf_cfg = etf_config(is_t0=True) + held = position(1000, 10.0, can_use=1000) + held.yesterday_volume = 0 + etf_positions.manage_positions( + self.run, {CODE: tick(10.5)}, [held], True, 100000.0 + ) + self.assertEqual(self.last_order()["volume"], 1000) + + +class LevelExitTests(ETFTradeBase): + """副出口:主出口在盈亏率 ≥1% 时会先吃掉整仓,因此这里直接喂盈亏率验证峰值回撤。 + + inner_step = 0.7、inner_grids = 2:只有峰值抬到第 2 格后的回撤才允许卖出。 + """ + + def observe(self, series, cost: float = 11.6): + held = position(1000, cost) + symbol = self.run.etf_cfg.symbols[CODE] + level = etf_positions.position_level(self.run, held) + decisions = [] + for pnl_rate in series: + price = cost * (1 + pnl_rate / 100) + decisions.append( + etf_positions.handle_level_exit( + self.run, symbol, held, tick(price), pnl_rate, level + ) + ) + return decisions + + def test_peak_retreat_sells_only_that_level(self): + first, second, third = self.observe([0.5, 1.5, 1.2]) + self.assertFalse(first.submitted) # 首次观察,只建基准 + self.assertFalse(second.submitted) # 峰值抬到第 2 格 + self.assertTrue(third.submitted) # 回撤到第 1 格 + request = self.last_order() + self.assertEqual(request["op_type"], 24) + self.assertEqual(request["volume"], 1000) + self.assertEqual(request["strategy_name"], "etf") + + def test_retreat_below_inner_grids_is_held(self): + first, second = self.observe([0.5, 0.1]) + self.assertFalse(first.submitted) + self.assertFalse(second.submitted) # 峰值只有 0 格 + self.client.passorder.assert_not_called() + + def test_peak_is_kept_when_the_order_is_rejected(self): + self.client.passorder.return_value = {"status": "rejected"} + self.run.orders.place = Mock(return_value=False) + first, second, third = self.observe([0.5, 1.5, 1.2]) + self.assertFalse(third.submitted) + # 下单失败必须保留峰值:下一轮同样能再次触发。 + fourth = self.observe([1.2])[0] + self.assertFalse(fourth.submitted) + + def test_manage_positions_runs_the_secondary_exit(self): + """成本 11.6、现价 11.7/11.65 的盈亏率都低于 1%,主出口不参与。""" + held = position(1000, 11.6) + etf_positions.manage_positions(self.run, {CODE: tick(11.7)}, [held], True, 0.0) + etf_positions.manage_positions(self.run, {CODE: tick(11.65)}, [held], True, 0.0) + self.client.passorder.assert_not_called() # 峰值未达 2 格 + + +class AddTests(ETFTradeBase): + """补仓规则单测:直接调 handle_add,避免其它出口的委托锁干扰。""" + + def held(self, volume: int = 3000, cost: float = 10.0) -> PositionItem: + return position(volume, cost) + + def add(self, price: float, volume: int = 3000, cost: float = 10.0, + budget: float = 100000.0, level: int | None = None, + symbol_overrides: dict | None = None, first_low: float | None = None): + if symbol_overrides: + self.run.etf_cfg = etf_config(**symbol_overrides) + held = self.held(volume, cost) + if first_low is not None: + # 先造出一个观察低点,再由本次调用验证反弹确认。 + self.run.add_watch.triggered("补仓", CODE, first_low) + return etf_positions.handle_add( + self.run, + self.run.etf_cfg.symbols[CODE], + held, + tick(price), + price, + budget, + level if level is not None else etf_positions.position_level(self.run, held), + ) + + def test_add_requires_add_pct_drop(self): + decision = self.add(9.95) + self.assertFalse(decision.submitted) + self.client.passorder.assert_not_called() + self.assertEqual(self.run.add_watch.data, {}) + + def test_add_waits_for_rebound_before_buying(self): + # 跌幅 4% ≥ add_pct 3%,但还没反弹确认:只观察,不下单。 + first = self.add(9.60) + self.assertFalse(first.submitted) + self.assertIn(CODE, self.run.add_watch.data) + # 从观察低点 9.60 反弹 0.63%:确认后按现价买一档。 + second = self.add(9.66) + self.assertTrue(second.submitted) + request = self.last_order() + self.assertEqual(request["op_type"], 23) + self.assertEqual(request["volume"], 1000) + self.assertEqual(request["price"], 9.66) + self.assertEqual(request["strategy_name"], "etf") + self.assertEqual(self.run.add_watch.data, {}) + + def test_add_stops_at_max_adds(self): + decision = self.add(9.60, volume=10000) + self.assertFalse(decision.submitted) + self.assertIn("档", decision.message) + + def test_add_respects_max_shares(self): + decision = self.add(9.60, volume=3000, symbol_overrides={"max_shares": 3000}) + self.assertFalse(decision.submitted) + self.client.passorder.assert_not_called() + + def test_add_needs_budget(self): + # 跌幅 4%、反弹 0.63% 都满足,但预算为 0:不消耗观察状态,也不下单。 + first = self.add(9.60, budget=0.0) + self.assertFalse(first.submitted) + second = self.add(9.66, budget=0.0, first_low=9.60) + self.assertFalse(second.submitted) + self.assertIn(CODE, self.run.add_watch.data) + # 资金到位后同一个观察低点仍可确认。 + third = self.add(9.66, budget=100000.0, first_low=None) + self.assertTrue(third.submitted) + + def test_add_uses_broker_cost_as_previous_level(self): + # 上一档 = 券商成本 9.5:跌到 9.16 是 3.58% ≥ 3%,反弹到 9.21 确认。 + decision = self.add(9.21, cost=9.5, first_low=9.16) + self.assertTrue(decision.submitted) + self.assertEqual(self.last_order()["price"], 9.21) + + def test_add_blocked_when_market_disallows(self): + held = self.held() + etf_positions.manage_positions(self.run, {CODE: tick(9.6)}, [held], False, 100000.0) + etf_positions.manage_positions(self.run, {CODE: tick(9.66)}, [held], False, 100000.0) + self.client.passorder.assert_not_called() + + def test_add_blocked_by_in_flight_buy(self): + self.run.orders.busy_cache.set("BUY-" + CODE, True, timeout=180) + self.add(9.60) + self.add(9.66) + self.client.passorder.assert_not_called() + + +class PositionLevelTests(ETFTradeBase): + def test_level_is_derived_from_volume(self): + for volume, expected in ((1000, 1), (2000, 2), (3500, 4), (10000, 10)): + with self.subTest(volume=volume): + self.assertEqual( + etf_positions.position_level(self.run, position(volume, 10.0)), expected + ) + + def test_unknown_symbol_yields_baseline_level(self): + self.assertEqual(etf_positions.position_level(self.run, position(1000, 10.0)), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/labs/tests/test_trend_collector.py b/labs/tests/test_trend_collector.py index a39a38a..e6a70d1 100644 --- a/labs/tests/test_trend_collector.py +++ b/labs/tests/test_trend_collector.py @@ -3,7 +3,7 @@ import io import logging import unittest from concurrent.futures import Future -from contextlib import ExitStack, redirect_stdout +from contextlib import ExitStack, redirect_stderr, redirect_stdout from types import SimpleNamespace from unittest.mock import Mock, patch @@ -77,7 +77,7 @@ class TrendCollectorTests(unittest.TestCase): self.assertEqual(payload['deals'][0]['volume'], 100) def test_main_registers_five_minute_collector_job(self): - for strategy in ('trend', 'zt'): + for strategy in ('trend', 'zt', 'etf'): with self.subTest(strategy=strategy), ExitStack() as stack: scheduler = Mock(running=True) stack.enter_context(patch.object(self.app, 'BackgroundScheduler', return_value=scheduler)) @@ -101,6 +101,63 @@ class TrendCollectorTests(unittest.TestCase): scheduler.start.assert_called_once() scheduler.shutdown.assert_called_once_with(wait=True) + def test_main_rejects_unknown_strategy_before_starting_the_scheduler(self): + scheduler = Mock(running=True) + with patch.object(self.app, 'BackgroundScheduler', return_value=scheduler), \ + patch.object(self.app, 'require_windows', return_value=True), \ + patch.object(self.app, 'check_single_instance', return_value=True), \ + patch.object(self.app, 'wait_for_qmt_api') as wait_api, \ + patch.object(self.app.config, 'load'), \ + patch.object(self.app.config, 'global_config', SimpleNamespace(api_host='unused')), \ + patch.object(self.app.config, 'account_config', SimpleNamespace(strategy='bogus')), \ + patch.object(self.app, 'wait_for_any_key'), \ + patch('sys.stdin', io.StringIO('\n')), redirect_stderr(io.StringIO()) as err: + self.assertEqual(self.app.main(), 1) + self.assertIn('bogus', err.getvalue()) + scheduler.start.assert_not_called() + wait_api.assert_not_called() + + def test_main_reports_a_missing_global_config(self): + scheduler = Mock(running=True) + with patch.object(self.app, 'BackgroundScheduler', return_value=scheduler), \ + patch.object(self.app, 'require_windows', return_value=True), \ + patch.object(self.app, 'check_single_instance', return_value=True), \ + patch.object(self.app, 'wait_for_qmt_api') as wait_api, \ + patch.object(self.app.config, 'load'), \ + patch.object(self.app.config, 'global_config', None), \ + patch.object(self.app.config, 'account_config', None), \ + patch.object(self.app, 'wait_for_any_key'), \ + patch('sys.stdin', io.StringIO('\n')), redirect_stderr(io.StringIO()) as err: + self.assertEqual(self.app.main(), 1) + self.assertIn('config.load', err.getvalue()) + scheduler.add_job.assert_not_called() + wait_api.assert_not_called() + + +class ETFEtfConfigSummaryTests(unittest.TestCase): + """启动日志里的 ETF 配置概览:缺文件必须说清楚,且不抛异常。""" + + def summary(self, etf_cfg): + app = importlib.import_module('main') + with patch.object(app.config, 'etf_config', etf_cfg, create=True): + return app.describe_etf_config() + + def test_missing_file_is_described_not_raised(self): + self.assertIn('_etf.yaml', self.summary(None)) + + def test_symbols_and_codes_are_listed_in_config_order(self): + from config import EtfConfig, EtfDefaults, EtfSymbolConfig + cfg = EtfConfig( + defaults=EtfDefaults(), + symbols={ + '159915.SZ': EtfSymbolConfig(is_t0=True, buy_shares=1000, atr_multiplier=1.0, inner_step=0.7), + '510300.SH': EtfSymbolConfig(is_t0=False, buy_shares=1000, atr_multiplier=1.0, inner_step=0.7), + }, + ) + summary = self.summary(cfg) + self.assertIn('2 只', summary) + self.assertLess(summary.index('159915.SZ'), summary.index('510300.SH')) + if __name__ == '__main__': unittest.main() diff --git a/py-client/README.md b/py-client/README.md index 219871a..a988360 100644 --- a/py-client/README.md +++ b/py-client/README.md @@ -35,11 +35,19 @@ py -3.14 -m venv .venv ## 验证与性能 +测试与基准已统一迁到仓库根的 `labs/`(试验与测试代码集中目录), +从**仓库根目录**执行: + ```powershell -.venv/Scripts/python.exe -B -m unittest discover -s tests -v -.venv/Scripts/python.exe -B benchmarks/hotpaths.py +py -3.14 -B labs/run_tests.py # 全部离线测试(统一入口) +py -3.14 -B labs/run_tests.py -v # 详细 +py -3.14 -B labs/benchmarks/hotpaths.py # 微基准 ``` +`labs/run_tests.py` 会自动把本目录(`py-client`)挂上 `sys.path`, +所以不需要先 `cd py-client`;测试模块用 `from tests.zt_harness import ...` +这类绝对导入,也由该入口统一处理顶层包名。 + 130 项离线测试通过,覆盖 SDK 与 API 字段契约、委托簿、配置校验、IPO 申购状态机、 ZT 轮次状态机(正T/反T)、Trend 采集任务与 Python 3.14 回归。 测试使用模拟客户端和临时目录,不启动真实交易、不访问真实接口。 @@ -64,7 +72,8 @@ ZT 轮次状态机(正T/反T)、Trend 采集任务与 Python 3.14 回归。 ## ETF 自适应网格策略 -入口为 `strategy: etf`,标的和参数见 [`etc/etf.yaml`](etc/etf.yaml), +入口为 `strategy: etf`,标的和参数见 [`etc/_etf.yaml`](etc/_etf.yaml)(由 +`config.load()` 按固定文件名加载,文件不存在时为 `None`), 完整规则和启用步骤见 [`strategy/etf/README.md`](strategy/etf/README.md)。 ## ZT 做 T 策略(2026-09 重构) diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py index 1497c7e..603020f 100644 --- a/py-client/config/__init__.py +++ b/py-client/config/__init__.py @@ -1,9 +1,28 @@ +import math +import re import socket from dataclasses import dataclass, field, fields from pathlib import Path import yaml +# 场内 ETF 代码:沪市 5 开头(510300.SH),深市 15/16/18 开头(159915.SZ); +# 必须带交易所后缀,且键与接口返回的 ts_code 完全一致。 +ETF_CODE_PATTERN = re.compile(r"(?:5[0-9]{5}\.SH|1[568][0-9]{4}\.SZ)") + +# 标的段允许覆盖的全局参数;账户级参数(如 add_pct、commission_rate)不允许覆盖。 +SYMBOL_OVERRIDE = ("inner_grids", "max_grid_span_pct", "rebound_pct", "max_hold_days") + +# load() 成功后保存已加载的配置,供策略模块直接读取。 +global_config: GlobalConfig | None = None +account_config: AccountConfig | None = None +# _etf.yaml 不存在时为 None:只有 ETF 策略需要该文件。 +etf_config: EtfConfig | None = None + +# QMT 和外部 HTTP 接口的默认请求超时时间,单位为秒。 +HTTP_TIMEOUT = 5.0 + + @dataclass(slots=True) class SignalConfig: @@ -60,27 +79,117 @@ class AccountConfig: # 当前账户启用的策略名称,例如 trend。 strategy: str = "" - # 为空时读取 py-client/etc/etf.yaml;非空路径相对于账户配置目录。 - etf_config_path: str = "" -# load() 成功后保存已加载的配置,供策略模块直接读取。 -global_config: GlobalConfig | None = None -account_config: AccountConfig | None = None +@dataclass(slots=True) +class EtfDefaults: + """ETF 网格策略的全局默认参数,来自 ``_etf.yaml`` 的 ``defaults`` 段。 -# QMT 和外部 HTTP 接口的默认请求超时时间,单位为秒。 -HTTP_TIMEOUT = 5.0 + 字段顺序与 ``etc/_etf.yaml`` 保持一致,数值口径见 ``docs/etf.md`` §6。 + """ + + atr_period: int = 14 + # 格距 = max(ATR × atr_multiplier, MA60 × min_grid_pct/100, 0.001)。 + min_grid_pct: float = 0.5 + # 跨度健康度告警线,超线只告警并建议下调 atr_multiplier,不阻止建网。 + max_grid_span_pct: float = 40.0 + # 区间通道回看天数,以及"距区间下沿多少百分比以内算低位"。 + channel_period: int = 20 + channel_pct: float = 15.0 + # 建仓与补仓共用的反弹确认阈值,必须小于 add_pct。 + rebound_pct: float = 0.5 + # 补仓触发:自上一档成交价再跌该百分比(需配合反弹确认)。 + add_pct: float = 3.0 + # 补仓次数上限,总档数 = max_adds + 1(含底仓)。 + max_adds: int = 9 + watch_seconds: int = 600 + # 主出口:盈亏率 ≥ 该值即整仓清掉。 + min_profit_pct: float = 1.0 + # 副出口:峰值至少抬到第 N 格才允许回撤卖出。 + inner_grids: float = 2.0 + # 买入日当天不挂卖单;is_t0 为真的标的跳过本条。 + min_hold_days: int = 1 + # 0 表示不止损。 + max_hold_days: int = 0 + commission_rate: float = 0.0003 + min_commission: float = 5.0 + max_tick_age_seconds: int = 90 + + +@dataclass(slots=True) +class EtfSymbolConfig: + """单个标的的参数;未覆盖的字段为 ``None``,由 ``get()`` 继承全局默认。""" + + # 是否支持当日回转;必须显式配置,没有安全默认值。 + is_t0: bool = False + # 每档每次买入股数,100 的整数倍。 + buy_shares: int = 0 + # 格距的 ATR 倍数,决定阶梯跨度,必须逐标的标定。 + atr_multiplier: float = 0.0 + # 内层格距(盈亏率百分点),决定副出口能否被触发。 + inner_step: float = 0.0 + # 单标的总持仓上限;缺省按 max_adds + 1 档计算。 + max_shares: int | None = None + + inner_grids: float | None = None + max_grid_span_pct: float | None = None + rebound_pct: float | None = None + max_hold_days: int | None = None + + # 同一份 _etf.yaml 的全局默认值,不参与相等性比较。 + defaults: EtfDefaults = field(default_factory=EtfDefaults, repr=False, compare=False) + + def get(self, name: str): + """返回生效参数:标的覆盖优先,未覆盖时回落到全局默认。""" + if name not in SYMBOL_OVERRIDE: + raise KeyError(f"标的配置不支持覆盖参数 {name}") + value = getattr(self, name) + return getattr(self.defaults, name) if value is None else value + + def effective(self) -> dict: + """返回全部生效参数,供日志和自检使用。""" + values = {name: getattr(self, name) for name in self.override_names()} + values.update({name: self.get(name) for name in SYMBOL_OVERRIDE}) + return values + + @classmethod + def override_names(cls) -> tuple[str, ...]: + """标的段允许出现在 YAML 中的字段名。""" + return tuple(item.name for item in fields(cls) if item.name != "defaults") + + +@dataclass(slots=True) +class EtfConfig: + """``_etf.yaml`` 的完整内容:全局默认 + 标的白名单。""" + + defaults: EtfDefaults = field(default_factory=EtfDefaults) + # 键为证券代码,顺序即资金优先级;每项已绑定全局默认,便于 ``get()`` 回退。 + symbols: dict[str, EtfSymbolConfig] = field(default_factory=dict) + + @property + def codes(self) -> tuple[str, ...]: + """按配置顺序返回标的代码。""" + return tuple(self.symbols) + + def symbol(self, code: str) -> EtfSymbolConfig: + """返回指定标的的配置;不在白名单内时抛 KeyError。""" + try: + return self.symbols[code] + except KeyError: + raise KeyError(f"证券 {code} 不在 _etf.yaml 的白名单内") from None def load( etc_dir: str | Path | None = None, hostname: str | None = None, -) -> tuple[GlobalConfig, AccountConfig]: - """加载公共配置以及当前主机对应的账户配置。 +) -> tuple[GlobalConfig, AccountConfig, EtfConfig]: + """加载公共配置、当前主机的账户配置以及 ETF 策略配置。 Args: etc_dir: 配置文件目录,其中必须包含 ``_global.yaml``;为空时 - 默认使用 py-client 下的 ``etc`` 目录。 + 默认使用 py-client 下的 ``etc`` 目录。ETF 策略的 + ``_etf.yaml`` 同样在该目录下按固定文件名查找,不存在时 + ``etf_config`` 为 None,不视为错误。 hostname: 指定要加载的主机名;为空时使用当前计算机名。 Returns: @@ -89,7 +198,7 @@ def load( Raises: ValueError: 配置缺失、格式错误或策略参数不合法。 """ - global global_config, account_config + global global_config, account_config, etf_config root = Path(etc_dir) if etc_dir is not None else Path(__file__).parent.parent / "etc" raw = _yaml(root / "_global.yaml") @@ -134,11 +243,6 @@ def load( Path(global_config.qmt_data_dir).mkdir(parents=True, exist_ok=True) account_config = AccountConfig(**_account_values(root / account_file)) - if account_config.etf_config_path: - etf_path = Path(account_config.etf_config_path) - account_config.etf_config_path = str(etf_path if etf_path.is_absolute() else root / etf_path) - elif account_config.strategy.strip().lower() == 'etf': - account_config.etf_config_path = str(root / 'etf.yaml') if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0: raise ValueError("buy_value、grid_step_pct 必须大于 0") if type(account_config.zt_open_hands) is not int or account_config.zt_open_hands < 0: @@ -159,7 +263,10 @@ def load( account_config.strategy = account_config.strategy.lower() if account_config.strategy == "zt" and account_config.signal_allow != ["dcm"]: raise ValueError("zt 策略的 signal_allow 必须且只能为 [\"dcm\"]") - return global_config, account_config + + # ETF 参数是固定文件名,缺文件时返回 None,由 ETF 策略自行决定是否必须。 + etf_config = _etf_config(root / "_etf.yaml") + return global_config, account_config, etf_config def _yaml(path: Path) -> dict: @@ -184,3 +291,233 @@ def _account_values(path: Path) -> dict: if unknown: raise ValueError(f"账户配置 {path} 存在未知字段: {', '.join(unknown)}") return raw + + +def _etf_config(path: Path) -> EtfConfig | None: + """读取固定文件名的 ETF 配置;文件不存在时返回 None。 + + 只有 ETF 策略需要 ``_etf.yaml``,因此缺文件不是错误;文件存在但内容 + 不合法(含未知键)仍然是错误,避免拼错参数被静默忽略。 + """ + if not path.is_file(): + return None + return _parse_etf(_yaml(path), path) + + +def _parse_etf(raw: dict, path: Path) -> EtfConfig: + """把 ``_etf.yaml`` 的内容转换为带校验的 ``EtfConfig``。""" + if not isinstance(raw, dict): + raise ValueError(f"ETF 配置 {path} 的根节点必须是对象") + + unknown = sorted(set(raw) - {"defaults", "symbols"}) + if unknown: + raise ValueError(f"ETF 配置 {path} 存在未知字段: {', '.join(unknown)}") + + defaults = _etf_defaults(raw.get("defaults") or {}, path) + raw_symbols = raw.get("symbols") or {} + if not isinstance(raw_symbols, dict) or not raw_symbols: + raise ValueError(f"ETF 配置 {path} 的 symbols 必须是非空标的映射") + + symbols: dict[str, EtfSymbolConfig] = {} + for code, values in raw_symbols.items(): + symbols[code] = _etf_symbol(code, values or {}, defaults, path) + return EtfConfig(defaults=defaults, symbols=symbols) + + +def _etf_defaults(values: dict, path: Path) -> EtfDefaults: + """校验 ``defaults`` 段并补齐缺省字段,口径见 ``docs/etf.md`` §6。""" + if not isinstance(values, dict): + raise ValueError(f"ETF 配置 {path} 的 defaults 必须是对象") + + unknown = sorted(set(values) - {item.name for item in fields(EtfDefaults)}) + if unknown: + raise ValueError( + f"ETF 配置 {path} 的 defaults 存在未知字段: {', '.join(unknown)}" + ) + + section = "defaults" + atr_period = _etf_int(values.get("atr_period", 14), "atr_period", path, section) + channel_period = _etf_int( + values.get("channel_period", 20), "channel_period", path, section + ) + watch_seconds = _etf_int( + values.get("watch_seconds", 600), "watch_seconds", path, section + ) + max_tick_age_seconds = _etf_int( + values.get("max_tick_age_seconds", 90), "max_tick_age_seconds", path, section + ) + min_hold_days = _etf_int( + values.get("min_hold_days", 1), "min_hold_days", path, section + ) + max_hold_days = _etf_int( + values.get("max_hold_days", 0), "max_hold_days", path, section, zero_ok=True + ) + max_adds = _etf_int(values.get("max_adds", 9), "max_adds", path, section, zero_ok=True) + if max_adds > 9: + raise ValueError(f"ETF 配置 {path} 的 max_adds 不能大于 9(总档数 10 档)") + + min_grid_pct = _etf_number( + values.get("min_grid_pct", 0.5), "min_grid_pct", path, section + ) + max_grid_span_pct = _etf_number( + values.get("max_grid_span_pct", 40.0), + "max_grid_span_pct", + path, + section, + high=100.0, + ) + channel_pct = _etf_number( + values.get("channel_pct", 15.0), "channel_pct", path, section + ) + rebound_pct = _etf_number( + values.get("rebound_pct", 0.5), "rebound_pct", path, section + ) + add_pct = _etf_number(values.get("add_pct", 3.0), "add_pct", path, section) + min_profit_pct = _etf_number( + values.get("min_profit_pct", 1.0), "min_profit_pct", path, section + ) + inner_grids = _etf_number( + values.get("inner_grids", 2.0), "inner_grids", path, section + ) + commission_rate = _etf_number( + values.get("commission_rate", 0.0003), + "commission_rate", + path, + section, + zero_ok=True, + ) + min_commission = _etf_number( + values.get("min_commission", 5.0), "min_commission", path, section, zero_ok=True + ) + + # 反弹确认必须早于补仓触发,否则确认价回到上一档之上,条件自相矛盾。 + if rebound_pct >= add_pct: + raise ValueError(f"ETF 配置 {path} 的 rebound_pct 必须小于 add_pct") + + return EtfDefaults( + atr_period=atr_period, + min_grid_pct=min_grid_pct, + max_grid_span_pct=max_grid_span_pct, + channel_period=channel_period, + channel_pct=channel_pct, + rebound_pct=rebound_pct, + add_pct=add_pct, + max_adds=max_adds, + watch_seconds=watch_seconds, + min_profit_pct=min_profit_pct, + inner_grids=inner_grids, + min_hold_days=min_hold_days, + max_hold_days=max_hold_days, + commission_rate=commission_rate, + min_commission=min_commission, + max_tick_age_seconds=max_tick_age_seconds, + ) + + +def _etf_symbol( + code: str, values: dict, defaults: EtfDefaults, path: Path +) -> EtfSymbolConfig: + """校验单个标的的参数,并绑定全局默认以支持未覆盖字段的继承。""" + if not isinstance(code, str) or not ETF_CODE_PATTERN.fullmatch(code): + raise ValueError(f"ETF 配置 {path} 的标的 {code!r} 不是合法场内 ETF 代码") + if not isinstance(values, dict): + raise ValueError(f"ETF 配置 {path} 的标的 {code} 必须是对象") + + unknown = sorted(set(values) - set(EtfSymbolConfig.override_names())) + if unknown: + raise ValueError( + f"ETF 配置 {path} 的标的 {code} 存在未知字段: {', '.join(unknown)}" + ) + + section = f"标的 {code}" + # is_t0、buy_shares、atr_multiplier、inner_step 分别决定结算制度、佣金轴 + # 与跨度轴,没有安全的全局默认值,必须逐标的显式给出。 + missing = sorted({"is_t0", "buy_shares", "atr_multiplier", "inner_step"} - set(values)) + if missing: + raise ValueError( + f"ETF 配置 {path} 的{section}缺少必填字段: {', '.join(missing)}" + ) + + is_t0 = values["is_t0"] + if type(is_t0) is not bool: + raise ValueError(f"ETF 配置 {path} 的{section}的 is_t0 必须是布尔值") + + buy_shares = _etf_int(values["buy_shares"], "buy_shares", path, section) + if buy_shares % 100: + raise ValueError(f"ETF 配置 {path} 的{section}的 buy_shares 必须是 100 的整数倍") + max_shares = _etf_int( + values.get("max_shares") or buy_shares * (defaults.max_adds + 1), + "max_shares", + path, + section, + ) + atr_multiplier = _etf_number( + values["atr_multiplier"], "atr_multiplier", path, section + ) + inner_step = _etf_number(values["inner_step"], "inner_step", path, section) + + overrides: dict = {} + if "inner_grids" in values: + overrides["inner_grids"] = _etf_number( + values["inner_grids"], "inner_grids", path, section + ) + if "max_grid_span_pct" in values: + overrides["max_grid_span_pct"] = _etf_number( + values["max_grid_span_pct"], "max_grid_span_pct", path, section, high=100.0 + ) + if "rebound_pct" in values: + overrides["rebound_pct"] = _etf_number( + values["rebound_pct"], "rebound_pct", path, section + ) + if "max_hold_days" in values: + overrides["max_hold_days"] = _etf_int( + values["max_hold_days"], "max_hold_days", path, section, zero_ok=True + ) + + return EtfSymbolConfig( + is_t0=is_t0, + buy_shares=buy_shares, + atr_multiplier=atr_multiplier, + inner_step=inner_step, + max_shares=max_shares, + defaults=defaults, + **overrides, + ) + + +def _etf_number( + value, + name: str, + path: Path, + section: str, + high: float | None = None, + zero_ok: bool = False, +) -> float: + """校验有限数值并返回;布尔值、非数值和越界值都抛 ValueError。""" + label = f"ETF 配置 {path} 的{section}的 {name}" + # bool 是 int 的子类,必须显式排除,否则 True 会被当成 1。 + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{label} 必须是数值") + if not math.isfinite(value): + raise ValueError(f"{label} 必须是有限数值") + + low = 0 if zero_ok else 0.0 + if value < low or (value == 0 and not zero_ok) or (high is not None and value > high): + if high is None: + expect = "大于 0" + elif zero_ok: + expect = f"在 [0, {high:g}] 区间" + else: + expect = f"在 (0, {high:g}] 区间" + raise ValueError(f"{label} 必须{expect}") + return value + + +def _etf_int( + value, name: str, path: Path, section: str, zero_ok: bool = False +) -> int: + """校验整数字段并返回;``zero_ok`` 为假时要求大于 0。""" + if type(value) is not int or value < 0 or (value == 0 and not zero_ok): + expect = "非负整数" if zero_ok else "正整数" + raise ValueError(f"ETF 配置 {path} 的{section}的 {name} 必须是{expect}") + return value diff --git a/py-client/etc/_etf.yaml b/py-client/etc/_etf.yaml index cdffa3b..6af9376 100644 --- a/py-client/etc/_etf.yaml +++ b/py-client/etc/_etf.yaml @@ -1,24 +1,41 @@ -# 标的是示例白名单;仅在账户配置 strategy: etf 时启用。 -codes: - - "588000.SH" # 华夏上证科创板50成份ETF - - "510300.SH" # 沪深300ETF华泰柏瑞 - - "518880.SH" # 安易富黄金ETF -# 固定买入手数,每手 100 份;每只 ETF 总持仓硬上限 10 手。 -buy_hands: 1 -max_hands: 10 -# 60 日均线作为中轴;格距 = max(ATR×倍数, MA60×最小格距百分比, 0.001)。 -atr_period: 14 -atr_multiplier: 1.0 -boll_period: 20 -boll_std: 2.0 -min_grid_pct: 0.5 -# 复用 DipWatch:触及低位后,从观察低点反弹 0.61% 才买入。 -rebound_pct: 0.61 -watch_seconds: 600 -# 达到高位和最低利润要求后启动网格回撤止盈。 -min_profit_pct: 0.5 -# 用于资金预留及止盈费用门槛,按实际券商佣金调整。 -commission_rate: 0.0003 -min_commission: 5.0 -# 超过此秒数或缺少时间戳的行情不交易。 -max_tick_age_seconds: 90 +defaults: + atr_period: 14 + min_grid_pct: 0.5 + max_grid_span_pct: 40 # 跨度健康度告警线(非闸门) + channel_period: 20 + channel_pct: 15 + rebound_pct: 0.5 # 反弹确认阈值(建仓与补仓共用) + add_pct: 3.0 # 补仓:自上一档再跌该百分比即触发(需配合反弹确认) + max_adds: 9 # 补仓次数上限(底仓另计,共 10 档) + watch_seconds: 600 + min_profit_pct: 1.0 # 主出口:盈亏率 >= 该值即整仓清掉 + inner_grids: 2.0 # 副出口:峰值至少抬到第 N 格才允许回撤卖出 + min_hold_days: 1 + max_hold_days: 0 # 0 = 不止损 + commission_rate: 0.0003 + min_commission: 5.0 + max_tick_age_seconds: 90 + +# ---------- 标的白名单(键即标的,顺序即资金优先级)---------- +# 键必须与接口返回的 ts_code 完全一致(588000.SH / 159915.SZ) +symbols: + "588000.SH": + is_t0: false + buy_shares: 10000 + max_shares: 100000 # 10 个价位 × 1000 股 + atr_multiplier: 0.5 # 该标的 ATR 相对价格偏高,必须收窄 + inner_step: 0.9 + + "510300.SH": + is_t0: false + buy_shares: 4000 + max_shares: 20000 + atr_multiplier: 1.0 + inner_step: 0.7 + + "518880.SH": + is_t0: true # 黄金 ETF 支持当日回转 + buy_shares: 2000 + max_shares: 10000 + atr_multiplier: 1.0 + inner_step: 0.8 \ No newline at end of file diff --git a/py-client/libs/runtime.py b/py-client/libs/runtime.py index 862cc80..12f3d7b 100644 --- a/py-client/libs/runtime.py +++ b/py-client/libs/runtime.py @@ -4,7 +4,7 @@ import logging from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field -from config import AccountConfig, GlobalConfig, HTTP_TIMEOUT +from config import AccountConfig, GlobalConfig,EtfConfig, HTTP_TIMEOUT from sdk import Client from libs.grid_take_profit import GridTrailingTracker from libs.http import get_json @@ -19,10 +19,11 @@ class Runtime: client: Client global_cfg: GlobalConfig account_cfg: AccountConfig + etf_cfg: EtfConfig orders: OrderBook - open_watch: DipWatch - add_watch: DipWatch - profit_tracker: GridTrailingTracker + open_watch: DipWatch | None = None + add_watch: DipWatch | None = None + profit_tracker: GridTrailingTracker | None = None executor: ThreadPoolExecutor | None = None server_inital: dict[str, list[str]] = field(init=False) diff --git a/py-client/main.py b/py-client/main.py index 7bc7711..4600fc4 100644 --- a/py-client/main.py +++ b/py-client/main.py @@ -1,11 +1,15 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +"""策略客户端启动入口:加载配置、拉起后台任务、按 ``strategy`` 分派策略主循环。 + +一个进程只跑一个策略(``account_config.strategy``)。IPO 打新、大盘刷新、 +趋势数据采集由后台调度线程承担,主线程跑策略自己的循环。 +""" import logging import os import sys from datetime import datetime -import traceback from apscheduler.schedulers.background import BackgroundScheduler import config from dataclasses import dataclass @@ -13,7 +17,6 @@ import yaml import httpx PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) -GLOBAL_CONFIG_PATH = os.path.join(PROJECT_ROOT, "etc", "_global.yaml") LOG_DIR = os.path.join(PROJECT_ROOT, "logs") os.makedirs(LOG_DIR, exist_ok=True) LOG_FILE = os.path.join(LOG_DIR, datetime.now().strftime("%Y%m%d.log")) @@ -55,6 +58,14 @@ STRATEGIES = { def require_windows() -> bool: return os.name == "nt" + +def describe_etf_config() -> str: + """启动日志用的 ETF 配置概览:文件缺失时明确说明,不在这里报错。""" + etf_cfg = getattr(config, "etf_config", None) + if etf_cfg is None: + return "未找到 _etf.yaml(仅 etf 策略需要)" + return f"标的={len(etf_cfg.symbols)} 只,代码={'/'.join(etf_cfg.codes)}" + def check_single_instance(project_root: str) -> bool: """使用 Windows 命名互斥锁保证单实例。""" try: @@ -123,6 +134,19 @@ def main() -> int: config.load() if config.global_config is None or config.account_config is None: raise RuntimeError("配置尚未加载,请先调用 config.load()") + strategy = config.account_config.strategy + start = STRATEGIES.get(strategy) + if start is None: + raise ValueError( + f"未知策略 strategy={strategy!r},可选: {', '.join(sorted(STRATEGIES))}" + ) + logging.info( + "配置已加载:主机=%s,账户=%s,策略=%s,ETF配置=%s", + getattr(config.account_config, "host_key", "-"), + getattr(config.account_config, "account_id", "-"), + strategy, + describe_etf_config(), + ) wait_for_qmt_api() # 后台调度不受趋势策略永久循环阻塞;同一时刻最多执行一个实例。 @@ -159,14 +183,14 @@ def main() -> int: logging.info("IPO 自动打新定时任务已启动:每日 10:00、14:00") logging.info("大盘信号后台刷新已启动:每分钟一次") - STRATEGIES[config.account_config.strategy].start_strategy() - logging.info("%s 策略已结束", config.account_config.strategy) + STRATEGIES[strategy].start_strategy() + logging.info("%s 策略主循环已结束", strategy) return 0 except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e: - print(f"启动失败: {e}", file=sys.stderr, flush=True) - traceback.print_exception(type(e), e, e.__traceback__) - wait_for_any_key() - return 1 + print(f"启动失败: {e}", file=sys.stderr, flush=True) + logging.exception("启动失败") + wait_for_any_key() + return 1 finally: if scheduler is not None and scheduler.running: scheduler.shutdown(wait=True) diff --git a/py-client/strategy/etf/README.md b/py-client/strategy/etf/README.md deleted file mode 100644 index aa8d554..0000000 --- a/py-client/strategy/etf/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# A 股 ETF 自适应网格策略 - -策略目录为 `py-client/strategy/etf`,入口名称 `etf`,独立配置为 -`py-client/etc/etf.yaml`。每只 ETF 总持仓上限 10 手(1000 份),默认每次买入 1 手。 -这是常见均值回归与波动率网格方法的工程组合,尚未完成历史收益回测。 - -## 方法分析 - -| 方法 | 优点 | 局限 | 本策略选择 | -| --- | --- | --- | --- | -| 固定价差网格 | 简单直观 | 不适应不同价格与波动率 | ATR 动态格距,设置百分比下限 | -| 均线回归 | 提供相对高低位置 | 单边下跌中均线会滞后 | MA60 作中轴,设置仓位上限 | -| BOLL 低吸 | 用价格分布寻找相对低位 | 触及下轨不代表跌势结束 | 下轨仅启动观察,反弹后再买 | -| 回撤止盈 | 上涨时跟随峰值 | 不能保证最高价退出 | 复用现有 GridTrailingTracker | - -ATR 反映波动幅度,不判断方向;BOLL 为均线加减标准差倍数。定义参考 -[Fidelity ATR](https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/atr) -及 [Fidelity BOLL](https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/bollinger-bands)。 -默认参数是可调整的起点,不代表已经优化或保证收益。 - -## 指标与网格 - -历史日线直接读取 `http://go.apinb.com/a/get_daily?code=<证券代码>`,使用响应 -`details` 中的 `ts_code / trade_date / open / high / low / close`。校验 `code=0`、 -证券归属、日期和 OHLC 后,按日期排序,排除当天及未来数据,再取最近 120 根。 -每个标的每天计算一次并缓存,至少需要 60 根;ATR 周期为 60 时至少需要 61 根。 -接口样本未声明复权口径,策略使用接口原始价格,不自行假定或执行前复权。 - -- `M = 最近60根收盘价平均值`,固定 MA60。 -- `TR = max(最高-最低, abs(最高-前收盘), abs(最低-前收盘))`。 -- ATR 默认 14 日:前 14 个 TR 平均作初值,后续按 - `ATR = (前ATR × 13 + 当日TR) / 14` 进行 Wilder 平滑。 -- BOLL 默认 20 日、2 倍总体标准差:`中轨=MA20`,`上下轨=MA20 ± 2σ`。 -- 格距 `G = max(ATR × atr_multiplier, M × min_grid_pct / 100, 0.001)`, - 向上取整至 0.001 元。均线作中轴,ATR 决定每格宽度。 - -重复日期、非正数或非有限价格、价格关系异常、日线不足均不交易。 -最后日线超过 15 个自然日也不交易;该检查只排除明显过期,不能替代交易所日历。 -接口数据应更新至上一交易日。获取失败的标的每 5 分钟重试。 - -## 买入规则 - -1. 首仓在 `现价 <= min(BOLL下轨, M-G)` 时开始观察,不直接买入。 -2. 复用 `DipWatch` 防接飞刀:下跌刷新低点,从低点反弹默认 0.61% 后触发; - 观察默认 600 秒过期重置。反弹可站回下轨上方,但不能超过 MA60。 -3. 加仓还须满足 `现价 <= 上次实际买入成交均价-G`,防止同价位连续补满。 - 初次接管已有仓位时,券商成本作为初始加仓基准。 -4. 每次买入 `buy_hands × 100` 份;若买后超过 `max_hands × 100`,整笔跳过, - 不临时缩量。配置强制 `1 <= buy_hands <= max_hands <= 10`。 -5. 买入预算为券商可用资金减账户现金安全线,再减所有本地待确认买单。 - 限价金额加预估佣金占用预算,多标的串行扣减。 - -示例:MA60=4.00、G=0.05、下轨=3.90,价格进入 3.90 以下才观察;低点 3.88 -反弹至 3.904 时超过 0.61%,可以提交固定手数。如果实际均价为 3.904, -下一笔最高买价为 3.854,同时仍须满足低位触发和反弹确认。 - -## 止盈规则 - -1. `现价 >= max(BOLL上轨, M+G, 成本+G)`,且满足 `min_profit_pct`、 - 预估价差收益大于买卖两侧佣金,才启动高位跟踪。 -2. 启动时冻结格距。盈利格编号为 `floor((现价-成本)/冻结格距)`,复用 - `GridTrailingTracker` 记录最高格;进入更高格更新峰值,跌回较低格时止盈。 - 这是跌破峰值格边界,不是从最高价回撤完整一个 ATR。 -3. 启动后,即使回落到 BOLL 上轨或启动价以下,也继续判断回撤;卖出时仍须满足 - 最低利润和费用门槛。止盈启动后暂停补仓,不同时发买卖单。 -4. **固定手数用于买入;止盈卖出当前全部可用整手份额**,数量为 - `min(持仓, 券商可卖数量)` 向下取整到 100 份。零股暂不处理。 -5. T+1 当天不可卖时继续记录峰值,翌日按券商 `can_use_volume` 判断。 - 清仓、实际成交改变仓位、外部数量或成本变化后重建基准;部分卖出后剩余仓位 - 重新等待高位启动,不把旧峰值带入新仓。 - -交易单位、0.001 元报价精度及股票 ETF 的 T+1 参考 -[上交所 ETF 常见问题](https://www.sse.com.cn/assortment/fund/etf/question/)。 -本策略没有自动止损,单边下跌可能满仓后长期持有;10 手上限只限制数量。 -佣金参数按券商实际情况调整。费用门槛是估算,不逐笔归集历史买入最低佣金; -若券商持仓成本已含费用,该估算会偏保守。 - -## 委托、持仓与持久化 - -- 管理 `codes` 白名单内的已有持仓;`excluded_codes` 优先排除。配置外证券不买卖。 -- 以 `ETF-BUY-*` / `ETF-SELL-*` 为本地编号,标签 `etf`,当前价按 0.001 元 - 精度提交限价。复用 OrderBook,只自动撤销超时 120 秒的 ETF 前缀委托。 -- 同标的全账户买卖在途、未知委托状态或在途份额都会阻止新单。 - 行情缺少 `timetag`、不是当天或超过默认 90 秒,也不交易。 -- 下单前原子保存意图,HTTP 成功、超时或失败均不会自动解除锁。 - 必须收到终态(53/54/56/57),并且券商持仓与累计成交量相符,才能继续。 - 部分成交按实际数量核对,买入必须取得实际成交均价才推进下一格。 -- 状态位于 `{qmt_data_dir}/etf/{账户SHA256}/state.json`,保存实际买入基准、 - 止盈峰值、冻结格距和未确认委托;重启恢复,损坏不静默覆盖。 -- 同一 ETF 不适合同时由人工或其他策略频繁交易。在途期间外部改变持仓,会暂停核对。 - 若跨日后柜台不再返回未确认订单,则持续暂停该标的,需要核对历史订单与持仓后 - 人工处理状态,不按超时自动重发。有未确认委托的标的不能直接从配置移除。 - -## 启用 - -1. 修改 `etc/etf.yaml` 的标的及参数。示例仅示范格式,请选择实际交易的 A 股股票 ETF; - 代码形态检查不验证基金投资范围。 -2. 主机对应的账户 YAML 设置: - - ```yaml - strategy: etf - etf_config_path: etf.yaml - enable_auto_ipo: false - ``` - - 路径相对账户配置目录,也支持绝对路径;不填默认 `etc/etf.yaml`。 - `account_id`、`min_cash_ratio` 继续生效。公共校验仍要求 `buy_value`、 - `grid_step_pct` 为正,但 ETF 不用它们计算数量或格距。 - 关闭 IPO 是此处示例选择;ETF 决策不依赖 IPO 或远端股票信号。 -3. 无需修改 QMT 服务端或 `sdk/`。历史接口适配完全位于 `strategy/etf/data.py`; - 实时行情、持仓和交易继续使用已有 QMT SDK。 -4. 确认外部接口提供配置 ETF 的足量、最新日线。HTTP 错误、业务失败、空列表、 - 证券代码不一致或数据异常都会跳过该标的,不改用其他证券数据。 - 2026-09-17 联通验证中,股票样本 `600584.SH` 成功返回 200 条,示例 ETF - `510300.SH` 返回 404;需要数据服务覆盖实际配置的 ETF 后才能正常运行。 -5. 按现有方式运行 `python main.py`。每 30 秒执行,午休暂停,15:00 退出。 - -本次新增不会自动切换已有实盘账户,也没有进行实盘委托。 - -## 离线验证 - -在 `py-client` 中运行: - -```powershell -python -m unittest discover -s tests -p test_etf.py -v -python -m unittest discover -s tests -v -``` - -覆盖指标、未收盘日线排除、反弹确认、固定手数、限仓、资金共享、部分成交、拒单、 -快照延迟、T+1、重启防重、峰值恢复、状态损坏、外部日线接口与异常响应。 -离线行为验证不等同于历史收益回测或实盘联调。 diff --git a/py-client/strategy/etf/__init__.py b/py-client/strategy/etf/__init__.py deleted file mode 100644 index d2a32fb..0000000 --- a/py-client/strategy/etf/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""A 股场内 ETF:均线中轴、ATR 网格与 BOLL 低吸策略。""" diff --git a/py-client/strategy/etf/boot.py b/py-client/strategy/etf/boot.py index f7ca618..293192c 100644 --- a/py-client/strategy/etf/boot.py +++ b/py-client/strategy/etf/boot.py @@ -1,67 +1,191 @@ -"""ETF 策略入口:每 30 秒运行,日线指标当天缓存,失败标的单独重试。""" +"""趋势策略启动器。 + +该模块负责组合 SDK、配置、状态存储和趋势策略组件,供 main.py 调用。 +""" -from datetime import datetime, timedelta -import hashlib -import logging as log -from pathlib import Path import time -import httpx +import logging as log +from concurrent.futures import Future, ThreadPoolExecutor +from datetime import datetime import config from libs.calc import trading_time -from libs.snapshot import cache_portfolio +from libs.market import market_allow_open +from libs.overview import Overview +from libs.signal import init_signals, SignalItem from sdk import Client - -from .config import load -from .data import daily_bars -from .engine import Engine -from .indicators import calculate -from .state import Store - +from libs.snapshot import cache_portfolio +from libs.grid_take_profit import GridTrailingTracker +from libs.order import OrderBook +from libs.watch import DipWatch +from libs.runtime import Runtime +from .open import open_signal +from .positions import manage_positions +from .signal import gen_signals def StartETF() -> None: - cfg = load(config.account_config.etf_config_path or None) - account = str(config.account_config.account_id).strip() - if not account: - raise ValueError('ETF 策略缺少账户编号') - key = hashlib.sha256(account.encode('utf-8')).hexdigest() - store = Store(Path(config.global_config.qmt_data_dir) / 'etf' / key / 'state.json', account) - # 独立 HTTP 连接池读取外部日线,不向外部接口发送 QMT 认证信息。 - with Client(config.global_config.qmt_base_url, config.global_config.qmt_token, config.HTTP_TIMEOUT) as client, \ - httpx.Client(timeout=config.HTTP_TIMEOUT) as history_client: - engine = Engine(client, cfg, store, config.account_config.min_cash_ratio, - config.account_config.excluded_codes) - log.info('[ETF启动] 标的=%s 每次=%d手 每只上限=%d手 状态=%s', - cfg.codes, cfg.buy_hands, cfg.max_hands, store.path) - log.info('[ETF启动] 管理配置白名单内已有持仓,卖出以券商可用份额为限') - indicators, retry_at = {}, {} - cached_day = None + """初始化趋势策略,并以 30 秒间隔持续执行。""" + client = Client( + config.global_config.qmt_base_url, + config.global_config.qmt_token, + config.HTTP_TIMEOUT, + ) + executor = None + try: + portfolio = client.portfolio() + assets = portfolio.assets + positions = list(portfolio.positions.values()) + cache_portfolio(config.account_config.account_id, assets, positions, client.deals()) + order_book = OrderBook() + order_book.refresh(client, portfolio.orders) + + executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ETF") + run = Runtime( + client=client, + etf_cfg=config.etf_config, + orders=order_book, + executor=executor, + ) + + # 获取本策略的信号开仓数据 + signals = gen_signals(run) + log.info( + "[启动] ETF策略已启动,账户=%s,信号=%d,持仓=%d", + config.account_config.account_id, + len(signals), + len(positions), + ) + + Overview(assets, positions, config.account_config) + + DEFAULT_TICK_INTERVAL = 30 while True: - now = datetime.now() - if now.hour >= 15: - log.info('[ETF结束] 已到 15:00') + lt = time.localtime() + if (lt.tm_hour, lt.tm_min, lt.tm_sec) >= (15, 0, 0): + log.info("[ETF] 已到 15:00,结束趋势策略") return - if trading_time(now): - try: - if cached_day != now.date(): - indicators, retry_at, cached_day = {}, {}, now.date() - for code in cfg.codes: - if code in indicators or now < retry_at.get(code, datetime.min): - continue - try: - rows = daily_bars(history_client, code, now.date()) - indicators[code] = calculate(rows, now.date(), cfg) - log.info('[ETF指标] %s %s', code, indicators[code]) - except Exception: - retry_at[code] = now + timedelta(minutes=5) - log.exception('[ETF日线] %s 获取或计算失败,5分钟后重试', code) - portfolio = client.portfolio() - ticks = client.full_tick(list(cfg.codes)) - engine.run(portfolio, ticks, indicators, datetime.now()) - try: - cache_portfolio(account, portfolio.assets, list(portfolio.positions.values()), client.deals()) - except Exception: - log.exception('[ETF采集] 成交快照读取失败') - except Exception: - log.exception('[ETF异常] 本轮失败,下一轮继续') - time.sleep(30 - datetime.now().second % 30) + current_sec = lt.tm_sec + + # 计算距离下一个目标时间点(0秒或30秒)的等待时间 + if current_sec < DEFAULT_TICK_INTERVAL: + wait_seconds = DEFAULT_TICK_INTERVAL - current_sec + elif current_sec < 60: + wait_seconds = 60 - current_sec + else: + wait_seconds = DEFAULT_TICK_INTERVAL + + # 等待到目标时间点 + time.sleep(wait_seconds) + + # 单轮失败不能杀死唯一的交易定时线程。 + try: + RunOnce(run, signals) + except Exception as e: + log.error( + f"[ETF] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True + ) + finally: + try: + if executor is not None: + executor.shutdown(wait=True) + finally: + client.close() + + +def RunOnce(run: Runtime, signals: list[SignalItem]) -> None: + """按固定步骤执行一轮趋势策略, ``RunOnce``。""" + if not trading_time(datetime.now()): + return + + print( + "=" * 40 + f" Ticker {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " + "=" * 40 + ) + + started_at = time.monotonic() + + # 1. 一次获取资产、持仓和订单,并清理过期订单。 + try: + portfolio = run.client.portfolio() + assets = portfolio.assets + position_codes = list(portfolio.positions) + positions = list(portfolio.positions.values()) + cache_portfolio(run.account_cfg.account_id, assets, positions, run.client.deals()) + run.orders.refresh(run.client, portfolio.orders) + except Exception: + log.exception("[Portfolio] 刷新账户快照失败") + return + + futures: list[tuple[str, Future]] = [] + + # 2. 验证可用资金;低于资金安全线时禁止开新仓。 + allow_open_by_cash = ( + assets.available >= assets.total * run.account_cfg.min_cash_ratio + ) + if not allow_open_by_cash: + log.info( + "[Status] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f", + assets.available, + assets.total, + ) + + # 3. 获取大盘状态,只有大盘信号允许时才执行开仓。 + market_ok = market_allow_open() + + # 4. 验证有效开仓信号:排除已有持仓。 + allow_open: list[SignalItem] = [] + allow_codes: list[str] = [] + for signal in signals: + if signal.code not in portfolio.positions: + allow_open.append(signal) + allow_codes.append(signal.code) + + if allow_open and not market_ok: + log.info("[开仓] 禁止开仓:大盘信号不允许,候选=%d", len(allow_open)) + + # 5. 获取持仓和待开仓证券的实时行情 tick。 + all_codes = list(dict.fromkeys(position_codes + allow_codes)) + try: + ticks = run.client.full_tick(all_codes) + except Exception: + log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes)) + return + + log.info( + "[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s", + len(positions), + len(allow_open), + market_ok, + allow_open_by_cash, + ) + + # 启动线程,开始计算 + # 7. 持仓计算。 + futures.append( + ( + "持仓计算", + run.executor.submit( + manage_positions, run, ticks, positions, market_ok, assets.available + ), + ) + ) + + # 8. 开仓计算:必须同时存在有效信号且大盘允许开仓。 + if allow_open and market_ok and allow_open_by_cash: + futures.append( + ("开仓计算", run.executor.submit(open_signal, run, ticks, allow_open)) + ) + + # 9. 开始执行 + for name, future in futures: + _wait_worker(name, future) + log.info( + "[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000) + ) + + +def _wait_worker(name: str, future: Future) -> None: + """保留单轮继续运行的语义,分别记录工作线程异常。""" + try: + future.result() + except Exception: + log.exception("[运行] %s线程失败", name) diff --git a/py-client/strategy/etf/config.py b/py-client/strategy/etf/config.py deleted file mode 100644 index 4a41e1b..0000000 --- a/py-client/strategy/etf/config.py +++ /dev/null @@ -1,59 +0,0 @@ -"""独立读取 ETF 参数,不修改现有账户策略的默认行为。""" - -from dataclasses import dataclass, fields -from pathlib import Path -import math -import re -import yaml - - -@dataclass(frozen=True) -class ETFConfig: - codes: tuple[str, ...] = () - buy_hands: int = 1 - max_hands: int = 10 - atr_period: int = 14 - atr_multiplier: float = 1.0 - boll_period: int = 20 - boll_std: float = 2.0 - min_grid_pct: float = 0.5 - rebound_pct: float = 0.61 - watch_seconds: int = 600 - min_profit_pct: float = 0.5 - commission_rate: float = 0.0003 - min_commission: float = 5.0 - max_tick_age_seconds: int = 90 - - def __post_init__(self): - # 限定沪深场内 ETF 代码形态;具体跟踪 A 股的标的由配置白名单决定。 - if not isinstance(self.codes, (list, tuple)) or not self.codes: - raise ValueError('etf.yaml 的 codes 必须是非空 ETF 代码列表') - if any(not isinstance(c, str) or not re.fullmatch(r'(?:5[0-9]{5}\.SH|1[58][0-9]{4}\.SZ)', c) - for c in self.codes) or len(set(self.codes)) != len(self.codes): - raise ValueError('ETF 代码必须唯一,使用完整沪深场内代码,如 510300.SH、159915.SZ') - object.__setattr__(self, 'codes', tuple(self.codes)) - for name in ('buy_hands', 'max_hands', 'atr_period', 'boll_period', 'watch_seconds', 'max_tick_age_seconds'): - if type(getattr(self, name)) is not int or getattr(self, name) <= 0: - raise ValueError(f'{name} 必须为正整数') - if not self.buy_hands <= self.max_hands <= 10: - raise ValueError('必须满足 buy_hands <= max_hands <= 10,每手 100 份') - if not 2 <= self.atr_period <= 60 or not 2 <= self.boll_period <= 60: - raise ValueError('ATR、BOLL 周期必须在 2 到 60 日之间') - for name in ('atr_multiplier', 'boll_std', 'min_grid_pct', 'rebound_pct', 'min_profit_pct', - 'commission_rate', 'min_commission'): - value = getattr(self, name) - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0: - raise ValueError(f'{name} 必须是有限非负数') - if name not in ('commission_rate', 'min_commission') and value == 0: - raise ValueError(f'{name} 必须大于零') - - -def load(path: str | Path | None = None) -> ETFConfig: - path = Path(path) if path else Path(__file__).resolve().parents[2] / 'etc' / '_etf.yaml' - try: - raw = yaml.safe_load(path.read_text(encoding='utf-8')) - except (OSError, yaml.YAMLError) as exc: - raise ValueError(f'ETF 配置读取失败:{path}') from exc - if not isinstance(raw, dict) or set(raw) - {f.name for f in fields(ETFConfig)}: - raise ValueError('ETF 配置必须为对象,且不能包含未知参数') - return ETFConfig(**raw) diff --git a/py-client/strategy/etf/data.py b/py-client/strategy/etf/data.py deleted file mode 100644 index 2812adb..0000000 --- a/py-client/strategy/etf/data.py +++ /dev/null @@ -1,55 +0,0 @@ -"""ETF 专用历史日线适配,不依赖或修改 QMT SDK。""" - -from datetime import date, datetime -import math -import re - -import httpx - - -DAILY_URL = 'http://139.224.247.176:13499/etf/daily' - - -def daily_bars(client: httpx.Client, code: str, today: date, count: int = 120) -> list[dict]: - """读取指定证券日线;只使用 code 参数,截取历史窗口在本地完成。""" - response = client.get(DAILY_URL, params={'code': code}) - response.raise_for_status() - return parse_daily(response.json(), code, today, count) - - -def parse_daily(payload: dict, code: str, today: date, count: int = 120) -> list[dict]: - """校验业务状态、证券归属和 OHLC,将 trade_date 转为指标需要的 date。""" - if type(count) is not int or count <= 0: - raise ValueError('日线数量必须为正整数') - if not isinstance(payload, dict) or type(payload.get('code')) is not int or payload['code'] != 0: - raise ValueError(f'日线接口业务失败:{payload.get("message", "状态无效") if isinstance(payload, dict) else "响应非对象"}') - details = payload.get('details') - if not isinstance(details, list) or not details: - raise ValueError(f'{code} 日线接口未返回有效 details 列表') - bars = {} - for row in details: - if not isinstance(row, dict) or row.get('ts_code') != code: - raise ValueError(f'{code} 日线证券代码不一致') - stamp = str(row.get('trade_date', '')) - if not re.fullmatch(r'[0-9]{8}', stamp): - raise ValueError(f'{code} 日线日期无效:{stamp}') - day = datetime.strptime(stamp, '%Y%m%d').date() - # 当前日及未来日线均不可用于盘中指标,先过滤再截取最近 count 根。 - if day >= today: - continue - if stamp in bars: - raise ValueError(f'{code} 日线日期重复:{stamp}') - values = {} - for key in ('open', 'high', 'low', 'close'): - value = row.get(key) - if isinstance(value, bool) or not isinstance(value, (int, float, str)): - raise ValueError(f'{code} 日线 {key} 无效') - value = float(value) - if not math.isfinite(value) or value <= 0: - raise ValueError(f'{code} 日线 {key} 非有限正数') - values[key] = value - if not (values['low'] <= values['open'] <= values['high'] - and values['low'] <= values['close'] <= values['high']): - raise ValueError(f'{code} 日线 OHLC 关系异常') - bars[stamp] = dict(date=stamp, **values) - return [bars[stamp] for stamp in sorted(bars)[-count:]] diff --git a/py-client/strategy/etf/engine.py b/py-client/strategy/etf/engine.py deleted file mode 100644 index 6bb3e1b..0000000 --- a/py-client/strategy/etf/engine.py +++ /dev/null @@ -1,202 +0,0 @@ -"""串行 ETF 决策:先核对成交,再止盈,最后低吸并预留本轮资金。""" - -from datetime import datetime -import logging as log -import math - -from libs.grid_take_profit import GridState, GridTrailingTracker -from libs.order import OrderBook, PlaceOrderRequest -from libs.watch import DipWatch -from sdk import OP_BUY, OP_SELL, Portfolio, PositionItem, Tick - -from .config import ETFConfig -from .indicators import Indicators -from .state import Store, SymbolState - - -class Engine: - def __init__(self, client, cfg: ETFConfig, store: Store, min_cash_ratio: float, excluded=()): - if not math.isfinite(min_cash_ratio) or not 0 <= min_cash_ratio <= 1: - raise ValueError('ETF min_cash_ratio 必须在 0 到 1 之间') - self.client, self.cfg, self.store = client, cfg, store - if any(state.pending and code not in cfg.codes for code, state in store.symbols.items()): - raise ValueError('存在已从配置移除的 ETF 待确认委托,请保留该标的直到核对完成') - self.min_cash_ratio, self.excluded = min_cash_ratio, set(excluded) - self.orders = OrderBook(cancel_timeout_sec=120) - self.watch = DipWatch(cfg.watch_seconds, cfg.rebound_pct) - - def fee(self, amount: float) -> float: - return max(self.cfg.min_commission, amount * self.cfg.commission_rate) - - def reconcile(self, code: str, state: SymbolState, position: PositionItem, orders) -> bool: - """必须同时得到终态委托与匹配的持仓快照,才解除本地待确认锁。""" - pending = state.pending - # 柜台清仓记录可能保留旧成本;零持仓应统一视为零成本,避免每轮清空低吸观察。 - current_cost = position.open_price if position.volume > 0 else 0.0 - if pending: - matches = [o for o in orders if o.local_order_id == pending['id'] and o.stock_code == code] - if len(matches) != 1: - log.warning('[ETF待确认] %s 订单=%s 回报缺失或不唯一,暂停该标的', code, pending['id']) - return False - order = matches[0] - status = str(order.order_status) - if status not in {'53', '54', '56', '57'}: - log.info('[ETF待确认] %s 订单=%s 状态=%s 成交=%s', code, pending['id'], status, order.volume_traded) - return False - filled = order.volume_traded - if (order.side != pending['side'] or type(filled) is not int or not 0 <= filled <= pending['volume'] - or (status == '56' and filled != pending['volume'])): - log.warning('[ETF待确认] %s 委托方向或成交数量不一致', code) - return False - expected = pending['base_volume'] + (filled if pending['side'] == 'BUY' else -filled) - if position.volume != expected: - log.warning('[ETF待确认] %s 持仓=%s 预期=%s,等待快照同步', code, position.volume, expected) - return False - if filled and pending['side'] == 'BUY': - if not math.isfinite(order.traded_price) or order.traded_price <= 0: - log.warning('[ETF待确认] %s 缺少实际成交均价', code) - return False - state.last_buy = order.traded_price - if filled: - state.reset_profit() - log.info('[ETF回报] %s 订单=%s 状态=%s 成交=%s 持仓=%s', - code, pending['id'], status, filled, position.volume) - state.pending = {} - # 终态已被快照证实,可清理共用委托簿的短期方向缓存。 - self.orders.busy_cache.delete(f"{pending['side']}-{code}") - elif position.volume != state.volume or not math.isclose(current_cost, state.cost, abs_tol=1e-8): - # 配置内已有仓位一并管理;人工改变仓位时重新建立止盈和加仓基准。 - state.reset_profit() - state.last_buy = position.open_price if position.volume > 0 else 0.0 - self.watch.forget(code) - state.volume, state.cost = position.volume, current_cost - if position.volume == 0: - state.last_buy = 0.0 - state.reset_profit() - return True - - def run(self, portfolio: Portfolio, ticks: dict[str, Tick], indicators: dict[str, Indicators], now: datetime): - # 防重看全账户,自动撤单只针对 ETF 前缀。 - self.orders.refresh(self.client, portfolio.orders, cancel_prefix='ETF-') - assets = portfolio.assets - if not all(math.isfinite(v) and v >= 0 for v in (assets.total, assets.available)): - raise ValueError('账户资金无效') - # 先扣除所有未确认买单,不能等遍历到后面的标的才预留。 - pending_cash = sum(s.pending['reserved'] for s in self.store.symbols.values() - if s.pending.get('side') == 'BUY') - cash = max(0.0, assets.available - assets.total * self.min_cash_ratio - pending_cash) - for code in self.cfg.codes: - state = self.store.get(code) - had_pending = bool(state.pending) - position = portfolio.positions.get(code, PositionItem(stock_code=code)) - try: - if (type(position.volume) is not int or position.volume < 0 - or type(position.can_use_volume) is not int or position.can_use_volume < 0 - or type(position.on_road_volume) is not int or position.on_road_volume < 0 - or not math.isfinite(position.open_price)): - raise ValueError('持仓数量或成本无效') - if not self.reconcile(code, state, position, portfolio.orders): - continue - self.store.save() - if code in self.excluded: - self.watch.forget(code) - continue - if self.orders.busy(code, 'BUY') or self.orders.busy(code, 'SELL'): - continue - if position.on_road_volume > 0 or any( - o.stock_code == code and str(o.order_status) not in {'53', '54', '56', '57'} - for o in portfolio.orders - ): - log.info('[ETF跳过] %s 存在在途份额或未知委托状态', code) - continue - tick, ind = ticks.get(code), indicators.get(code) - if ind is None or not self.fresh_tick(tick, now): - self.watch.forget(code) - log.info('[ETF跳过] %s 日线或实时行情无效/过期', code) - continue - price = round(tick.last_price, 3) - if position.volume > 0 and position.open_price <= 0: - raise ValueError('非空持仓缺少有效成本') - if self.sell(code, state, position, price, ind): - continue - cash -= self.buy(code, state, position, price, ind, cash, now) - except Exception: - # 异常后不允许其他标的重复使用可能已提交的资金。 - if not had_pending and state.pending.get('side') == 'BUY': - cash = max(0, cash - state.pending['reserved']) - log.exception('[ETF异常] %s 本轮跳过', code) - - def fresh_tick(self, tick: Tick | None, now: datetime) -> bool: - if tick is None or not math.isfinite(tick.last_price) or tick.last_price <= 0: - return False - try: - stamp = datetime.strptime(tick.raw['timetag'], '%Y%m%d %H:%M:%S') - return stamp.date() == now.date() and 0 <= (now - stamp).total_seconds() <= self.cfg.max_tick_age_seconds - except (KeyError, TypeError, ValueError): - return False - - def sell(self, code: str, state: SymbolState, position: PositionItem, price: float, ind: Indicators) -> bool: - if position.volume <= 0: - return False - cost = position.open_price - volume = min(position.volume, position.can_use_volume) - volume = volume // 100 * 100 - # 即使 T+1 当天不可卖,也持续记录高位与峰值;翌日可卖时继续判断。 - estimate_volume = volume or position.volume - profit = (price - cost) * estimate_volume - enough_profit = ((price - cost) / cost * 100 >= self.cfg.min_profit_pct - and profit > self.fee(cost * estimate_volume) + self.fee(price * estimate_volume)) - if not state.armed: - if price < max(ind.upper, ind.ma60 + ind.grid, cost + ind.grid) or not enough_profit: - return False - state.armed, state.sell_grid = True, ind.grid - state.peak = math.floor((price - cost) / state.sell_grid) - self.store.save() - log.info('[ETF止盈] %s 高位启动,峰值格=%d 格距=%.3f', code, state.peak, state.sell_grid) - return True - # 通过公开 observe 接口恢复跨日峰值,复用现有网格回撤算法。 - tracker = GridTrailingTracker(1.0) - tracker.observe(code, state.peak) - observation = tracker.observe(code, (price - cost) / state.sell_grid) - state.peak = observation.peak_grid - self.store.save() - if observation.state == GridState.RETREAT and enough_profit and volume > 0: - self.submit(code, state, position, 'SELL', volume, price, 0.0) - # 止盈已启动时不同时补仓,避免同一轮买卖冲突。 - return True - - def buy(self, code: str, state: SymbolState, position: PositionItem, price: float, - ind: Indicators, cash: float, now: datetime) -> float: - volume = self.cfg.buy_hands * 100 - if position.volume + volume > self.cfg.max_hands * 100: - self.watch.forget(code) - return 0.0 - # 首次进入 BOLL 下轨且低于均线一格;加仓须比上次实际买入再低至少一格。 - ceiling = min(ind.ma60, state.last_buy - ind.grid) if state.last_buy else ind.ma60 - entry = min(ind.lower, ind.ma60 - ind.grid, ceiling) - if price > ceiling: - self.watch.forget(code) - return 0.0 - if code not in self.watch.data and price > entry: - return 0.0 - amount = round(price, 3) * volume - reserved = amount + self.fee(amount) - if reserved > cash: - return 0.0 - if not self.watch.triggered('ETF低吸', code, price, now): - return 0.0 - self.submit(code, state, position, 'BUY', volume, price, reserved) - return reserved - - def submit(self, code: str, state: SymbolState, position: PositionItem, side: str, - volume: int, price: float, reserved: float): - order_id = self.orders.new_order_id('ETF', side) - # 先持久化再提交;超时、异常、进程重启均不会丢失未确认的意图。 - state.pending = dict(id=order_id, side=side, volume=volume, - base_volume=position.volume, reserved=reserved) - self.store.save() - request = PlaceOrderRequest(OP_BUY if side == 'BUY' else OP_SELL, - code, volume, order_id, 'etf', price=round(price, 3)) - accepted = self.orders.place(self.client, request) - log.info('[ETF委托] %s %s 数量=%d 限价=%.3f 接口返回=%s 订单=%s,等待柜台核对', - code, side, volume, price, accepted, order_id) diff --git a/py-client/strategy/etf/indicators.py b/py-client/strategy/etf/indicators.py deleted file mode 100644 index 170cdcb..0000000 --- a/py-client/strategy/etf/indicators.py +++ /dev/null @@ -1,55 +0,0 @@ -"""仅用已收盘日线计算指标,避免把盘中未完成的日线混入信号。""" - -from dataclasses import dataclass -from datetime import date, datetime -from decimal import Decimal, ROUND_CEILING -import math -from statistics import fmean, pstdev - -from .config import ETFConfig - - -@dataclass(frozen=True) -class Indicators: - day: str - ma60: float - atr: float - lower: float - middle: float - upper: float - grid: float - - -def calculate(rows: list[dict], today: date, cfg: ETFConfig) -> Indicators: - """MA60 + Wilder ATR + BOLL(总体标准差),格距向上取整到 0.001 元。""" - bars = {} - for row in rows: - day = datetime.strptime(str(row['date']), '%Y%m%d').date() - if day >= today: - continue - if day in bars: - raise ValueError('日线包含重复日期') - high, low, close = (float(row[key]) for key in ('high', 'low', 'close')) - if not all(math.isfinite(v) and v > 0 for v in (high, low, close)) or not low <= close <= high: - raise ValueError('日线价格无效') - bars[day] = (high, low, close) - days = sorted(bars) - if len(days) < max(60, cfg.atr_period + 1, cfg.boll_period): - raise ValueError('已收盘日线不足,至少需要 60 根且能计算 ATR') - # 长期停牌或历史缓存未补齐时不使用过期信号;春节等长假允许 15 个自然日。 - if (today - days[-1]).days > 15: - raise ValueError('最近日线超过 15 个自然日,需补齐行情') - values = [bars[d] for d in days] - closes = [v[2] for v in values] - tr = [max(h - l, abs(h - closes[i - 1]), abs(l - closes[i - 1])) - for i, (h, l, _) in enumerate(values) if i > 0] - n = cfg.atr_period - atr = fmean(tr[:n]) - for value in tr[n:]: - atr = (atr * (n - 1) + value) / n - ma = fmean(closes[-60:]) - window = closes[-cfg.boll_period:] - middle, width = fmean(window), cfg.boll_std * pstdev(window) - raw_grid = max(atr * cfg.atr_multiplier, ma * cfg.min_grid_pct / 100, 0.001) - grid = float(Decimal(str(raw_grid)).quantize(Decimal('0.001'), rounding=ROUND_CEILING)) - return Indicators(days[-1].strftime('%Y%m%d'), ma, atr, middle - width, middle, middle + width, grid) diff --git a/py-client/strategy/etf/open.py b/py-client/strategy/etf/open.py new file mode 100644 index 0000000..ce6b235 --- /dev/null +++ b/py-client/strategy/etf/open.py @@ -0,0 +1,250 @@ +"""ETF 网格策略开仓:观察 → 反弹确认 → 底仓挂单。 + +信号由 ``strategy/etf/signal.py`` 的 ``gen_signals`` 生成:白名单里的每个标的 +一条信号,``tech_indicator`` 里带着已收盘指标(``etf_entry``、``etf_price`` 等)。 +本模块只负责"能不能建网 / 按哪个价挂底仓",补仓与卖出见 ``positions.py``。 + +底仓规则(``docs/etf.md`` §2、§3.5): + +1. 现价必须落在入场门槛以内(``min(区间下沿 + 通道幅度×channel_pct%, MA60)``); +2. 用 ``rt.open_watch``(``DipWatch``)确认从观察低点反弹 ``rebound_pct%``; +3. 反弹确认价就是锚点,按该价挂限价单买一档 ``buy_shares`` 股; +4. 资金不足或挂单失败时撤销锚点,下一轮重新触发,不留"死锚点"。 +""" + +from datetime import datetime +import logging as log +import math +from typing import Any, Mapping + +from libs.calc import trading_time +from libs.order import PlaceOrderRequest +from libs.runtime import Runtime +from libs.signal import SignalItem +from sdk import OP_BUY + +from .signal import IND_ENTRY, IND_PRICE + + +def entry_prices(item: SignalItem) -> tuple[float, float]: + """返回 (入场门槛, 最近收盘价);缺失时对应项为 0。""" + values = getattr(item, "tech_indicator", None) + if not isinstance(values, Mapping): + values = {} + entry = _positive(values.get(IND_ENTRY) or values.get("entry")) + price = _positive(values.get(IND_PRICE) or getattr(item, "last_close", 0.0)) + return entry, price + + +def classify_entry(item: SignalItem, runtime: Runtime, price: float) -> tuple[bool, str]: + """判定现价是否处于入场区,并维护 ``open_watch`` 的观察状态。 + + Returns: + (是否已确认可建网, 说明)。价格在入场区之上时清除观察点, + 防止用"陈旧低点 + 现价"拼出虚假反弹。 + """ + entry, _ = entry_prices(item) + if entry <= 0: + return False, "缺少入场门槛指标" + + if price > entry: + # 价格回到入场区上方:旧观察低点作废,必须重新形成低点。 + runtime.open_watch.forget(item.code) + return False, f"未进入入场区(现价{price:.3f}>门槛{entry:.3f})" + + if not runtime.open_watch.triggered("建网", item.code, price): + return False, f"入场区内等待反弹确认(门槛{entry:.3f})" + return True, f"反弹已确认,锚点={price:.3f}" + + +def open_signal(run: Runtime, ticks, open_signals) -> None: + """逐个验证开仓信号,按锚点价挂出底仓限价单。""" + if not trading_time(datetime.now()): + return + + for item in open_signals: + code = item.code + try: + symbol = _symbol(run, code) + if symbol is None: + log.info("[ETF开仓] %s 跳过:不在 _etf.yaml 白名单内", code) + continue + if code in (getattr(run.account_cfg, "excluded_codes", None) or []): + log.info("[ETF开仓] %s 跳过:已配置为排除证券", code) + continue + + price = _tick_price(run, code, (ticks or {}).get(code)) + if price <= 0: + continue + if run.orders.busy(code, "BUY"): + log.info("[ETF开仓] %s 跳过:买入委托处理中", code) + continue + + confirmed, reason = classify_entry(item, run, price) + if not confirmed: + log.info("[ETF开仓] %s 跳过:%s", code, reason) + continue + + volume = _entry_volume(run, code) + if volume <= 0: + run.open_watch.forget(code) # 不留挂不出单的死锚点 + continue + if not _budget_ok(run, price * volume): + run.open_watch.forget(code) + log.info( + "[ETF开仓] %s 跳过:本轮预算不足,锚点作废,现价=%.3f,需要=%.2f", + code, + price, + price * volume, + ) + continue + + do_open(run, code, volume, price, reason) + except Exception as exc: + log.exception("[ETF开仓] %s 处理异常:%s", code, exc) + + +def do_open(run: Runtime, code: str, volume: int, price: float, reason: str = "") -> bool: + """按锚点价挂底仓买入委托;成功返回 True。""" + request = PlaceOrderRequest( + op=OP_BUY, + code=code, + volume=int(volume), + order_id=run.orders.new_order_id("ETF", "BUY"), + strategy_name=strategy_name(run), + kind="base", + price=price, + ) + if not run.orders.place(run.client, request): + run.open_watch.forget(code) + log.warning("[ETF开仓] %s 底仓挂单失败,撤销锚点:%s", code, reason) + return False + + run.open_watch.forget(code) + log.info( + "[ETF开仓] %s 建网底仓 %d 股,锚点=%.3f,%s", code, request.volume, price, reason + ) + return True + + +def strategy_name(run: Runtime) -> str: + """委托上的策略名:与账户 ``strategy`` 一致,便于按策略过滤委托与日志。""" + return str(getattr(run.account_cfg, "strategy", "") or "etf").strip().lower() or "etf" + + +def _symbol(run: Runtime, code: str) -> Any | None: + """取标的配置;不在白名单内返回 None。""" + symbols = getattr(getattr(run, "etf_cfg", None), "symbols", None) + if not isinstance(symbols, Mapping): + return None + return symbols.get(code) + + +def _tick_price(run: Runtime, code: str, tick) -> float: + """校验实时行情:有限正数、时间戳为当天且未超过 ``max_tick_age_seconds``。""" + price = _positive(getattr(tick, "last_price", 0.0)) if tick is not None else 0.0 + if price <= 0: + log.info("[ETF开仓] %s 跳过:价格无效", code) + return 0.0 + + now = datetime.now() + stamp = _tick_stamp(getattr(tick, "raw", None)) + if stamp is None: + log.info("[ETF开仓] %s 跳过:行情时间戳缺失", code) + return 0.0 + if stamp.date() != now.date(): + log.info("[ETF开仓] %s 跳过:行情时间戳非当天(%s)", code, stamp) + return 0.0 + + limit = _max_tick_age(run) + age = (now - stamp).total_seconds() + if age > limit: + log.info("[ETF开仓] %s 跳过:行情已过期 %.0f 秒>%d 秒", code, age, limit) + return 0.0 + return price + + +def _tick_stamp(raw: Any) -> datetime | None: + """解析行情时间戳(``20260916103000`` / ``2026-09-16 10:30:00``)。""" + if not isinstance(raw, Mapping): + return None + text = str(raw.get("timetag") or raw.get("time") or raw.get("stime") or "") + digits = "".join(char for char in text if char.isdigit()) + if len(digits) < 14: + return None + try: + return datetime.strptime(digits[:14], "%Y%m%d%H%M%S") + except ValueError: + return None + + +def _max_tick_age(run: Runtime) -> int: + defaults = getattr(getattr(run, "etf_cfg", None), "defaults", None) + value = getattr(defaults, "max_tick_age_seconds", 0) + return value if type(value) is int and value > 0 else 90 + + +def _entry_volume(run: Runtime, code: str) -> int: + """底仓股数:配置的 ``buy_shares``,按整手与单标的上限裁剪。""" + volume = getattr(_symbol(run, code), "buy_shares", 0) + if type(volume) is not int or volume <= 0: + log.info("[ETF开仓] %s 跳过:buy_shares 配置无效", code) + return 0 + volume -= volume % 100 + if volume <= 0: + return 0 + + max_shares = getattr(_symbol(run, code), "max_shares", None) + if type(max_shares) is int and max_shares > 0: + volume = min(volume, max_shares - max_shares % 100) + return volume + + +def _budget_ok(run: Runtime, amount: float) -> bool: + """本轮可用预算 = 券商可用资金 − 现金安全线 − 所有在途买单预留。""" + assets = _latest_assets(run) + available = getattr(assets, "available", None) + if isinstance(available, bool) or not isinstance(available, (int, float)): + # 拿不到资金快照时不阻拦,最终由柜台与在途委托锁把关。 + return True + + total = _positive(getattr(assets, "total", 0.0)) + ratio = getattr(run.account_cfg, "min_cash_ratio", 0.0) + if isinstance(ratio, bool) or not isinstance(ratio, (int, float)): + ratio = 0.0 + budget = float(available) - total * float(ratio) - pending_buy_amount(run) + return amount <= max(0.0, budget) + + +def pending_buy_amount(run: Runtime) -> float: + """所有未确认买单的预留金额(不是只算当前标的)。""" + reserved = 0.0 + for order in getattr(run.orders, "data", None) or []: + if getattr(order, "side", "") != "BUY": + continue + remaining = getattr(order, "volume_total_original", 0) - getattr( + order, "volume_traded", 0 + ) + price = getattr(order, "limit_price", 0.0) or getattr(order, "traded_price", 0.0) + if remaining > 0 and _positive(price) > 0: + reserved += float(remaining) * float(price) + return reserved + + +def _latest_assets(run: Runtime) -> Any: + """读取最新资金快照:优先用 Runtime 上缓存的,其次问一次客户端。""" + cached = getattr(run, "assets", None) + if cached is not None: + return cached + try: + return run.client.assets() + except Exception: + return None + + +def _positive(value: Any) -> float: + """把配置/指标值转成有限正浮点数;不合法时返回 0。""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return 0.0 + value = float(value) + return value if math.isfinite(value) and value > 0 else 0.0 diff --git a/py-client/strategy/etf/positions.py b/py-client/strategy/etf/positions.py new file mode 100644 index 0000000..006d620 --- /dev/null +++ b/py-client/strategy/etf/positions.py @@ -0,0 +1,490 @@ +"""ETF 网格策略持仓管理:整仓止盈、单档止盈、百分比补仓。 + +规则见 ``docs/etf.md`` §4、§5,参数全部取自 ``rt.etf_cfg``: + +- 持仓数量与可用份额**只以券商快照为准**,本地不重算持仓; +- 档位由"持仓股数 ÷ 每档 ``buy_shares``"推出,是唯一能跨轮次存活的档位依据; +- 上一档成交价优先用券商成本价 ``open_price``,没有可用成本时回落到本模块 + 记录的上次成交价; +- 主出口:盈亏率 ≥ ``min_profit_pct`` 整仓卖出(受 T+1 与 ``min_hold_days`` 限制); +- 副出口:单档盈利从峰值回撤(``inner_step`` 网格、峰值已抬到 ``inner_grids``)只卖该档; +- 补仓:自上一档再跌 ``add_pct`` 且 ``add_watch`` 反弹确认,最多 ``max_adds`` 次; +- 超过 ``max_hold_days`` 只告警不强制平仓,残量留作隔夜持仓。 +""" + +from dataclasses import dataclass, field +from datetime import datetime +import logging as log +import math +from threading import Lock +from typing import Any, Mapping + +from libs.calc import trading_time +from libs.grid_take_profit import GridState, GridTrailingTracker +from libs.order import PlaceOrderRequest +from libs.runtime import Runtime +from sdk import OP_BUY, OP_SELL, PositionItem, Tick + +from .open import pending_buy_amount, strategy_name + +# 单次卖出/补仓委托被拒后的冷却时间,避免同一 tick 反复重试。 +REJECT_COOLDOWN_SECONDS = 30 + + +@dataclass(slots=True) +class TradeDecision: + """一次止盈或补仓判断的统一结果。""" + + submitted: bool + message: str = "" + reserved_cash: float = 0.0 + + +@dataclass(slots=True) +class SymbolProgress: + """单标的的进程内进度:补仓次数、上次成交价与冷却时刻。""" + + adds: int = 0 + last_buy_price: float = 0.0 + last_add_day: int = 0 + last_sell_at: datetime | None = None + warned_hold_days: int = 0 + # 主出口当日重试次数:挂单失败或状态未回报时不每轮重试。 + failed_sell_day: int = 0 + + +_progress: dict[str, SymbolProgress] = {} +_trackers: dict[str, GridTrailingTracker] = {} +_state_lock = Lock() + + +def manage_positions( + runtime: Runtime, + ticks: Mapping[str, Tick], + positions: list[PositionItem], + market_ok: bool, + available: float, +) -> None: + """逐只核对持仓并执行卖出与补仓。""" + if not trading_time(datetime.now()): + return + + # 所有标的分预算:先扣除全部在途买单,避免轮到后面才发现钱不够。 + budget = _available_budget(runtime, available, market_ok) + + for position in positions: + code = position.stock_code + try: + excluded = getattr(runtime.account_cfg, "excluded_codes", None) or [] + if code in excluded: + log.info("[ETF持仓] %s 跳过:已配置为排除证券", code) + continue + symbol = _symbol(runtime, code) + if symbol is None: + log.info("[ETF持仓] %s 跳过:不在 _etf.yaml 白名单内", code) + continue + + tick = ticks.get(code) + price = _tick_price(runtime, code, tick) + if price <= 0 or position.volume <= 0: + log.warning( + "[ETF持仓] %s 跳过:持仓或行情无效,持仓=%d,现价=%.3f", + code, + position.volume, + price, + ) + continue + + # 盈亏率口径与主出口一致:以券商成本价为分母。 + cost = _positive(position.open_price) + pnl_rate = (price - cost) / cost * 100 if cost > 0 else 0.0 + level = position_level(runtime, position) + progress = _get_progress(code, level) + sellable = sellable_volume(symbol, position) + + # 1. 主出口:整仓止盈,一次清空网格。 + exit_decision = handle_exit( + runtime, symbol, position, tick, pnl_rate, sellable + ) + action = exit_decision.message or "未触发" + if exit_decision.submitted: + _log_position(code, position, price, pnl_rate, action, "已停止") + continue + + # 2. 副出口:单档峰值回撤,只处理当前档。 + if not runtime.orders.busy(code, "SELL"): + per_level = handle_level_exit( + runtime, symbol, position, tick, pnl_rate, level + ) + action = per_level.message or action + + # 3. 时间退出:超期只告警,残量留作隔夜持仓。 + hold_decision = handle_max_hold(runtime, code, progress, level) + add_action = "未启用" + if hold_decision.submitted: + add_action = hold_decision.message + + # 4. 补仓:自上一档再跌 add_pct,且反弹确认后才买。 + if market_ok: + add_decision = handle_add( + runtime, symbol, position, tick, price, budget, level + ) + budget = max(0.0, budget - add_decision.reserved_cash) + add_action = add_decision.message or "未触发" + else: + add_action = "大盘信号不允许" + + _log_position(code, position, price, pnl_rate, action, add_action) + except Exception as exc: + log.exception("[ETF持仓] %s 处理异常:%s", code, exc) + + +def handle_exit( + runtime: Runtime, + symbol: Any, + position: PositionItem, + tick: Tick, + pnl_rate: float, + sellable: int, +) -> TradeDecision: + """主出口:盈亏率 ≥ ``min_profit_pct`` 时整仓卖出。""" + target = _default(runtime, "min_profit_pct", 1.0) + minimum = _positive(target) + if minimum <= 0 or pnl_rate < minimum: + return TradeDecision(False, f"持有中 PNL={pnl_rate:.2f}%(目标{minimum:.2f}%)") + + code = position.stock_code + volume = min(max(0, int(sellable)) - int(sellable) % 100, position.volume) + if volume <= 0: + return TradeDecision(False, f"无当日可卖整手(可用={position.can_use_volume})") + if runtime.orders.busy(code, "SELL"): + return TradeDecision(False, "卖出委托处理中") + + progress = _get_progress(code, position_level(runtime, position)) + now = datetime.now() + if progress.last_sell_at is not None and ( + now - progress.last_sell_at + ).total_seconds() < REJECT_COOLDOWN_SECONDS: + return TradeDecision(False, "卖出冷却中") + if progress.failed_sell_day == now.date().toordinal(): + # 当日挂单失败过:等收盘或等仓位变化,避免每轮重复下单。 + return TradeDecision(False, "当日整仓止盈挂单未成功,暂停重试") + + request = PlaceOrderRequest( + op=OP_SELL, + code=code, + volume=volume, + order_id=runtime.orders.new_order_id("ETF", "SELL"), + strategy_name=strategy_name(runtime), + kind="exit", + price=_tick_price_or(position.last_price, tick.last_price), + ) + submitted = runtime.orders.place(runtime.client, request) + progress.last_sell_at = now + if not submitted: + progress.failed_sell_day = now.date().toordinal() + return TradeDecision(False, "整仓止盈委托失败") + + return TradeDecision(True, f"[主出口] 盈亏率={pnl_rate:.2f}% 整仓卖出 {volume} 股") + + +def handle_level_exit( + runtime: Runtime, + symbol: Any, + position: PositionItem, + tick: Tick, + pnl_rate: float, + level: int, +) -> TradeDecision: + """副出口:单档盈利从峰值回撤且峰值已抬到 ``inner_grids`` 格时只卖该档。""" + code = position.stock_code + observation = _tracker(code, symbol).observe(f"etf:{code}:level:{level}", pnl_rate) + if observation.state is not GridState.RETREAT: + return TradeDecision( + False, f"单档网格={observation.current_grid}/峰值={observation.peak_grid}" + ) + + required = _positive(_symbol_value(symbol, "inner_grids", runtime, "inner_grids", 2.0)) + if observation.peak_grid < required: + return TradeDecision( + False, + f"峰值未达 {required:g} 格(当前峰值={observation.peak_grid})", + ) + + volume = min(max(0, int(position.can_use_volume)) - int(position.can_use_volume) % 100, + position.volume) + if volume <= 0: + return TradeDecision(False, "该档无当日可卖整仓") + if runtime.orders.busy(code, "SELL"): + return TradeDecision(False, "卖出委托处理中") + + request = PlaceOrderRequest( + op=OP_SELL, + code=code, + volume=volume, + order_id=runtime.orders.new_order_id("ETF", "SELL"), + strategy_name=strategy_name(runtime), + kind="profit", + price=_tick_price_or(position.last_price, tick.last_price), + ) + if not runtime.orders.place(runtime.client, request): + # 下单失败或撤单时必须保留峰值,等下一轮再试。 + return TradeDecision(False, "单档止盈委托失败") + + # 峰值只能在卖出成功后清除。 + _tracker(code, symbol).clear(f"etf:{code}:level:{level}") + return TradeDecision(True, f"[副出口] 第{level}档 盈亏率={pnl_rate:.2f}% 卖出 {volume} 股") + + +def handle_max_hold( + runtime: Runtime, code: str, progress: SymbolProgress, level: int +) -> TradeDecision: + """超过 ``max_hold_days`` 的轮次只告警,不强制平仓(残量留作隔夜持仓)。""" + limit = _default(runtime, "max_hold_days", 0) + if type(limit) is not int or limit <= 0: + return TradeDecision(False) + + # 本地不记录真实买入日:用"档位 + 当日首见/本次加档"推算持有自然日, + # 只为触发一次告警,不参与下单决策。没有记录时退化为"档位 ≈ 已持有天数"。 + today = datetime.now().date().toordinal() + started = progress.last_buy_day or (today - level) + if progress.warned_hold_days == today or today < started + limit: + return TradeDecision(False) + + progress.warned_hold_days = today + return TradeDecision(True, f"[超期] 已持有{max(0, today - started)}天,超过 max_hold_days={limit},仅告警不平仓") + + +def handle_add( + runtime: Runtime, + symbol: Any, + position: PositionItem, + tick: Tick, + price: float, + budget: float, + level: int, +) -> TradeDecision: + """补仓:自上一档再跌 ``add_pct`` 且反弹确认后按现价买入一档。""" + code = position.stock_code + progress = _get_progress(code, level) + max_adds = _default(runtime, "max_adds", 9) + if type(max_adds) is not int or max_adds < 0: + return TradeDecision(False, "max_adds 配置无效") + if progress.adds >= max_adds: + return TradeDecision(False, f"已满 {max_adds + 1} 档,只等主出口") + + add_pct = _positive(_default(runtime, "add_pct", 3.0)) + last_price = last_buy_price(symbol, position, progress) + if add_pct <= 0 or last_price <= 0: + return TradeDecision(False, "缺少上一档成交价") + + drop = (last_price - price) / last_price * 100 + if drop < add_pct: + # 跌幅未达门槛时不观察,避免把"没到位的低点"记成观察起点。 + runtime.add_watch.forget(code) + return TradeDecision(False, f"自上一档跌幅={drop:.2f}%<{add_pct:.2f}%") + + if progress.last_add_day == datetime.now().date().toordinal(): + # 同一交易日每档最多补一次:避免同一个低点被反复确认成多笔加仓。 + return TradeDecision(False, "本档当日已补仓,等待下一档") + + buy_shares = _symbol_value(symbol, "buy_shares", runtime, "buy_shares", 0) + if type(buy_shares) is not int or buy_shares <= 0: + return TradeDecision(False, "buy_shares 配置无效") + volume = buy_shares - buy_shares % 100 + max_shares = _symbol_value(symbol, "max_shares", runtime, "max_shares", 0) + if type(max_shares) is int and max_shares > 0: + room = max_shares - max_shares % 100 - position.volume + volume = min(volume, room) + if volume <= 0: + return TradeDecision(False, "已达单标的上限") + + amount = price * volume + if runtime.orders.busy(code, "BUY"): + return TradeDecision(False, "买入委托处理中") + # 预算不足时不消耗观察状态:等资金腾出来仍可用同一个观察低点确认。 + if amount > budget: + return TradeDecision(False, f"本轮预算不足(需要{amount:.2f}>可用{budget:.2f})") + if not runtime.add_watch.triggered("补仓", code, price): + return TradeDecision(False, "等待价格反弹确认") + + request = PlaceOrderRequest( + op=OP_BUY, + code=code, + volume=volume, + order_id=runtime.orders.new_order_id("ETF", "BUY"), + strategy_name=strategy_name(runtime), + kind="add", + price=price, + ) + if not runtime.orders.place(runtime.client, request): + return TradeDecision(False, "补仓委托失败") + + progress.adds += 1 + progress.last_buy_price = price + progress.last_add_day = datetime.now().date().toordinal() + runtime.add_watch.forget(code) + return TradeDecision( + True, f"[补仓] 第{level + 1}档 {volume} 股,跌幅={drop:.2f}%", amount + ) + + +def position_level(runtime: Runtime, position: PositionItem) -> int: + """由持仓股数推出档位:1 = 只有底仓,2 = 底仓 + 一档补仓……""" + buy_shares = _symbol_value( + _symbol(runtime, position.stock_code), + "buy_shares", + runtime, + "buy_shares", + 0, + ) + if type(buy_shares) is not int or buy_shares <= 0: + return 1 + return max(1, -(-int(position.volume) // buy_shares)) + + +def last_buy_price( + symbol: Any, position: PositionItem, progress: SymbolProgress +) -> float: + """上一档成交价:优先券商成本价,其次本模块记录的上次成交价。""" + if progress.last_buy_price > 0: + return progress.last_buy_price + return _positive(position.open_price) + + +def sellable_volume(symbol: Any, position: PositionItem) -> int: + """当日可卖股数:受 T+1 与 ``min_hold_days`` 限制,整手向下取整。""" + if _is_t0(symbol) or position.yesterday_volume > 0: + # T+0 标的,或已有隔夜持仓:券商可用份额就是上限。 + return max(0, int(position.can_use_volume)) + return 0 + + +def _available_budget(runtime: Runtime, available: float, market_ok: bool) -> float: + """补仓预算 = 调用方传入的可用资金 − 现金安全线 − 全部在途买单预留。 + + 调用方只给 ``assets.available``(见 ``boot.RunOnce``),因此现金安全线按 + "可用资金"比例扣除:``available × min_cash_ratio`` 是本模块能保守估计的 + 安全垫,不会把预留资金算成可加仓的额度。 + """ + if isinstance(available, bool) or not isinstance(available, (int, float)): + return 0.0 + ratio = getattr(runtime.account_cfg, "min_cash_ratio", 0.0) + if isinstance(ratio, bool) or not isinstance(ratio, (int, float)): + ratio = 0.0 + budget = float(available) - abs(float(available)) * float(ratio) - pending_buy_amount(runtime) + return max(0.0, budget) + + +def _tick_price(runtime: Runtime, code: str, tick: Tick | None) -> float: + """校验实时行情:有限正数、当天且未超过 ``max_tick_age_seconds``。""" + price = _positive(getattr(tick, "last_price", 0.0)) if tick is not None else 0.0 + if price <= 0: + return 0.0 + stamp = _tick_stamp(getattr(tick, "raw", None)) + if stamp is None or stamp.date() != datetime.now().date(): + return 0.0 + limit = _default(runtime, "max_tick_age_seconds", 90) + if type(limit) is not int or limit <= 0: + limit = 90 + if (datetime.now() - stamp).total_seconds() > limit: + return 0.0 + return price + + +def _tick_stamp(raw: Any) -> datetime | None: + if not isinstance(raw, Mapping): + return None + text = str(raw.get("timetag") or raw.get("time") or raw.get("stime") or "") + digits = "".join(char for char in text if char.isdigit()) + if len(digits) < 14: + return None + try: + return datetime.strptime(digits[:14], "%Y%m%d%H%M%S") + except ValueError: + return None + + +def _tick_price_or(fallback: Any, price: Any) -> float: + """限价:优先现价,缺失时用持仓快照的最新价。""" + return _positive(price) or _positive(fallback) + + +def _symbol(runtime: Runtime, code: str) -> Any | None: + symbols = getattr(getattr(runtime, "etf_cfg", None), "symbols", None) + if not isinstance(symbols, Mapping): + return None + return symbols.get(code) + + +def _symbol_value( + symbol: Any, attr: str, runtime: Runtime, defaults_attr: str, fallback: Any +) -> Any: + """标的覆盖优先,其次全局默认:标的为 None 时按未覆盖处理。""" + value = getattr(symbol, attr, None) + if value is not None: + return value + return _default(runtime, defaults_attr, fallback) + + +def _default(runtime: Runtime, name: str, fallback: Any) -> Any: + """读取 ``_etf.yaml`` 的全局默认参数。""" + defaults = getattr(getattr(runtime, "etf_cfg", None), "defaults", None) + value = getattr(defaults, name, None) + return fallback if value is None else value + + +def _is_t0(symbol: Any) -> bool: + return getattr(symbol, "is_t0", False) is True + + +def _tracker(code: str, symbol: Any) -> GridTrailingTracker: + """按标的缓存峰值跟踪器:内层格距是逐标的参数。""" + with _state_lock: + tracker = _trackers.get(code) + if tracker is None: + step = _positive(getattr(symbol, "inner_step", 0.0)) or 0.5 + tracker = GridTrailingTracker(step) + _trackers[code] = tracker + return tracker + + +def _get_progress(code: str, level: int) -> SymbolProgress: + """取标的进度;首次见到时用券商推出来的档位补齐补仓次数。""" + with _state_lock: + progress = _progress.get(code) + if progress is None: + # 档位 N 意味着已经补过 N-1 次,重启后仍能对上 max_adds 上限。 + progress = SymbolProgress(adds=max(0, level - 1)) + _progress[code] = progress + return progress + + +def _log_position( + code: str, + position: PositionItem, + price: float, + pnl_rate: float, + exit_action: str, + add_action: str, +) -> None: + log.info( + "[ETF持仓] %s %s,现价=%.3f,成本=%.3f,盈亏=%.2f%%,持有=%d,可用=%d,止盈=%s,补仓=%s", + code, + position.stock_name or "-", + price, + position.open_price, + pnl_rate, + position.volume, + position.can_use_volume, + exit_action, + add_action, + ) + + +def _positive(value: Any) -> float: + """把配置/行情值转成有限正浮点数;不合法时返回 0。""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return 0.0 + value = float(value) + return value if math.isfinite(value) and value > 0 else 0.0 diff --git a/py-client/strategy/etf/signal.py b/py-client/strategy/etf/signal.py new file mode 100644 index 0000000..13b4d0a --- /dev/null +++ b/py-client/strategy/etf/signal.py @@ -0,0 +1,317 @@ +"""ETF 信号层:把 ``_etf.yaml`` 的白名单展开为可交易的信号列表。 + +一个信号就是一个标的:入场判定所需的指标全部固化在 ``SignalItem.tech_indicator`` +里,引擎不再自己取数、算指标。数据来自外部日线接口(``docs/etf.md`` §7.4), +只使用已收盘日线,见 ``calculate`` 的校验。 +""" + +from datetime import date, datetime, timedelta +from decimal import ROUND_CEILING, Decimal +import logging as log +import math +import re +from statistics import fmean + +import httpx + +from config import HTTP_TIMEOUT, EtfSymbolConfig +from libs.runtime import Runtime +from libs.signal import SignalItem + +# 外部日线接口:只接受单个 code,无复权价;不发送 QMT 认证信息。 +DAILY_PATH = "/etf/daily" +DAILY_URL = "http://139.224.247.176:13499/etf/daily" + +# 取数失败后的重试间隔,避免接口故障时每轮都打满请求。 +RETRY_SECONDS = 300 +# 只保留最近的样本;需要 60 根 MA60、61 根 ATR14。 +BAR_COUNT = 120 +# ATR 需要前一根收盘价,MA60 需要 60 根,取两者的较大值作为样本下限。 +MIN_BARS = 61 +# MA60 是网的顶部上限,周期固定为 60 个交易日。 +MA_PERIOD = 60 +# 长期停牌或缓存未补齐时不使用过期指标;春节等长假允许 15 个自然日。 +MAX_BAR_AGE_DAYS = 15 + +# tech_indicator 的键:带 etf_ 前缀,避免与其它策略的信号字段混用。 +IND_MA60 = "etf_ma60" +IND_ATR = "etf_atr" +IND_CHANNEL_LOW = "etf_channel_low" +IND_CHANNEL_HIGH = "etf_channel_high" +IND_ENTRY = "etf_entry" +IND_GRID = "etf_grid" +IND_GRID_PCT = "etf_grid_pct" +IND_ADD_PRICE = "etf_add_price" +IND_PRICE = "etf_price" + +_STAMP_PATTERN = re.compile(r"[0-9]{8}") +_PRICE_FIELDS = ("open", "high", "low", "close") + +# 行情客户端按需创建:模块只做信号生成,不持有 QMT 客户端。 +_history_client: httpx.Client | None = None +# 每标的每日只取一次;失败标的按 RETRY_SECONDS 重试。 +_daily_cache: dict[str, list[dict]] = {} +_fetched: dict[str, date] = {} +_retry_at: dict[str, datetime] = {} + + +def gen_signals(rt: Runtime) -> list[SignalItem]: + """从 ``_etf.yaml`` 白名单生成信号列表,顺序即资金优先级。 + + 每个标的独立取日线并计算指标;取数或计算失败的标的本轮直接跳过, + 不允许退化使用旧数据,也不允许替换成别的证券。返回的 + ``SignalItem.tech_indicator`` 携带引擎需要的全部已收盘指标。 + """ + etf_cfg = getattr(rt, "etf_cfg", None) + if etf_cfg is None: + log.error("[ETF信号] 缺少 _etf.yaml 配置,本轮无可交易标的") + return [] + + today = datetime.now().date() + _reset_daily(today) + endpoint = _api_endpoint(rt) + cfg = etf_cfg.defaults + signals: list[SignalItem] = [] + + for code in etf_cfg.codes: + try: + symbol = etf_cfg.symbols[code] + bars = _daily_bars(code, today, endpoint) + if not bars: + continue + indicators = calculate(bars, symbol, cfg, today) + except Exception as exc: + log.warning("[ETF信号] %s 跳过:%s", code, exc) + continue + + signals.append( + SignalItem( + signal_key=etf_cfg_key(rt), + code=code, + name=code, + desc=f"ETF网格 档位={symbol.buy_shares}股 上限={symbol.max_shares}股", + last_close=indicators[IND_PRICE], + tech_indicator=indicators, + ) + ) + + if signals: + log.info( + "[ETF信号] 生成完毕,可交易=%d/%d,来源=%s", + len(signals), + len(etf_cfg.codes), + endpoint, + ) + else: + log.warning("[ETF信号] 本轮没有可用信号,白名单=%d", len(etf_cfg.codes)) + return signals + + +def etf_cfg_key(rt: Runtime) -> str: + """信号的品种标识:ETF 全部标的共用 ``etf``,便于按策略名过滤委托与日志。""" + strategy = str(getattr(rt.account_cfg, "strategy", "") or "").strip().lower() + return strategy or "etf" + + +def calculate( + bars: list[dict], symbol: EtfSymbolConfig, defaults, today: date +) -> dict[str, float]: + """用已收盘日线算出引擎需要的全部指标。 + + ATR 走 Wilder 平滑;格距向上取整到 0.001 元(ETF 最小报价单位)。 + 样本不足或日线过期时抛 ValueError,由调用方放弃该标的当轮交易。 + """ + if len(bars) < MIN_BARS: + raise ValueError(f"已收盘日线不足 {MIN_BARS} 根") + + ordered = sorted(bars, key=lambda bar: bar["date"]) + last_day = datetime.strptime(ordered[-1]["date"], "%Y%m%d").date() + # 长期停牌或历史缓存未补齐时不使用过期数据;春节等长假允许 15 个自然日。 + if (today - last_day).days > MAX_BAR_AGE_DAYS: + raise ValueError( + f"最近日线 {ordered[-1]['date']} 超过 {MAX_BAR_AGE_DAYS} 个自然日" + ) + + period = defaults.atr_period + if type(period) is not int or period < 2: + raise ValueError("atr_period 必须是大于 1 的整数") + + closes = [float(bar["close"]) for bar in ordered] + highs = [float(bar["high"]) for bar in ordered] + lows = [float(bar["low"]) for bar in ordered] + ranges = [ + max(high - low, abs(high - closes[index - 1]), abs(low - closes[index - 1])) + for index, (high, low) in enumerate(zip(highs, lows)) + if index > 0 + ] + if len(ranges) < period: + raise ValueError(f"日线不足 {period + 1} 根,无法计算 ATR") + + atr = fmean(ranges[:period]) + for value in ranges[period:]: + atr = (atr * (period - 1) + value) / period + + window = int(defaults.channel_period) + if len(highs) < max(window, MA_PERIOD): + raise ValueError(f"日线不足 {max(window, MA_PERIOD)} 根,无法计算通道或 MA60") + ma60 = fmean(closes[-MA_PERIOD:]) + channel_low = min(lows[-window:]) + channel_high = max(highs[-window:]) + # 入场门槛 = min(距区间下沿 channel_pct% 的价位, MA60):不在均线上方建网。 + entry = min( + channel_low + (channel_high - channel_low) * defaults.channel_pct / 100, ma60 + ) + # 格距 = max(ATR × 倍数, MA60 × 格距下限百分比, 0.001),向上取整到 0.001 元。 + raw_grid = max(atr * symbol.atr_multiplier, ma60 * defaults.min_grid_pct / 100, 0.001) + grid = float(Decimal(str(raw_grid)).quantize(Decimal("0.001"), rounding=ROUND_CEILING)) + + values = (ma60, atr, channel_low, channel_high, entry, grid, closes[-1]) + if not all(math.isfinite(value) and value > 0 for value in values): + raise ValueError("指标存在非有限正数") + if grid <= 0: + raise ValueError("格距非正数") + + return { + IND_MA60: ma60, + IND_ATR: atr, + IND_CHANNEL_LOW: channel_low, + IND_CHANNEL_HIGH: channel_high, + IND_ENTRY: entry, + IND_GRID: grid, + IND_GRID_PCT: grid / closes[-1] * 100, + IND_ADD_PRICE: closes[-1] * (1 - defaults.add_pct / 100), + IND_PRICE: closes[-1], + } + + +def daily_bars( + client: httpx.Client, + code: str, + today: date, + count: int = BAR_COUNT, + endpoint: str = DAILY_URL, +) -> list[dict]: + """读取指定证券日线;窗口截取在本地完成(接口只支持单 code)。""" + response = client.get(endpoint, params={"code": code}) + response.raise_for_status() + return parse_daily(response.json(), code, today, count) + + +def parse_daily( + payload: object, code: str, today: date, count: int = BAR_COUNT +) -> list[dict]: + """校验业务状态、证券归属、OHLC 与日期,返回按日期升序的最近若干根。 + + 线上接口直接返回一维数组(倒序),旧版是 ``{code, message, details}`` 包装, + 两种形式都支持。任一校验不通过即抛 ValueError,调用方放弃该标的当轮交易。 + """ + if type(count) is not int or count <= 0: + raise ValueError("日线数量必须为正整数") + if isinstance(payload, list): + rows = payload + elif isinstance(payload, dict): + if type(payload.get("code")) is not int or payload["code"] != 0: + raise ValueError(f"日线接口业务失败:{payload.get('message', '状态无效')}") + rows = payload.get("details") + else: + rows = None + if not isinstance(rows, list) or not rows: + raise ValueError(f"{code} 日线接口未返回有效数据列表") + + bars: dict[str, dict] = {} + for row in rows: + if not isinstance(row, dict) or row.get("ts_code") != code: + raise ValueError(f"{code} 日线证券代码不一致") + stamp = str(row.get("trade_date", "")) + if not _STAMP_PATTERN.fullmatch(stamp): + raise ValueError(f"{code} 日线日期无效:{stamp}") + day = datetime.strptime(stamp, "%Y%m%d").date() + # 当前日及未来日线不得混入盘中指标,先过滤再截取最近 count 根。 + if day >= today: + continue + if stamp in bars: + raise ValueError(f"{code} 日线日期重复:{stamp}") + + values = {} + for name in _PRICE_FIELDS: + value = row.get(name) + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + raise ValueError(f"{code} 日线 {name} 无效") + try: + number = float(value) + except ValueError as exc: + raise ValueError(f"{code} 日线 {name} 无效") from exc + if not math.isfinite(number) or number <= 0: + raise ValueError(f"{code} 日线 {name} 非有限正数") + values[name] = number + if not ( + values["low"] <= values["open"] <= values["high"] + and values["low"] <= values["close"] <= values["high"] + ): + raise ValueError(f"{code} 日线 OHLC 关系异常") + bars[stamp] = dict(date=stamp, **values) + return [bars[stamp] for stamp in sorted(bars)[-count:]] + + +def _api_endpoint(rt: Runtime) -> str: + """日线接口地址:拼接全局 api_host,未配置时用接口默认地址。""" + global_cfg = getattr(rt, "global_cfg", None) + host = str(getattr(global_cfg, "api_host", "") or "").strip().rstrip("/") + return f"{host}{DAILY_PATH}" if host else DAILY_URL + + +def _reset_daily(today: date) -> None: + """跨交易日清空日线缓存,保证指标只基于当天可见的已收盘日线。 + + 按标的逐个判断取数日期:失败重试记录带着自己的日期,即使还有标的当天 + 尚未取数成功也不会被清掉,重试窗口因此始终有效。 + """ + for code in [code for code, day in _fetched.items() if day != today]: + _daily_cache.pop(code, None) + _fetched.pop(code, None) + for code in [ + code for code, retry_at in _retry_at.items() if retry_at.date() != today + ]: + _retry_at.pop(code, None) + + +def _daily_bars(code: str, today: date, endpoint: str) -> list[dict] | None: + """取某个标的的日线:当日成功过就直接复用,失败则等重试间隔。""" + if _fetched.get(code) == today: + return _daily_cache.get(code) + + now = datetime.now() + # 重试时刻在同一天内才生效;跨日后必须先重新取数。 + retry_at = _retry_at.get(code) + if retry_at is not None and retry_at.date() == today and now < retry_at: + return None + + try: + bars = daily_bars(_history_client_get(), code, today, endpoint=endpoint) + except (httpx.HTTPError, ValueError, OSError) as exc: + _retry_at[code] = now + timedelta(seconds=RETRY_SECONDS) + log.warning( + "[ETF日线] %s 获取失败,%d 秒后重试:%s", code, RETRY_SECONDS, exc + ) + return None + + _daily_cache[code] = bars + _fetched[code] = today + _retry_at.pop(code, None) + return bars + + +def _history_client_get() -> httpx.Client: + """复用外部日线连接池;模块首次取数时才创建。""" + global _history_client + if _history_client is None: + _history_client = httpx.Client(timeout=HTTP_TIMEOUT) + return _history_client + + +def reset_history_client() -> None: + """关闭并清空外部日线客户端,供进程退出或测试收尾调用。""" + global _history_client + if _history_client is not None: + _history_client.close() + _history_client = None diff --git a/py-client/strategy/etf/state.py b/py-client/strategy/etf/state.py deleted file mode 100644 index 1a73f92..0000000 --- a/py-client/strategy/etf/state.py +++ /dev/null @@ -1,65 +0,0 @@ -"""保存交易意图与网格基准;实际持仓始终以券商快照为准。""" - -from dataclasses import asdict, dataclass, field -from pathlib import Path -import json -import math - -from libs.lockfile import replace_json - - -@dataclass -class SymbolState: - volume: int = 0 - cost: float = 0.0 - last_buy: float = 0.0 - armed: bool = False - sell_grid: float = 0.0 - peak: int = 0 - pending: dict = field(default_factory=dict) - - def reset_profit(self): - """持仓成本或数量改变后,不沿用上轮止盈峰值。""" - self.armed = False - self.sell_grid = 0.0 - self.peak = 0 - - -class Store: - def __init__(self, path: Path, account: str): - self.path, self.account = path, account - self.symbols: dict[str, SymbolState] = {} - if path.exists(): - try: - raw = json.loads(path.read_text(encoding='utf-8')) - if raw['version'] != 1 or raw['account'] != account: - raise ValueError('版本或账户不一致') - for code, value in raw['symbols'].items(): - state = SymbolState(**value) - if type(state.volume) is not int or state.volume < 0 or type(state.peak) is not int: - raise ValueError('状态数量或峰值无效') - if any(not math.isfinite(v) or v < 0 for v in (state.cost, state.last_buy, state.sell_grid)): - raise ValueError('状态价格无效') - if type(state.armed) is not bool or (state.armed and state.sell_grid <= 0): - raise ValueError('止盈状态无效') - if not isinstance(state.pending, dict): - raise ValueError('委托状态无效') - if state.pending: - p = state.pending - if (p['side'] not in ('BUY', 'SELL') or not p['id'].startswith('ETF-') - or type(p['volume']) is not int or p['volume'] <= 0 - or (p['side'] == 'BUY' and p['volume'] > 1000) - or type(p['base_volume']) is not int or p['base_volume'] < 0 - or not math.isfinite(p['reserved']) or p['reserved'] < 0): - raise ValueError('待确认委托无效') - self.symbols[code] = state - except (ValueError, KeyError, TypeError, AttributeError) as exc: - raise ValueError(f'ETF 状态损坏,禁止自动重建:{path}') from exc - - def get(self, code: str) -> SymbolState: - return self.symbols.setdefault(code, SymbolState()) - - def save(self): - self.path.parent.mkdir(parents=True, exist_ok=True) - replace_json(self.path, dict(version=1, account=self.account, - symbols={k: asdict(v) for k, v in self.symbols.items()}))