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 0000000..479d39c Binary files /dev/null and b/labs/tests/__pycache__/test_deal_model.cpython-314.pyc differ 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 0000000..52c767e Binary files /dev/null and b/labs/tests/__pycache__/test_etf.cpython-314.pyc differ 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 0000000..00e461e Binary files /dev/null and b/labs/tests/__pycache__/test_etf_signal.cpython-314.pyc differ diff --git a/labs/tests/__pycache__/test_etf_trade.cpython-314.pyc b/labs/tests/__pycache__/test_etf_trade.cpython-314.pyc new file mode 100644 index 0000000..7c33ae1 Binary files /dev/null and b/labs/tests/__pycache__/test_etf_trade.cpython-314.pyc differ 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 0000000..ec2eb74 Binary files /dev/null and b/labs/tests/__pycache__/test_ipo.cpython-314.pyc differ diff --git a/labs/tests/__pycache__/test_orderbook.cpython-314.pyc b/labs/tests/__pycache__/test_orderbook.cpython-314.pyc new file mode 100644 index 0000000..b08aa7a Binary files /dev/null and b/labs/tests/__pycache__/test_orderbook.cpython-314.pyc differ diff --git a/labs/tests/__pycache__/test_python314_performance.cpython-314.pyc b/labs/tests/__pycache__/test_python314_performance.cpython-314.pyc new file mode 100644 index 0000000..c948f35 Binary files /dev/null and b/labs/tests/__pycache__/test_python314_performance.cpython-314.pyc differ 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 0000000..c4385e7 Binary files /dev/null and b/labs/tests/__pycache__/test_trend_collector.cpython-314.pyc differ 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 0000000..3b9db71 Binary files /dev/null and b/labs/tests/__pycache__/test_zt_boot.cpython-314.pyc differ 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 0000000..bc68352 Binary files /dev/null and b/labs/tests/__pycache__/test_zt_config.cpython-314.pyc differ 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 0000000..b1181c8 Binary files /dev/null and b/labs/tests/__pycache__/test_zt_ownership.cpython-314.pyc differ 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 0000000..03c4b60 Binary files /dev/null and b/labs/tests/__pycache__/test_zt_rounds.cpython-314.pyc differ diff --git a/labs/tests/__pycache__/test_zt_rules.cpython-314.pyc b/labs/tests/__pycache__/test_zt_rules.cpython-314.pyc new file mode 100644 index 0000000..7a94452 Binary files /dev/null and b/labs/tests/__pycache__/test_zt_rules.cpython-314.pyc differ diff --git a/labs/tests/__pycache__/zt_harness.cpython-314.pyc b/labs/tests/__pycache__/zt_harness.cpython-314.pyc new file mode 100644 index 0000000..a461386 Binary files /dev/null and b/labs/tests/__pycache__/zt_harness.cpython-314.pyc differ diff --git a/labs/tests/test_etf.py b/labs/tests/test_etf.py index 090e531..d9c192b 100644 --- a/labs/tests/test_etf.py +++ b/labs/tests/test_etf.py @@ -1,4 +1,4 @@ -"""ETF 离线回归:指标、真实防飞刀/网格算法、限仓、回报和持久化。""" +"""ETF 离线回归:指标、分格网格(每格独立买卖)、费用门槛、限仓、回报与持久化。""" from datetime import date, datetime, timedelta import httpx @@ -12,12 +12,14 @@ from strategy.etf.config import ETFConfig, load from strategy.etf.data import DAILY_URL, daily_bars, parse_daily from strategy.etf.engine import Engine from strategy.etf.indicators import Indicators, calculate -from strategy.etf.state import Store +from strategy.etf.state import GridLot, Store, SymbolState CODE, OTHER = '510300.SH', '159915.SZ' NOW = datetime(2026, 9, 16, 10) -IND = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2) +# ma60=10、grid=0.2,指标入场门槛 9.5;引擎再叠加 ma60-1格 = 9.8,最终入场价 9.5 +IND = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2, entry_band=9.5, + donchian_lo=9.1, donchian_hi=10.9) def tick(price, now=NOW): @@ -29,201 +31,414 @@ def position(volume=0, cost=0, available=None): can_use_volume=volume if available is None else available) +def held(lots: dict, anchor: float, opened_days_ago=5): + """构造一个已持仓的网格:lots = {档位: (数量, 成本)}。""" + state = SymbolState(anchor=anchor) + day = NOW.date() - timedelta(days=opened_days_ago) + for level, (volume, cost) in lots.items(): + state.lots[str(level)] = GridLot(volume=volume, cost=cost, bought=day, buys=1) + state.last_buy = max(cost for _, cost in lots.values()) + return state + + class ETFTests(unittest.TestCase): + """分格网格:每格独立买入,达到自身目标价就卖掉该格。""" + def setUp(self): temp = tempfile.TemporaryDirectory() self.addCleanup(temp.cleanup) self.path = Path(temp.name) / 'state.json' self.client = Mock() self.client.passorder.return_value = {'status': 'success'} - self.cfg = ETFConfig(codes=(CODE,), min_commission=0, commission_rate=0) + # 测试用零佣金、零金额门槛、每格 1 手、3 档:真实默认值为最低佣金 5 元设计。 + self.cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3, + min_commission=0, commission_rate=0, min_order_value=0) self.store = Store(self.path, 'test') self.engine = Engine(self.client, self.cfg, self.store, 0.1) - def run_price(self, price, pos=None, orders=(), cash=10000, now=NOW, ind=IND): - portfolio = Portfolio(Assets(total=10000, available=cash), {CODE: pos or position()}, list(orders)) - self.engine.run(portfolio, {CODE: tick(price, now)}, {CODE: ind}, now) + # ------------------------------------------------------------- 辅助 + def run_price(self, price, pos=None, orders=(), cash=10000, now=NOW, ind=IND, engine=None): + portfolio = Portfolio(Assets(total=10000, available=cash), + {CODE: pos or position()}, list(orders)) + (engine or self.engine).run(portfolio, {CODE: tick(price, now)}, {CODE: ind}, now) - def buy(self): - self.run_price(9.4) - self.run_price(9.46) - self.client.passorder.assert_called_once() + def keep(self, low, high): + """让 DipWatch 从 low 反弹到 high(反弹幅度必须 ≥ 0.61%)。""" + self.run_price(low) + self.run_price(round(low * 1.007, 3)) + self.run_price(high) - def report(self, status=56, filled=100, side=23, price=9.46): + def anchor_grid(self, low=9.3, high=9.38): + """跌到 low 后反弹到 high(≥0.61%)确认,锚点取确认价 high,挂单挂在 low。""" + self.run_price(low) + self.run_price(high) + return self.newest() + + def ack(self, pending, pos, price=None, side=None, cost=None, status=56, filled=None): + """把一笔委托做成终态回报,并给出成交后的持仓快照。""" + volume = pending['volume'] if filled is None else filled + side = side or pending['side'] + fill_price = pending['price'] if price is None else price + report = OrderItem(stock_code=CODE, remark=pending['id'] + '|etf', + offset_flag={'BUY': 23, 'SELL': 24}[side], + volume_traded=volume, volume_total_original=pending['volume'], + traded_price=fill_price, order_status=status) + after = PositionItem(stock_code=CODE, + volume=pos.volume + (volume if side == 'BUY' else -volume), + open_price=cost if cost is not None else pos.open_price, + can_use_volume=max(0, pos.can_use_volume - volume) + if side == 'SELL' else pos.can_use_volume) + return report, after + + def fills(self, volume, cost, available=None): + return {level: (volume, cost, available) for level in self.store.get(CODE).lots} + + def newest(self, side='BUY'): pending = self.store.get(CODE).pending - return OrderItem(stock_code=CODE, remark=pending['id'] + '|etf', offset_flag=side, - volume_traded=filled, volume_total_original=pending['volume'], - traded_price=price, order_status=status) + rows = [p for p in pending.values() if p['side'] == side] + return max(rows, key=lambda p: p['id']) - def test_boll_lower_requires_rebound_and_uses_fixed_limit_order(self): - self.run_price(9.8) - self.run_price(9.4) - self.run_price(9.3) - self.run_price(9.35) + def report(self, pending, status=56, filled=None, price=None, side=None): + volume = pending['volume'] if filled is None else filled + return OrderItem(stock_code=CODE, remark=pending['id'] + '|etf', + offset_flag={'BUY': 23, 'SELL': 24}[side or pending['side']], + volume_traded=volume, volume_total_original=pending['volume'], + traded_price=pending['price'] if price is None else price, + order_status=status) + + # ------------------------------------------------------------- 入场 + def test_entry_band_requires_rebound_then_ladder_order(self): + """进入入场区只是开始观察;反弹确认后以 t0 价为锚点挂出锚点档。""" + self.run_price(9.8) # 未进入入场区 + self.run_price(9.4) # 进入入场区,开始观察 + self.run_price(9.3) # 刷新低点 t0=9.3 + self.run_price(9.35) # 反弹不足 0.61% self.client.passorder.assert_not_called() - self.run_price(9.36) + self.run_price(9.38) # 反弹 (9.38-9.3)/9.3 = 0.86%,锚点取确认价 request = self.client.passorder.call_args.kwargs - self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.36, 11)) + self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.38, 11)) self.assertEqual(request['strategy_name'], 'etf') - self.assertTrue(self.store.get(CODE).pending) + self.assertEqual(self.store.get(CODE).anchor, 9.38) + self.assertEqual(set(self.store.get(CODE).pending), {'0'}) + + def test_leaving_entry_zone_restarts_observation(self): + """价格弹回入场区上方后,旧低点作废,必须重新形成低点再确认。""" + self.run_price(9.4) + self.run_price(9.3) # t0=9.3 + self.run_price(9.8) # 离开入场区,观察点作废 + self.run_price(9.38) # 反弹不再基于 9.3 + self.client.passorder.assert_not_called() + + def test_ladder_places_one_order_per_level_below_anchor(self): + """锚点 9.4、格距 0.2:价格每跌一格补一档;同一时刻只允许一笔在途委托。""" + cash = 50000 + pending = self.anchor_grid(low=9.3, high=9.4) # 锚点 = 9.4 + self.assertEqual((pending['level'], pending['price']), (0, 9.4)) + report, after = self.ack(pending, position(), price=9.4) + self.run_price(9.4, after, [report], cash=cash) # 锚点档成交 + self.assertEqual(self.store.get(CODE).lots['0'].volume, 100) + + self.run_price(9.2, position(100, 9.4, 0), cash=cash) # 跌到第 1 档 + self.assertEqual(self.store.get(CODE).pending['1']['price'], 9.2) # 锚点-1格 + self.run_price(9.2, position(100, 9.4, 0), cash=cash) # 同档不重复挂单 + self.assertEqual(sorted(self.store.get(CODE).pending), ['1']) + + pending = self.store.get(CODE).pending['1'] + report, after = self.ack(pending, position(100, 9.4, 0), price=9.2) + self.run_price(9.0, after, [report], cash=cash) # 第 1 档成交 + self.assertEqual(self.store.get(CODE).lots['1'].volume, 100) + self.run_price(9.0, position(200, 9.3, 0), cash=cash) # 跌到第 2 档 + self.assertEqual(self.store.get(CODE).pending['2']['price'], 9.0) # 锚点-2格 + + def test_per_level_fill_cost_and_target(self): + pending = self.anchor_grid() + report, after = self.ack(pending, position(), price=9.28) + self.run_price(9.28, after, [report]) + state = self.store.get(CODE) + self.assertEqual(state.lots['0'].volume, 100) + self.assertAlmostEqual(state.lots['0'].cost, 9.28) + self.assertEqual(state.lots['0'].bought, NOW.date()) + # 目标价 = 成本 + 格距×2 = 9.28 + 0.4 + self.assertAlmostEqual(self.engine.sell_target(9.28, IND), 9.68) + + # ------------------------------------------------------------- 卖出 + def test_each_level_sells_independently_at_own_target(self): + """两档成本不同,各自到价才卖,且只卖该档。""" + state = held({0: (100, 9.0), 1: (100, 10.0)}, anchor=10.0) + pos = position(200, 9.5, 200) + self.run_price(9.4, pos, engine=self.engine_with(state)) + request = self.client.passorder.call_args.kwargs + self.assertEqual((request['op_type'], request['volume']), (24, 100)) + self.assertEqual(self.store.get(CODE).pending['0']['price'], 9.4) + self.assertNotIn('1', self.store.get(CODE).pending) + + def engine_with(self, state): + """把预置状态挂到引擎上,跳过与券商快照的首次核对。""" + self.store.symbols[CODE] = state + engine = Engine(self.client, self.cfg, self.store, 0.1) + return engine + + def test_sell_waits_for_target_then_clears_only_that_level(self): + state = held({0: (100, 9.0)}, anchor=9.0) + engine = self.engine_with(state) + pos = position(100, 9.0, 100) + target = engine.sell_target(9.0, IND) + self.run_price(target - 0.001, pos, engine=engine) + self.client.passorder.assert_not_called() + self.run_price(target, pos, engine=engine) + request = self.client.passorder.call_args.kwargs + self.assertEqual((request['op_type'], request['volume']), (24, 100)) + pending = self.store.get(CODE).pending['0'] + report, after = self.ack(pending, pos) + self.run_price(target, after, [report], engine=engine) + state = self.store.get(CODE) + self.assertEqual(state.lots, {}) + self.assertEqual(state.pending, {}) + + def test_sold_level_is_rebought_when_price_returns(self): + state = held({0: (100, 9.0)}, anchor=9.0) + engine = self.engine_with(state) + pos = position(100, 9.0, 100) + target = engine.sell_target(9.0, IND) + self.run_price(target, pos, engine=engine) + report, after = self.ack(self.store.get(CODE).pending['0'], pos) + self.run_price(target, after, [report], engine=engine) + self.assertEqual(self.store.get(CODE).lots, {}) + # 价格回到锚点下方:同一档重新挂买单 + self.run_price(9.0, position(), engine=engine) + pending = self.store.get(CODE).pending + self.assertIn('0', pending) + self.assertEqual(pending['0']['side'], 'BUY') + + def test_t_plus_one_lot_is_not_sold_same_day(self): + state = held({0: (100, 9.0)}, anchor=9.0, opened_days_ago=0) + self.run_price(10.7, position(100, 9.0, 100), engine=self.engine_with(state)) + self.client.passorder.assert_not_called() + + def test_partial_available_volume_limits_sell_to_whole_lots(self): + state = held({0: (300, 9.0)}, anchor=9.0) + self.run_price(10.7, position(300, 9.0, 250), engine=self.engine_with(state)) + request = self.client.passorder.call_args.kwargs + self.assertEqual((request['op_type'], request['volume']), (24, 200)) + + def test_max_hold_days_clears_only_stale_level(self): + cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3, + min_commission=0, commission_rate=0, min_order_value=0, + max_hold_days=3) + self.engine = Engine(self.client, cfg, self.store, 0.1) + state = held({0: (100, 9.0)}, anchor=9.0) + self.store.symbols[CODE] = state + with self.assertLogs(level='WARNING'): + self.run_price(9.0, position(100, 9.0, 100)) + request = self.client.passorder.call_args.kwargs + self.assertEqual((request['op_type'], request['volume']), (24, 100)) + + # ------------------------------------------------------------- 核对 + def test_fill_waits_for_position_snapshot(self): + pending = self.anchor_grid() + report = self.report(pending) + with self.assertLogs(level='WARNING'): + self.run_price(9.28, orders=[report]) + self.assertIn('0', self.store.get(CODE).pending) + report, after = self.ack(pending, position(), price=9.28) + self.run_price(9.28, after, [report]) + state = self.store.get(CODE) + self.assertEqual(state.pending, {}) + self.assertEqual(state.lots['0'].volume, 100) + self.assertAlmostEqual(state.last_buy, 9.28) + self.client.passorder.assert_called_once() def test_pending_written_before_network_and_retained_after_timeout(self): + """提交前先落盘意图;网络异常不解除锁,重启后仍保留待确认状态。""" def submit(**kwargs): saved = Store(self.path, 'test').get(CODE).pending - self.assertEqual(saved['id'], kwargs['order_id']) + self.assertEqual({p['id'] for p in saved.values()}, {kwargs['order_id']}) raise TimeoutError('unknown result') self.client.passorder.side_effect = submit - self.run_price(9.4) + engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1) + engine.orders.busy_cache.clear() + self.run_price(9.3, engine=engine) # 进入入场区 with self.assertLogs(level='ERROR'): - self.run_price(9.46) - self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1) + self.run_price(9.38, engine=engine) # 反弹确认,提交时网络异常 + self.assertTrue(Store(self.path, 'test').get(CODE).pending) + engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1) + engine.orders.busy_cache.clear() with self.assertLogs(level='WARNING'): - self.run_price(9.3, now=NOW + timedelta(minutes=10)) + self.run_price(9.3, engine=engine, now=NOW + timedelta(minutes=10)) self.client.passorder.assert_called_once() - def test_filled_order_waits_for_position_snapshot(self): - self.buy() - report = self.report() - with self.assertLogs(level='WARNING'): - self.run_price(9.2, orders=[report]) - self.assertTrue(self.store.get(CODE).pending) - self.run_price(9.2, position(100, 9.46, 0), [report]) - self.assertFalse(self.store.get(CODE).pending) - self.assertEqual(self.store.get(CODE).last_buy, 9.46) - self.client.passorder.assert_called_once() + def test_rejected_order_releases_level_and_does_not_advance(self): + pending = self.anchor_grid() + report = self.report(pending, status=57, filled=0) + self.run_price(9.39, orders=[report]) # 价格已回到锚点上方,不会重挂 + state = self.store.get(CODE) + self.assertEqual(state.pending, {}) + self.assertEqual(state.lots, {}) + self.assertEqual(state.last_buy, 0.0) + # 网格仍保留锚点,价格回到锚点档可重新挂单 + self.run_price(9.38) + self.assertIn('0', self.store.get(CODE).pending) - def test_add_requires_another_grid_below_actual_fill(self): - self.buy() - report = self.report() - pos = position(100, 9.46, 0) - self.run_price(9.4, pos, [report]) - self.run_price(9.46, pos) - self.client.passorder.assert_called_once() - self.run_price(9.1, pos) - self.run_price(9.16, pos) - self.assertEqual(self.client.passorder.call_count, 2) + def test_external_position_change_rebuilds_grid_from_broker_snapshot(self): + self.run_price(10.0, position(500, 9.9, 500)) + state = self.store.get(CODE) + self.assertEqual(state.volume, 500) + self.assertEqual(state.anchor, 9.9) + self.assertEqual(state.lots['0'].volume, 500) - def test_partial_cancel_records_actual_fill_and_never_exceeds_cap(self): - self.cfg = ETFConfig(codes=(CODE,), buy_hands=2, min_commission=0, commission_rate=0) - self.engine = Engine(self.client, self.cfg, self.store, 0.1) - pos = position(800, 10) - self.run_price(9.4, pos) - self.run_price(9.46, pos) - report = self.report(status=53, filled=100) - self.run_price(9.1, position(900, 9.94), [report]) - self.run_price(9.16, position(900, 9.94)) - self.client.passorder.assert_called_once() - self.assertFalse(self.store.get(CODE).pending) - - def test_full_position_blocks_buy_and_zero_position_is_not_a_warning(self): - self.run_price(9.4, position(1000, 10)) - self.run_price(9.46, position(1000, 10)) - self.client.passorder.assert_not_called() - with self.assertNoLogs(level='WARNING'): - self.run_price(9.8, position()) - - def test_zero_position_with_retained_broker_cost_can_reopen(self): - self.run_price(9.4, position(0, 10)) - self.run_price(9.46, position(0, 10)) - self.client.passorder.assert_called_once() - - def test_rejected_order_is_logged_and_does_not_advance_anchor(self): - self.buy() - report = self.report(status=57, filled=0) - self.run_price(9.8, orders=[report]) - self.assertEqual(self.store.get(CODE).last_buy, 0) - self.assertFalse(self.store.get(CODE).pending) - - def test_t_plus_one_tracks_peak_but_only_sells_available_whole_lots(self): - self.run_price(10.7, position(200, 10, 0)) - self.run_price(10.55, position(200, 10, 0)) - self.client.passorder.assert_not_called() - self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1) - self.run_price(10.55, position(200, 10, 100)) - order = self.client.passorder.call_args.kwargs - self.assertEqual((order['op_type'], order['volume']), (24, 100)) - - def test_drop_below_activation_price_still_triggers_profitable_retreat(self): - self.run_price(10.7, position(100, 10)) - self.run_price(10.4, position(100, 10)) - self.assertEqual(self.client.passorder.call_args.kwargs['op_type'], 24) - - def test_cost_change_and_flat_position_reset_peak(self): - self.run_price(10.7, position(100, 10)) - self.assertTrue(self.store.get(CODE).armed) - self.run_price(10.5, position(200, 10.4)) - self.assertFalse(self.store.get(CODE).armed) - self.run_price(9.8, position()) - self.assertEqual(self.store.get(CODE).last_buy, 0) - self.client.passorder.assert_not_called() - - def test_fee_floor_prevents_loss_after_commission(self): - cfg = ETFConfig(codes=(CODE,), min_commission=50, commission_rate=0) - self.engine = Engine(self.client, cfg, self.store, 0.1) - self.run_price(10.7, position(100, 10)) - self.run_price(10.55, position(100, 10)) + def test_full_position_blocks_further_levels(self): + state = self.store.get(CODE) + state.adopt(300, 9.0, NOW.date() - timedelta(days=5)) + self.run_price(8.0, position(300, 9.0, 300)) self.client.passorder.assert_not_called() def test_cash_reserve_and_fixed_lot_no_downsizing(self): - self.run_price(9.4, cash=1900) - self.run_price(9.46, cash=1900) + self.run_price(9.4, cash=1, now=NOW) + self.run_price(9.3, cash=1, now=NOW) + self.client.passorder.assert_not_called() + + def test_min_order_value_skips_small_level_orders(self): + cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3, + min_commission=5.0, commission_rate=0.0003, min_order_value=2000) + self.engine = Engine(self.client, cfg, self.store, 0.1) + with self.assertLogs(level='INFO'): + self.run_price(9.4) + self.run_price(9.3) self.client.passorder.assert_not_called() def test_multiple_symbols_share_one_cash_budget(self): - cfg = ETFConfig(codes=(CODE, OTHER), min_commission=0, commission_rate=0) + cfg = ETFConfig(codes=(CODE, OTHER), buy_hands=1, grid_levels=2, max_hands=3, + min_commission=0, commission_rate=0, min_order_value=0) self.engine = Engine(self.client, cfg, self.store, 0.1) - portfolio = Portfolio(Assets(total=10000, available=2500), {}, []) - for price in (9.4, 9.46): - self.engine.run(portfolio, {c: tick(price) for c in cfg.codes}, {c: IND for c in cfg.codes}, NOW) + portfolio = Portfolio(Assets(total=10000, available=2000), {}, []) + for price in (9.3, 9.38): + self.engine.run(portfolio, {c: tick(price) for c in cfg.codes}, + {c: IND for c in cfg.codes}, NOW) self.client.passorder.assert_called_once() + self.assertIn('0', self.store.get(CODE).pending) + self.assertFalse(self.store.get(OTHER).pending) + + def test_pending_later_symbol_reserves_cash_before_first_symbol(self): + cfg = ETFConfig(codes=(CODE, OTHER), buy_hands=1, grid_levels=2, max_hands=3, + min_commission=0, commission_rate=0, min_order_value=0) + self.store.get(OTHER).pending['0'] = dict(id='ETF-BUY-pending', side='BUY', volume=100, + base_volume=0, reserved=950, level=0, + price=9.5) + self.engine = Engine(self.client, cfg, self.store, 0.1) + portfolio = Portfolio(Assets(total=10000, available=2000), {}, []) + with self.assertLogs(level='WARNING'): + for price in (9.4, 9.3): + self.engine.run(portfolio, {CODE: tick(price)}, {CODE: IND}, NOW) + self.client.passorder.assert_not_called() def test_other_strategy_order_blocks_same_symbol_without_cancel(self): report = OrderItem(stock_code=CODE, remark='TREN-BUY-other', offset_flag=23, order_status=50, insert_date='20260916', insert_time='093000') self.run_price(9.4, orders=[report]) - self.run_price(9.46, orders=[report]) + self.run_price(9.3, orders=[report]) self.client.passorder.assert_not_called() self.client.cancel_by_id.assert_not_called() - def test_pending_later_symbol_reserves_cash_before_first_symbol(self): - cfg = ETFConfig(codes=(CODE, OTHER), min_commission=0, commission_rate=0) - self.store.get(OTHER).pending = dict(id='ETF-BUY-pending', side='BUY', volume=100, - base_volume=0, reserved=950) - self.engine = Engine(self.client, cfg, self.store, 0.1) - portfolio = Portfolio(Assets(total=10000, available=2500), {}, []) - with self.assertLogs(level='WARNING'): - for price in (9.4, 9.46): - self.engine.run(portfolio, {CODE: tick(price)}, {CODE: IND}, NOW) - self.client.passorder.assert_not_called() - - def test_on_road_or_unknown_order_never_opens_another_buy(self): + def test_on_road_or_unknown_order_never_opens_grid(self): pos = position() pos.on_road_volume = 100 self.run_price(9.4, pos) - self.run_price(9.46, pos) + self.run_price(9.3, pos) unknown = OrderItem(stock_code=CODE, order_status=255) self.run_price(9.4, orders=[unknown]) - self.run_price(9.46, orders=[unknown]) + self.run_price(9.3, orders=[unknown]) self.client.passorder.assert_not_called() def test_excluded_symbol_is_neither_bought_nor_sold(self): self.engine.excluded.add(CODE) self.run_price(9.4) - self.run_price(9.46) - self.run_price(10.7, position(100, 10)) - self.run_price(10.55, position(100, 10)) + self.run_price(9.3) + self.run_price(10.7, position(100, 9.0, 100)) self.client.passorder.assert_not_called() + def test_entry_gate_uses_configured_band_not_boll_lower(self): + """入场门槛取自 Indicators.entry_band;band_type 决定它由谁计算。""" + donchian_ind = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2, + entry_band=9.0, donchian_lo=8.8, donchian_hi=11.0) + self.run_price(9.4, ind=donchian_ind) + self.run_price(9.35, ind=donchian_ind) # 高于 Donchian 门槛 9.0 + self.client.passorder.assert_not_called() + self.run_price(8.95, ind=donchian_ind) # 进入入场区 + self.run_price(8.90, ind=donchian_ind) # 刷新低点 + self.run_price(8.96, ind=donchian_ind) # 反弹 0.67% 确认 + self.client.passorder.assert_called_once() + self.assertEqual(self.client.passorder.call_args.kwargs['price'], 8.96) + + def test_band_type_switch_changes_entry_band(self): + rows = IndicatorTests().bars() + don = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='donchian', + donchian_period=20, donchian_pct=15.0)) + boll = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='boll')) + self.assertEqual((don.donchian_lo, don.donchian_hi), (9.0, 11.0)) + # 常数序列下 ma60=10、grid=2,两条通道都被 ma60-1格 压到 8.0; + # 因此这里验证通道本身确实换了,而不是只看最终门槛。 + self.assertEqual(don.lower, boll.lower) + self.assertAlmostEqual(boll.entry_band, min(boll.lower, boll.ma60 - boll.grid)) + widened = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='donchian', + donchian_period=20, donchian_pct=5.0)) + self.assertAlmostEqual(widened.entry_band, min(9.0 + (11.0 - 9.0) * 0.05, + widened.ma60 - widened.grid)) + def test_invalid_or_stale_tick_cannot_trade(self): for t in (None, Tick(10), tick(float('nan')), tick(9.4, NOW - timedelta(days=1)), tick(9.4, NOW - timedelta(seconds=91))): self.assertFalse(self.engine.fresh_tick(t, NOW)) self.assertTrue(self.engine.fresh_tick(tick(9.4), NOW)) + # ------------------------------------------------------------- 状态 + def test_state_roundtrip_keeps_lots_and_pending(self): + state = self.store.get(CODE) + state.anchor = 9.3 + state.last_buy = 9.28 + state.lot(0).volume = 200 + state.lot(0).cost = 9.28 + state.lot(0).bought = NOW.date() + state.lot(0).buys = 1 + state.pending['1'] = dict(id='ETF-BUY-abc', side='BUY', volume=100, base_volume=200, + reserved=913.0, level=1, price=9.1) + self.store.save() + again = Store(self.path, 'test').get(CODE) + self.assertEqual(again.anchor, 9.3) + self.assertEqual(again.volume, 200) + self.assertEqual(again.lots['0'].bought, NOW.date()) + self.assertEqual(again.pending['1']['level'], 1) + + def test_v1_state_is_migrated_to_anchor_lot(self): + self.path.write_text( + '{"version": 1, "account": "test", "symbols": {"510300.SH": ' + '{"volume": 200, "cost": 9.9, "last_buy": 9.8, "armed": true, "sell_grid": 0.2, ' + '"peak": 3, "hold_days": 4, "pending": {}}}}', encoding='utf-8') + state = Store(self.path, 'test').get(CODE) + self.assertEqual(state.volume, 200) + self.assertEqual(state.anchor, 9.9) + self.assertEqual(state.lots['0'].volume, 200) + self.assertEqual(state.pending, {}) + def test_corrupt_state_does_not_silently_start_empty(self): - self.path.write_text('{', encoding='utf-8') + for payload in ('{', '{"version": 9, "account": "test", "symbols": {}}', + '{"version": 2, "account": "other", "symbols": {}}', + '{"version": 2, "account": "test", "symbols": {"510300.SH": ' + '{"anchor": 9.0, "lots": {"0": {"volume": 100, "cost": 0}}, "pending": {}}}}', + '{"version": 2, "account": "test", "symbols": {"510300.SH": ' + '{"anchor": 0, "lots": {"0": {"volume": 100, "cost": 9.0}}, "pending": {}}}}', + '{"version": 2, "account": "test", "symbols": {"510300.SH": ' + '{"anchor": 9.0, "lots": {"0": {"volume": 50, "cost": 9.0}}, "pending": {}}}}'): + with self.subTest(payload=payload): + self.path.write_text(payload, encoding='utf-8') + with self.assertRaises(ValueError): + Store(self.path, 'test') + + def test_engine_rejects_symbol_removed_with_pending_order(self): + state = self.store.get(CODE) + state.pending['0'] = dict(id='ETF-BUY-x', side='BUY', volume=100, base_volume=0, + reserved=950.0, level=0, price=9.5) with self.assertRaises(ValueError): - Store(self.path, 'test') + Engine(self.client, ETFConfig(codes=(OTHER,)), self.store, 0.1) class IndicatorTests(unittest.TestCase): @@ -241,6 +456,7 @@ class IndicatorTests(unittest.TestCase): rows = self.bars() + [dict(date='20260916', high=999, low=1, close=999)] ind = calculate(rows, NOW.date(), cfg) self.assertEqual((ind.ma60, ind.atr, ind.lower, ind.upper, ind.grid), (10, 2, 10, 10, 2)) + self.assertAlmostEqual(ind.entry_band, min(ind.lower, ind.ma60 - ind.grid)) def test_atr_accounts_for_gap_and_uses_wilder_smoothing(self): rows = self.bars() @@ -267,32 +483,41 @@ class ConfigAndDataTests(unittest.TestCase): def test_config_rejects_excess_hands_and_invalid_codes(self): for kwargs in ({'max_hands': 11}, {'buy_hands': 11}, {'buy_hands': True}, {'atr_multiplier': float('nan')}, {'codes': ('920202.BJ',)}, - {'codes': (CODE, CODE)}, {'codes': ()}): + {'codes': (CODE, CODE)}, {'codes': ()}, {'min_hold_days': -1}, + {'max_hold_days': -1}, {'grid_levels': 0}, {'grid_levels': 10}, + {'band_type': 'ma'}, {'donchian_pct': 0}): with self.assertRaises(ValueError): ETFConfig(**dict({'codes': (CODE,)}, **kwargs)) def test_default_file_loads(self): cfg = load() - self.assertEqual((cfg.buy_hands, cfg.max_hands), (1, 10)) - + self.assertEqual(cfg.band_type, 'boll') + self.assertGreaterEqual(cfg.grid_levels, 1) + self.assertGreater(cfg.sell_grid_mult, 0) + self.assertGreaterEqual(cfg.min_order_value, 1000) class DailyDataTests(unittest.TestCase): def row(self, day=20260915, **changes): return dict(dict(ts_code=CODE, trade_date=day, open=10, high=11, low=9, close=10), **changes) - def test_request_and_sample_shape(self): + def test_bare_list_response_is_supported(self): + """线上 /etf/daily 直接返回一维数组(倒序),旧版是 {code, details} 包装。""" def respond(request): self.assertEqual(str(request.url), DAILY_URL + '?code=' + CODE) self.assertNotIn('x-token', request.headers) - return httpx.Response(200, json={'code': 0, 'message': '', 'details': [self.row()]}) + return httpx.Response(200, json=[self.row(20260915)]) with httpx.Client(transport=httpx.MockTransport(respond)) as client: self.assertEqual(daily_bars(client, CODE, NOW.date()), [dict(date='20260915', open=10.0, high=11.0, low=9.0, close=10.0)]) + def test_legacy_envelope_response_still_supported(self): + payload = {'code': 0, 'message': '', 'details': [self.row(20260915)]} + self.assertEqual([b['date'] for b in parse_daily(payload, CODE, NOW.date())], ['20260915']) + def test_sort_filter_then_limit_and_numeric_strings(self): - payload = dict(code=0, details=[self.row(20260916), self.row(20260915, close='10.5'), - self.row(20260914), self.row(20260917)]) + payload = [self.row(20260916), self.row(20260915, close='10.5'), + self.row(20260914), self.row(20260917)] bars = parse_daily(payload, CODE, NOW.date(), count=1) self.assertEqual([b['date'] for b in bars], ['20260915']) self.assertEqual(bars[0]['close'], 10.5) @@ -307,7 +532,8 @@ class DailyDataTests(unittest.TestCase): def test_bad_business_response_is_rejected(self): for payload in (None, [], {}, {'code': False, 'details': [self.row()]}, {'code': 1, 'message': 'failed'}, {'code': 0, 'details': []}, - {'code': 0, 'details': {}}, {'code': 0, 'details': None}): + {'code': 0, 'details': {}}, {'code': 0, 'details': None}, + [self.row(20260916)]): with self.subTest(payload=payload), self.assertRaises(ValueError): parse_daily(payload, CODE, NOW.date()) @@ -316,11 +542,11 @@ class DailyDataTests(unittest.TestCase): [self.row(20260230)], [self.row(close=float('nan'))], [self.row(open=True)], [self.row(low=12)], [self.row(close=None)]): with self.subTest(rows=rows), self.assertRaises(ValueError): - parse_daily(dict(code=0, details=rows), CODE, NOW.date()) + parse_daily(rows, CODE, NOW.date()) def test_external_history_flows_into_real_indicators(self): - details = [self.row(int(row['date'])) for row in IndicatorTests().bars()] - rows = parse_daily(dict(code=0, details=details), CODE, NOW.date()) + rows = parse_daily([self.row(int(row['date'])) for row in IndicatorTests().bars()], + CODE, NOW.date()) ind = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,))) self.assertEqual((ind.ma60, ind.atr, ind.grid), (10, 2, 2)) diff --git a/labs/tests/test_etf_config.py b/labs/tests/test_etf_config.py new file mode 100644 index 0000000..564de0b --- /dev/null +++ b/labs/tests/test_etf_config.py @@ -0,0 +1,147 @@ +"""ETF 配置:固定文件加载、缺文件返回 None、全局默认与逐标的覆盖的校验。""" + +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +import config +from config import EtfConfig, EtfDefaults, EtfSymbolConfig + +GLOBAL = {"qmt_base_url": "unused", "api_host": "unused", "hosts": {"test": "account"}} +SYMBOL = 'symbols: {"510300.SH": {is_t0: false, buy_shares: 1000, atr_multiplier: 1.0, inner_step: 0.7}}\n' + + +class EtfConfigTests(unittest.TestCase): + def load(self, etf: str | None = None, account: dict | None = None): + """在临时目录里生成 _global.yaml / account.yaml / _etf.yaml 并加载。""" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "_global.yaml").write_text( + yaml.safe_dump(dict(GLOBAL, qmt_data_dir=directory)), encoding="utf-8" + ) + (root / "account.yaml").write_text( + yaml.safe_dump(account or {"buy_value": 1000, "strategy": "etf"}), + encoding="utf-8", + ) + if etf is not None: + (root / "_etf.yaml").write_text(textwrap.dedent(etf), encoding="utf-8") + with patch.object(config, "global_config"), \ + patch.object(config, "account_config"), \ + patch.object(config, "etf_config"): + config.load(root, "test") + return config.etf_config + + def reject(self, etf: str, message: str): + with self.assertRaisesRegex(ValueError, message): + self.load(etf) + + # ------------------------------------------------------------- 加载 + def test_missing_file_returns_none(self): + """_etf.yaml 缺失不是错误:其它策略的账户不应因此启动失败。""" + self.assertIsNone(self.load()) + + def test_loads_defaults_and_symbols(self): + loaded = self.load( + """ + defaults: + atr_period: 10 + add_pct: 2.5 + symbols: + "159915.SZ": + is_t0: true + buy_shares: 2000 + max_shares: 20000 + atr_multiplier: 1.5 + inner_step: 1.2 + """ + ) + self.assertIsInstance(loaded, EtfConfig) + self.assertEqual(loaded.codes, ("159915.SZ",)) + self.assertEqual(loaded.defaults.atr_period, 10) + self.assertEqual(loaded.defaults.add_pct, 2.5) + symbol = loaded.symbol("159915.SZ") + self.assertTrue(symbol.is_t0) + self.assertEqual(symbol.buy_shares, 2000) + self.assertEqual(symbol.max_shares, 20000) + + def test_unspecified_defaults_fall_back_to_dataclass(self): + loaded = self.load(SYMBOL) + self.assertEqual(loaded.defaults, EtfDefaults()) + self.assertEqual(loaded.defaults.commission_rate, 0.0003) + + def test_symbol_inherits_uncovered_defaults(self): + """max_shares 缺省按 max_adds + 1 档计算,其余未覆盖项回落全局默认。""" + loaded = self.load(SYMBOL) + symbol = loaded.symbol("510300.SH") + self.assertEqual(symbol.max_shares, 1000 * (loaded.defaults.max_adds + 1)) + self.assertEqual(symbol.get("rebound_pct"), loaded.defaults.rebound_pct) + self.assertEqual(symbol.get("inner_grids"), loaded.defaults.inner_grids) + self.assertEqual(symbol.get("max_hold_days"), loaded.defaults.max_hold_days) + self.assertEqual(symbol.get("max_grid_span_pct"), loaded.defaults.max_grid_span_pct) + + def test_symbol_override_wins_over_default(self): + loaded = self.load( + 'defaults: {rebound_pct: 0.5}\n' + SYMBOL.replace( + "inner_step: 0.7", "inner_step: 0.7, rebound_pct: 0.4" + ) + ) + self.assertEqual(loaded.symbol("510300.SH").get("rebound_pct"), 0.4) + + def test_unknown_symbol_is_rejected_by_lookup(self): + loaded = self.load(SYMBOL) + with self.assertRaisesRegex(KeyError, "159915.SZ"): + loaded.symbol("159915.SZ") + + # ------------------------------------------------------------- 校验 + def test_unknown_keys_are_rejected(self): + self.reject("foo: 1\n", "foo") + self.reject("defaults: {atr_period: 14, foo: 1}\n" + SYMBOL, "foo") + self.reject(SYMBOL.replace("inner_step: 0.7", "inner_step: 0.7, add_pct: 2"), "add_pct") + + def test_symbols_must_be_a_non_empty_mapping(self): + self.reject("defaults: {atr_period: 14}\n", "symbols") + + def test_symbol_code_must_be_a_listed_etf(self): + self.reject('symbols: {"600000.SH": {is_t0: false, buy_shares: 100, ' + 'atr_multiplier: 1.0, inner_step: 0.7}}\n', "600000.SH") + + def test_required_symbol_fields_cannot_fall_back(self): + """is_t0、buy_shares、atr_multiplier、inner_step 没有安全的全局默认值。""" + self.reject('symbols: {"510300.SH": {is_t0: false, buy_shares: 1000, ' + 'atr_multiplier: 1.0}}\n', "inner_step") + + def test_symbol_field_types_are_checked(self): + self.reject(SYMBOL.replace("is_t0: false", "is_t0: 1"), "is_t0") + self.reject(SYMBOL.replace("buy_shares: 1000", "buy_shares: 150"), "100") + self.reject(SYMBOL.replace("buy_shares: 1000", "buy_shares: 0"), "buy_shares") + self.reject(SYMBOL.replace("atr_multiplier: 1.0", "atr_multiplier: true"), "atr_multiplier") + self.reject(SYMBOL.replace("atr_multiplier: 1.0", "atr_multiplier: 0"), "atr_multiplier") + + def test_defaults_ranges_are_checked(self): + self.reject("defaults: {max_adds: 10}\n" + SYMBOL, "max_adds") + self.reject("defaults: {max_adds: -1}\n" + SYMBOL, "max_adds") + self.reject("defaults: {max_adds: 1.5}\n" + SYMBOL, "max_adds") + self.reject("defaults: {max_grid_span_pct: 101}\n" + SYMBOL, "max_grid_span_pct") + self.reject("defaults: {atr_period: 0}\n" + SYMBOL, "atr_period") + self.reject("defaults: {min_profit_pct: 0}\n" + SYMBOL, "min_profit_pct") + # 0 表示不止损,是合法值;佣金允许为 0(回测口径)。 + loaded = self.load("defaults: {max_hold_days: 0, commission_rate: 0, min_commission: 0}\n" + SYMBOL) + self.assertEqual(loaded.defaults.max_hold_days, 0) + self.assertEqual(loaded.defaults.commission_rate, 0) + + def test_rebound_must_be_below_add_pct(self): + """反弹确认价必须早于补仓触发,否则条件自相矛盾。""" + self.reject("defaults: {rebound_pct: 3.0, add_pct: 3.0}\n" + SYMBOL, "rebound_pct") + + def test_symbol_defaults_are_shared_not_copied(self): + loaded = self.load(SYMBOL) + self.assertIs(loaded.symbol("510300.SH").defaults, loaded.defaults) + self.assertFalse(EtfSymbolConfig().is_t0) + + +if __name__ == "__main__": + unittest.main() diff --git a/labs/tests/test_etf_signal.py b/labs/tests/test_etf_signal.py new file mode 100644 index 0000000..7227dd1 --- /dev/null +++ b/labs/tests/test_etf_signal.py @@ -0,0 +1,324 @@ +"""ETF 信号层:白名单展开、已收盘日线校验、指标计算与取数缓存。""" + +from datetime import date, datetime, timedelta +from decimal import Decimal, ROUND_CEILING +from statistics import fmean +import unittest +from unittest.mock import Mock, patch + +import httpx + +from config import AccountConfig, EtfConfig, EtfDefaults, EtfSymbolConfig, GlobalConfig +from libs.signal import SignalItem +from strategy.etf import signal as etf_signal + +CODE, OTHER = "510300.SH", "159915.SZ" +TODAY = date(2026, 9, 16) +# 最后一根日线为 2026-09-15(前一交易日),既不过期也不混入当日未收盘日线。 +LAST_DAY = date(2026, 9, 15) + + +def raw_bar(day: str, close: float = 10.0, spread: float = 1.0, code: str = CODE) -> 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()}))