|
| 1 | +# DS 指令包 #01:Fee Schedule 元数据升级 |
| 2 | + |
| 3 | +**主方案**:[`2026-05-11-longtail-resolution-thesis.md`](./2026-05-11-longtail-resolution-thesis.md) §横切 1 |
| 4 | + |
| 5 | +**作者**:方案 author 起草,DS 执行 |
| 6 | +**review 链路**:DS → 方案 author → 同学(PR) |
| 7 | +**预计代码量**:~150 行(实现 + 测试) |
| 8 | +**预计 DS 单次 token 消耗**:< 30k |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## 0. 上下文(DS 必读) |
| 13 | + |
| 14 | +Polymarket 当前 fee 公式: |
| 15 | + |
| 16 | +```text |
| 17 | +fees = feeRate * shares * price * (1 - price) |
| 18 | +``` |
| 19 | + |
| 20 | +**重要事实**(不要重新发明): |
| 21 | +- 项目代码**已经**在 `collectors.py:1382` 的 `market_fee_rate(market)` 里从 Gamma `feeSchedule.rate` 拿到每个市场自己的费率,写入 `BinaryMarketSnapshot.fee_rate`。**不要硬编码 category 阶梯,也不要假装代码当前只有一个全局常量。** |
| 22 | +- 真正的缺口:Gamma `feeSchedule` 还有 `rebateRate`、`takerOnly`、`feeType` 等字段,**目前没保存进 snapshot**。这导致: |
| 23 | + - maker rebate 没法在诊断里展示真实值 |
| 24 | + - 未来 fee 政策变化(如调整 rebateRate)无法在历史 snapshot 上回放 |
| 25 | +- 本任务的范围是**补齐元数据**,不改变现有 fee 计算逻辑。 |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## 1. 任务清单 |
| 30 | + |
| 31 | +### 1.1 `poly_strategy/models.py` |
| 32 | + |
| 33 | +修改 `BinaryMarketSnapshot` dataclass,**保持 frozen + 向后兼容**: |
| 34 | + |
| 35 | +```python |
| 36 | +@dataclass(frozen=True) |
| 37 | +class BinaryMarketSnapshot: |
| 38 | + market_id: str |
| 39 | + venue: str |
| 40 | + yes: OrderBook |
| 41 | + no: OrderBook |
| 42 | + fee_rate: float # 保留,等同 fee_schedule_rate |
| 43 | + ts: Optional[str] = None |
| 44 | + fee_schedule_rate: Optional[float] = None # 新增 |
| 45 | + fee_rebate_rate: Optional[float] = None # 新增 |
| 46 | + fee_taker_only: Optional[bool] = None # 新增 |
| 47 | + fee_type: Optional[str] = None # 新增 |
| 48 | +``` |
| 49 | + |
| 50 | +**约束**: |
| 51 | +- 4 个新字段必须全部 Optional + 默认 None,否则旧 NDJSON 加载会破。 |
| 52 | +- `fee_rate` 字段保留语义不变(taker 费率),等同 `fee_schedule_rate`。代码内部以 `fee_schedule_rate` 为权威;外部已有调用方继续读 `fee_rate` 不破坏。 |
| 53 | + |
| 54 | +### 1.2 `poly_strategy/collectors.py` |
| 55 | + |
| 56 | +**新增函数**(紧贴现有 `market_fee_rate` 之后): |
| 57 | + |
| 58 | +```python |
| 59 | +def market_fee_schedule(market: dict) -> dict: |
| 60 | + """从 Gamma market payload 读完整 feeSchedule 元数据。 |
| 61 | +
|
| 62 | + 返回 dict 包含: |
| 63 | + - rate: float (taker 费率,feesEnabled=False 时 0.0) |
| 64 | + - rebate_rate: Optional[float] |
| 65 | + - taker_only: Optional[bool] |
| 66 | + - fee_type: Optional[str] |
| 67 | +
|
| 68 | + 所有字段缺失时为 None(而非 0/False/""),让下游能区分"未提供"和"已声明为 0"。 |
| 69 | + """ |
| 70 | +``` |
| 71 | + |
| 72 | +**修改 snapshot 写入处**(搜 `market_fee_rate(market)` 的调用点): |
| 73 | +- 当前在 ~line 1033 写 `"fee_rate": market_fee_rate(market)`。 |
| 74 | +- 改为同时写: |
| 75 | + ```python |
| 76 | + schedule = market_fee_schedule(market) |
| 77 | + ... |
| 78 | + "fee_rate": schedule["rate"], |
| 79 | + "fee_schedule_rate": schedule["rate"], |
| 80 | + "fee_rebate_rate": schedule.get("rebate_rate"), |
| 81 | + "fee_taker_only": schedule.get("taker_only"), |
| 82 | + "fee_type": schedule.get("fee_type"), |
| 83 | + ``` |
| 84 | +- Kalshi 路径(~line 758)**不变**,新字段写 None(Kalshi 不暴露 Polymarket-style feeSchedule)。 |
| 85 | + |
| 86 | +**约束**: |
| 87 | +- 不要修改 `market_fee_rate` 的签名或行为;它是其他模块的稳定 API。 |
| 88 | +- 不要给所有调用方都强制升级;按需补字段即可。 |
| 89 | + |
| 90 | +### 1.3 `poly_strategy/backtest.py` |
| 91 | + |
| 92 | +修改 NDJSON 加载处(line 386 附近,构造 `BinaryMarketSnapshot` 的地方): |
| 93 | +- 读 `fee_rate` 保持不变。 |
| 94 | +- 增读: |
| 95 | + ```python |
| 96 | + fee_schedule_rate=row.get("fee_schedule_rate"), |
| 97 | + fee_rebate_rate=row.get("fee_rebate_rate"), |
| 98 | + fee_taker_only=row.get("fee_taker_only"), |
| 99 | + fee_type=row.get("fee_type"), |
| 100 | + ``` |
| 101 | +- 旧 NDJSON 缺失这些字段时,全部为 None,不影响现有 replay。 |
| 102 | + |
| 103 | +### 1.4 `poly_strategy/maker.py` |
| 104 | + |
| 105 | +修改 maker diagnostics 输出(搜 `fee_rate_assumption`,~line 1625 和 1665): |
| 106 | +- 当前写 `"fee_rate_assumption": snapshot.fee_rate`。 |
| 107 | +- 增加: |
| 108 | + ```python |
| 109 | + "rebate_rate": snapshot.fee_rebate_rate, |
| 110 | + "taker_only": snapshot.fee_taker_only, |
| 111 | + ``` |
| 112 | +- **关键约束**:rebate 只作为诊断信息呈现,**不要**把 rebate 当成"确定收入"加到 maker 路径的 net edge 计算里。Maker fill 概率本身没建模,rebate 是条件期望,不是已实现收益。任何 fee net 计算保持现状。 |
| 113 | + |
| 114 | +### 1.5 `poly_strategy/fees.py` |
| 115 | + |
| 116 | +**主公式不变**。如果需要新增 category fallback: |
| 117 | +- 单独的 helper `polymarket_category_fallback_rate(category: str) -> Optional[float]` |
| 118 | +- **不默认启用**;只在显式 opt-in 时使用(参数名 `allow_category_fallback: bool = False`) |
| 119 | +- fallback 表硬编码在文件顶部 dict,附 TODO 注释:源 URL(`docs.polymarket.com/trading/fees`)+ 复核日期(YYYY-MM-DD) |
| 120 | +- **本 PR 不要求实现这个 helper**——如果加,也只暴露函数,不接入主路径。 |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | +## 2. 测试要求 |
| 125 | + |
| 126 | +在 `tests/test_collectors.py`、`tests/test_backtest.py`、`tests/test_maker.py` 增加测试。如果对应文件不存在,新建。 |
| 127 | + |
| 128 | +### 2.1 `tests/test_collectors.py`(新增或扩展) |
| 129 | + |
| 130 | +```python |
| 131 | +def test_market_fee_schedule_full(): |
| 132 | + market = { |
| 133 | + "feesEnabled": True, |
| 134 | + "feeSchedule": { |
| 135 | + "rate": 0.02, |
| 136 | + "rebateRate": 0.01, |
| 137 | + "takerOnly": True, |
| 138 | + "feeType": "linear_price_squared", |
| 139 | + }, |
| 140 | + } |
| 141 | + result = market_fee_schedule(market) |
| 142 | + assert result["rate"] == 0.02 |
| 143 | + assert result["rebate_rate"] == 0.01 |
| 144 | + assert result["taker_only"] is True |
| 145 | + assert result["fee_type"] == "linear_price_squared" |
| 146 | + |
| 147 | +def test_market_fee_schedule_disabled(): |
| 148 | + market = {"feesEnabled": False, "feeSchedule": {"rate": 0.02}} |
| 149 | + result = market_fee_schedule(market) |
| 150 | + assert result["rate"] == 0.0 |
| 151 | + # 其他字段未启用时返回 None |
| 152 | + assert result["rebate_rate"] is None |
| 153 | + |
| 154 | +def test_market_fee_schedule_partial(): |
| 155 | + # 只有 rate 字段时,其他字段 None |
| 156 | + market = {"feesEnabled": True, "feeSchedule": {"rate": 0.015}} |
| 157 | + result = market_fee_schedule(market) |
| 158 | + assert result["rate"] == 0.015 |
| 159 | + assert result["rebate_rate"] is None |
| 160 | + assert result["taker_only"] is None |
| 161 | + assert result["fee_type"] is None |
| 162 | +``` |
| 163 | + |
| 164 | +### 2.2 `tests/test_backtest.py`(扩展) |
| 165 | + |
| 166 | +```python |
| 167 | +def test_replay_handles_legacy_snapshot_without_fee_schedule_fields(tmp_path): |
| 168 | + """旧 NDJSON 只有 fee_rate,没有新字段,应该正常 replay。""" |
| 169 | + # 构造一行只含 fee_rate 的 snapshot, 验证 replay 不抛错 |
| 170 | + |
| 171 | +def test_replay_preserves_new_fee_schedule_fields(tmp_path): |
| 172 | + """新 NDJSON 带全套字段,replay 后 snapshot 上字段可访问。""" |
| 173 | +``` |
| 174 | + |
| 175 | +### 2.3 `tests/test_maker.py`(扩展) |
| 176 | + |
| 177 | +```python |
| 178 | +def test_maker_diagnostics_include_rebate_when_present(): |
| 179 | + """snapshot 带 fee_rebate_rate 时,diagnostics 中应该包含 rebate_rate 字段。""" |
| 180 | + |
| 181 | +def test_maker_diagnostics_rebate_missing_when_absent(): |
| 182 | + """snapshot 不带 fee_rebate_rate 时,diagnostics 中 rebate_rate 为 None(或不影响净 edge)。""" |
| 183 | + |
| 184 | +def test_maker_net_edge_does_not_credit_rebate(): |
| 185 | + """关键:maker 路径的 net edge 计算不能因为 rebate 存在而提高。""" |
| 186 | +``` |
| 187 | + |
| 188 | +--- |
| 189 | + |
| 190 | +## 3. 不要做的事 |
| 191 | + |
| 192 | +明确**out of scope**: |
| 193 | + |
| 194 | +- ❌ 不要改 `BinaryMarketSnapshot.fee_rate` 的语义或类型 |
| 195 | +- ❌ 不要把 category 阶梯写进 `fees.py` 的主路径 |
| 196 | +- ❌ 不要在 `fee_adjusted_buy_cost` / scanner 的 net edge 计算里加入 rebate |
| 197 | +- ❌ 不要修改 Kalshi 路径的 fee 处理 |
| 198 | +- ❌ 不要"顺便"修 `fee_adjusted_buy_cost` 永远走 polymarket fee 的潜在问题(独立任务) |
| 199 | +- ❌ 不要重命名现有字段 |
| 200 | +- ❌ 不要触碰 watchlist / scanner / 任何 longtail/T1-T4 相关模块 |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## 4. 完成定义(DoD) |
| 205 | + |
| 206 | +- [ ] `pytest tests/test_collectors.py tests/test_backtest.py tests/test_maker.py` 全绿 |
| 207 | +- [ ] `pytest` 整套 0 失败、0 新 warning |
| 208 | +- [ ] `mypy poly_strategy/models.py poly_strategy/collectors.py poly_strategy/backtest.py poly_strategy/maker.py` 通过(如果项目用 mypy) |
| 209 | +- [ ] 旧 NDJSON 样本(`data/` 下任意现存 snapshot 文件)能被 `replay_ndjson` 加载不抛错 |
| 210 | +- [ ] 在 PR 描述里贴出: |
| 211 | + - 一段真实 Gamma market payload 中 `feeSchedule` 字段的实际形状(从一个最近的 raw market dump 里 copy) |
| 212 | + - 一份新生成 snapshot NDJSON 的样例行(含新字段) |
| 213 | + - `pytest -q` 的最后 3 行输出 |
| 214 | + |
| 215 | +--- |
| 216 | + |
| 217 | +## 5. PR 标题与描述模板 |
| 218 | + |
| 219 | +**标题**:`Save full feeSchedule metadata in binary snapshots` |
| 220 | + |
| 221 | +**描述**: |
| 222 | +``` |
| 223 | +Implements §横切 1 of the longtail thesis plan (post-review version). |
| 224 | +
|
| 225 | +What changes: |
| 226 | +- BinaryMarketSnapshot gains 4 optional fee_schedule_* fields. |
| 227 | +- collectors.market_fee_schedule() reads them from Gamma payload. |
| 228 | +- backtest replay reads them with backward-compat defaults. |
| 229 | +- maker diagnostics surface rebate_rate (as info, not as credited income). |
| 230 | +
|
| 231 | +What does NOT change: |
| 232 | +- Existing fee_rate field semantics. |
| 233 | +- fees.py main formula. |
| 234 | +- Kalshi path. |
| 235 | +- Any net-edge / scanner / watchlist logic. |
| 236 | +
|
| 237 | +Tests: <pytest output snippet> |
| 238 | +
|
| 239 | +Verified backward compat: <how> |
| 240 | +``` |
| 241 | + |
| 242 | +--- |
| 243 | + |
| 244 | +## 6. 如果 DS 遇到不确定情况 |
| 245 | + |
| 246 | +- 找不到指定行号 → grep 关键函数名,code shape 可能因小重构有偏移,按语义对齐即可 |
| 247 | +- 测试样例数据缺失 → 在 `tests/fixtures/` 下新建最小 JSON fixture |
| 248 | +- 任何 schema/接口外的疑问 → **停下来在 PR 上提问**,不要凭直觉扩大范围 |
0 commit comments