From a58d97938c7d166c4841a1f0e26c2d70da13b59a Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 26 Jul 2026 16:07:20 +0800 Subject: [PATCH] Floor option close at intrinsic to fix expiry garbage quotes. Co-authored-by: Cursor --- backend/app/sim/matcher.py | 103 +++++- backend/app/sim/pricing.py | 43 +++ backend/tests/test_p1_p2_rules.py | 37 +++ docs/策略说明.md | 519 +++++++++++++++--------------- scripts/repair_expiry_settle.py | 152 +++++++++ 5 files changed, 588 insertions(+), 266 deletions(-) create mode 100644 scripts/repair_expiry_settle.py diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 5e6373b..d4dd2e4 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -12,7 +12,12 @@ from ..models.db import Database, get_db from ..strategy.session import get_session from .ledger import Ledger from .liquidity import bid_covers_eth, bid_mark_ok, contracts_for_eth -from .pricing import option_fill, perp_fill +from .pricing import ( + option_fill, + option_intrinsic, + perp_fill, + resolve_option_close_bid, +) @dataclass(slots=True) @@ -71,6 +76,61 @@ class Matcher: ) return CloseResult(ok=False, detail=detail, liquidity_wait=True) + def _group_strike(self, group_id: str, option_inst_id: str) -> float | None: + g = self.db.fetchone( + "SELECT strike FROM groups WHERE group_id=?", (group_id,) + ) + if g is not None and g["strike"] is not None: + try: + return float(g["strike"]) + except (TypeError, ValueError): + pass + try: + from ..exchange.okx.parse import parse_option_inst_id + + _, stk, _ = parse_option_inst_id(option_inst_id) + if stk is not None: + return float(stk) + except Exception: + pass + try: + from ..exchange.binance.parse import parse_option_symbol + + _, stk, _ = parse_option_symbol(option_inst_id) + if stk is not None: + return float(stk) + except Exception: + pass + return None + + def _close_spot_px(self, snap: Any) -> float | None: + if getattr(snap, "index_px", None) is not None: + try: + px = float(snap.index_px) + if px > 0: + return px + except (TypeError, ValueError): + pass + perp = getattr(snap, "perp", None) + if not perp: + return None + for attr in ("mark_px", "last"): + v = getattr(perp, attr, None) + if v is not None: + try: + px = float(v) + if px > 0: + return px + except (TypeError, ValueError): + pass + if perp.bid is not None and perp.ask is not None: + return (float(perp.bid) + float(perp.ask)) / 2.0 + if perp.bid is not None: + return float(perp.bid) + if perp.ask is not None: + return float(perp.ask) + return None + def open_group( self, *, @@ -269,7 +329,8 @@ class Matcher: def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult: """ 全平一组。默认校验期权买一深度 + 买一/标记偏差(默认≤30%)。 - bypass_liquidity=True:紧急全平可绕过(仍需有可用买一价才能成交;无买一时用标记近似)。 + bypass_liquidity=True:紧急/到期可绕过深度闸门;平仓价取 max(买一, 标记, 内在价值), + 避免到期垃圾盘口把实值期权按近零价卖掉。 成交顺序:先平期权(薄)→ 再瞬时平永续(对冲先留着);永续盘口失败则回滚期权入账。 """ s = get_settings() @@ -302,6 +363,14 @@ class Matcher: ) close_bid = oq.bid + strike = self._group_strike(group_id, option_inst_id) + spot = self._close_spot_px(snap) + intrinsic: float | None = None + if strike is not None and spot is not None: + intrinsic = option_intrinsic( + option_side=option_side, strike=strike, spot=spot + ) + if not bypass_liquidity: if close_bid is None: return self._liquidity_wait(group_id, "期权买一不可用") @@ -316,12 +385,29 @@ class Matcher: ) if not ok_dev: return self._liquidity_wait(group_id, why) + resolved = resolve_option_close_bid( + bid=float(close_bid), + mark=oq.mark_px, + intrinsic=intrinsic, + bypass_liquidity=False, + ) + if resolved is None: + return self._liquidity_wait(group_id, "期权平仓价不可用") + close_bid = resolved else: - # 紧急:优先买一,否则用标记价近似成交(SIM) - if close_bid is None: - close_bid = oq.mark_px - if close_bid is None: - return CloseResult(ok=False, detail="紧急全平失败:无买一/标记价") + # 到期/紧急:买一/标记可能枯死,用 max(买一, 标记, 内在价值) + resolved = resolve_option_close_bid( + bid=close_bid, + mark=oq.mark_px, + intrinsic=intrinsic, + bypass_liquidity=True, + ) + if resolved is None: + return CloseResult( + ok=False, + detail="紧急全平失败:无买一/标记/内在价值", + ) + close_bid = resolved fee_rate = self._fee_rate() perp_side = str(pos["perp_side"]) @@ -455,6 +541,9 @@ class Matcher: "net": net, "close_sequence": ["option", "perp"], "cash_delta": opt_cash + perp_pnl - pf.fee, + "option_close_bid": float(close_bid), + "option_intrinsic": intrinsic, + "settle_spot": spot, }, ) diff --git a/backend/app/sim/pricing.py b/backend/app/sim/pricing.py index 854bf5e..807a63d 100644 --- a/backend/app/sim/pricing.py +++ b/backend/app/sim/pricing.py @@ -17,6 +17,49 @@ class PriceResult: return asdict(self) +def option_intrinsic(*, option_side: str, strike: float, spot: float) -> float: + """多头期权内在价值(USDT/ETH)。call=max(S−K,0),put=max(K−S,0)。""" + s = float(spot) + k = float(strike) + side = str(option_side).lower().strip() + if side in ("call", "c"): + return max(s - k, 0.0) + if side in ("put", "p"): + return max(k - s, 0.0) + return 0.0 + + +def resolve_option_close_bid( + *, + bid: float | None, + mark: float | None, + intrinsic: float | None, + bypass_liquidity: bool, +) -> float | None: + """ + 平仓用买一价;多头卖出不得低于内在价值(SIM 防到期垃圾盘口)。 + bypass 时:买一缺失可用标记/内在价值兜底。 + """ + candidates: list[float] = [] + if bid is not None and bid >= 0: + candidates.append(float(bid)) + if bypass_liquidity and mark is not None and mark >= 0: + candidates.append(float(mark)) + if intrinsic is not None and intrinsic >= 0: + candidates.append(float(intrinsic)) + if not candidates: + return None + # 常规:有买一时,仍用 max(买一, 内在价值) 抬到合理底价 + # bypass:max(买一, 标记, 内在价值) + if bypass_liquidity: + return max(candidates) + if bid is None: + return None + if intrinsic is not None and intrinsic >= 0: + return max(float(bid), float(intrinsic)) + return float(bid) + + def perp_fill( *, side: str, diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index f865673..2048bad 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -142,3 +142,40 @@ def test_expiry_close() -> None: assert d2.reason == "expiry" d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001) assert d3.should_close is True + + +def test_option_intrinsic_and_close_bid_floor() -> None: + from app.sim.pricing import option_intrinsic, resolve_option_close_bid + + assert option_intrinsic(option_side="call", strike=1860, spot=1882) == 22.0 + assert option_intrinsic(option_side="put", strike=1860, spot=1882) == 0.0 + assert option_intrinsic(option_side="put", strike=1860, spot=1840) == 20.0 + + # 到期垃圾买一 0.2,内在价值 22 → 抬到 22 + assert ( + resolve_option_close_bid( + bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=True + ) + == 22.0 + ) + # 常规也有内在价值地板 + assert ( + resolve_option_close_bid( + bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=False + ) + == 22.0 + ) + # 买一高于内在价值,保留买一 + assert ( + resolve_option_close_bid( + bid=25.0, mark=24.0, intrinsic=22.0, bypass_liquidity=True + ) + == 25.0 + ) + # bypass 无买一,用标记与内在价值 + assert ( + resolve_option_close_bid( + bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True + ) + == 22.0 + ) diff --git a/docs/策略说明.md b/docs/策略说明.md index 8b4be37..3d99182 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -1,259 +1,260 @@ -# ETH 永续 + 期权对冲策略说明 - -> 依据当前代码逻辑整理(SIM 默认真值参数)。 -> 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md) -> 更新:2026-07-25 - ---- - -## 1. 策略一句话 - -用 **ATM 期权买方** 表达方向弹性,用 **反向永续** 做对冲腿;波动大时争取多轮兑现净盈利,波动小时接受权利金磨损,**到期自动全平**。 - -本质是 **概率与样本**:不追求每天固定轮次,而按行情吃机会。 - -**行情来源可切换(SIM)**:系统设置中可选 **OKX** 或 **币安** 公共行情;成交仍为本机模拟撮合,**不下真单**。 - -| 交易所 | 永续(默认) | 期权 | 说明 | -|--------|--------------|------|------| -| OKX | ETH-USDT-SWAP | ETH-USD_UM(偏 USDC 保证金族) | 当前默认 | -| 币安 | ETHUSDT(USDT-M) | 欧洲期权 ETH-YYMMDD-行权价-C/P(USDT) | 后期实盘优先候选 | - -有持仓时不可切换交易所;切换后自动套用该所合约并重连行情。 - ---- - -## 2. 仓位结构(默认) - -| 腿 | 默认数量 | 方向规则 | 成交方式 | -|----|----------|----------|----------| -| 永续 ETH-USDT-SWAP | **1 ETH** | 见下表 | 市价(吃买一/卖一 + 滑点) | -| 期权 ETH-USD_UM | **2 ETH** 名义 | 只买不卖 | 开仓吃卖一,平仓吃买一 | - -**开仓顺序**:先成交期权 → 确认后再市价成交永续。永续盘口失败则回滚期权扣款,不留半边仓。 - -**平仓顺序**:同样先平期权(买一薄)→ 再瞬时平永续。平期权期间永续对冲先留着;永续盘口失败则回滚期权入账。 - -永续杠杆默认 **3×**(可配)。同时最多 **1 组**仓,禁止叠仓开下一组。 - -### 2.1 开仓方向(ATM 相对现价优先) - -行权价相对标的有偏离时(币安粗档常见),**先按 ATM 偏上/偏下选向**;仅当行权价与标的贴平(≈)时,才回退卖一比价。 - -| 条件 | 期权 | 永续 | bias | -|------|------|------|------| -| ATM 行权价 **<** 标的(如 1850 vs 1859) | 买入 Call | 做空 | `strike_below_spot` | -| ATM 行权价 **>** 标的(如 1875 vs 1859) | 买入 Put | 做多 | `strike_above_spot` | -| ATM ≈ 标的,且 Call 卖一 > Put 卖一 | 买入 Call | 做空 | `call_ask_gt_put` | -| ATM ≈ 标的,且 Put 卖一 > Call 卖一 | 买入 Put | 做多 | `put_ask_gt_call` | -| ATM ≈ 标的且卖一相等 | 不开仓,继续等待 | — | — | - -直觉:偏下行权价用 Call+空,偏上行权价用 Put+多;贴平时再按权利金溢价侧选向。 - ---- - -## 3. 开仓机制 - -### 3.1 流程概览 - -``` -策略运行中 - → 无持仓且不在休息期 - → 非周末跳过(若开启) - → 选到期:剩余时长 ≥ min_option_hours(默认 12h) - → 该到期 ATM 行权价(最接近标的) - → 若开启 atm_open_offset_enabled:|ATM − 标的| ≤ max_atm_open_offset(默认 3)否则跳过 - → 选向:ATM 偏下→Call+空;偏上→Put+多;贴平→卖一比价 - → 期权杠杆 = 标的价 ÷ 卖一权利金 ≥ min_option_leverage(默认 100) - → 先开期权(吃卖一)→ 再市价开永续(一组) - → 锁定 initial_premium = 期权成交价 × 期权名义(不含费) -``` - -### 3.2 选约约束 - -| 参数 | 默认 | 作用 | -|------|------|------| -| `min_option_hours` | 12 | 过滤过近到期,减少刚开仓就到期 | -| `min_option_leverage` | 100 | 权利金相对标的不能太贵(现价/卖一) | -| `atm_open_offset_enabled` | false | 开仓 ATM 偏差限制开关(默认关) | -| `max_atm_open_offset` | 3 | 开关开启后:\|ATM − 标的\| 超过则不开 | -| ATM | — | 同到期、最接近指数/标记价的行权价 | - -无合格合约时:状态停留等待,记录「无合格期权…」,不硬开。 - -### 3.3 时间与节奏 - -| 规则 | 说明 | -|------|------| -| 开仓时间窗 | **已取消**,全天可尝试(仍受选约约束) | -| 周六/周日 | 默认 **跳过新开仓**(上海时区,`skip_weekends`);持仓仍可平 | -| 每日轮次上限 | **不限制**;波动大可能多轮,波动小可能一轮都难 | -| 组间休息 | 全平后默认休息 **300 秒**(`rest_seconds`)再开下一组 | -| 同时持仓 | 最多 1 组 | - -### 3.4 费用(SIM) - -- 永续、期权均按可配 `fee_rate`(默认 0.0005)计费。 -- 滑点按约 1 倍费率计入成交价,手续费另扣。 -- 开仓现金:支付期权权利金 + 开仓手续费;永续开仓主要扣费。 - ---- - -## 4. 平仓机制 - -任一触发 → **该组永续 + 期权全部平掉**(先期权、后永续)。 - -### 4.1 净盈利达标(主出场) - -**净盈利**口径(盯盘用): - -``` -净盈利 ≈ 永续浮盈 + 期权浮盈 − 预估平仓手续费 -期权浮盈 = 当前买一 × 数量 − 初始权利金 -``` - -| 出场模式 | 默认 | 触发条件 | -|----------|------|----------| -| `fixed_usdt` | 目标 **15 USDT** | 净盈利 ≥ `net_profit_target` | -| `premium_multiple` | 倍数 **1.0** | 净盈利 ≥ `initial_premium × premium_exit_multiple` | - -说明:权利金倍数模式下,**1× = 赚回一倍权利金金额的净利**(例如权利金 20.41,目标约 20.41,不是 40.82)。 - -### 4.2 到期自动全平 - -- 到达期权到期时刻(OKX:UTC 08:00 = **上海 16:00**)→ 原因 `expiry`。 -- **绕过**买一深度 / 买一相对标记偏差闸门(与紧急全平同类),尽量卖掉。 -- 策略 **暂停时仍执行**,避免拖过期。 -- 波动小、拖到到期:权利金亏损视为 **预算内成本**,可接受。 - -### 4.3 平仓流动性闸门(常规出场) - -常规净盈利平仓时: - -- 期权买一深度需覆盖平仓名义; -- 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`); -- 不满足 → `liquidity_wait`,继续等待,不改开仓。 - -**到期 / 紧急全平**:绕过上述闸门。 - -### 4.4 其它平仓入口 - -| 原因码 | 含义 | -|--------|------| -| `fixed_usdt` / `premium_multiple` | 净盈利达标 | -| `expiry` | 到期自动全平 | -| `emergency` | 界面紧急全平 | -| `manual` | 手动平仓 | - ---- - -## 5. 策略侧重点 - -| 侧重点 | 说明 | -|--------|------| -| **吃波动,不赌横盘** | 买方期权需要标的走动;大波动利于达标出场 | -| **概率与轮次** | 波动日可能多轮;磨盘日可能零轮或一轮认亏 | -| **确定性时间规则** | 用周末跳过、到期全平,**不做**难以精准的横盘识别 | -| **费用后净利** | 出场看扣完预估平仓费后的净盈利,避免「账面赚、平完亏」 | -| **单组纪律** | 有仓不开下一组,防止重叠风险 | -| **权利金是成本预算** | 小波动拖到期亏权利金,属于策略设计内损耗,不是异常 bug | - -适合对外表述: - -> 适合有波动的时段;震荡磨盘是主要磨损源。不怕单边大波动本身,怕没波动却持续付时间价值。 - ---- - -## 6. 风险点评估 - -### 6.1 主要风险 - -| 风险 | 等级 | 说明 | 缓解(已有/建议) | -|------|------|------|------------------| -| **横盘 / 低波动** | 高 | Theta 消耗权利金;永续几乎不贡献利润 | 周末跳过;到期认亏全平;接受磨损日 | -| **方向选错** | 中高 | 卖一比价并非预测圣杯;错向时期权可能失利,永续端也可能承压 | 净盈利达标才兑现;到期强平止损时间 | -| **开平滑点与手续费** | 中 | 双边 taker + 滑点侵蚀小目标利润 | 目标默认 15U 需覆盖费用;勿把目标设过低 | -| **期权流动性** | 中 | 平仓买一薄、偏差大 → 等待 | 流动性闸门;到期/紧急可绕过 | -| **杠杆与保证金** | 中 | 永续 3×,标的急跌/急涨挤压保证金 | 控制名义、保留资金缓冲;实盘注意强平线 | -| **同到期 ATM 拥挤** | 低–中 | 临近到期价差与盘口恶化 | `min_option_hours`;到期强平 | -| **SIM≠实盘** | 高(若直接外推) | 本地撮合、深度与成交假设简化 | 小资金实盘验证后再放大 | -| **交易所限流 / 断连** | 中 | 行情中断导致延误开平 | 健康检查;部署侧监控 | - -### 6.2 明确不做什么(边界) - -- 不做卖方期权(不收权利金那套)。 -- 不做横盘「智能识别」开仓过滤。 -- 不做多组并行。 -- 当前主路径为 **SIM**;实盘需另行授权与二次确认(见商业化方案)。 - -### 6.3 损益直觉 - -- **有利波动**:期权端弹性(2 ETH 名义)常快于永续(1 ETH)的对冲损耗 → 易达净利目标。 -- **不利 / 无波动**:权利金贬值 + 费用 → 拖到到期全平,单组亏损量级约在 **权利金量级 + 双边费用**(再叠加永续期间浮亏/浮盈)。 - ---- - -## 7. 最小资金推荐 - -以下按 **默认仓位**(永续 1 ETH、期权 2 ETH 名义、杠杆 3×)估算,标的价按 **ETH ≈ 1,800–2,200 USDT** 量级。 - -### 7.1 单组占用粗算 - -| 项目 | 约略金额(USDT) | -|------|------------------| -| 永续保证金(名义÷杠杆) | ≈ 1,800–2,200 ÷ 3 ≈ **600–750** | -| 期权权利金(ATM、短期限,2 ETH) | 常见约 **15–60**(行情波动大时更高) | -| 开平手续费 / 滑点缓冲 | **20–50** | -| 逆向波动缓冲(避免过紧) | **300–800**(经验缓冲,非精确 VaR) | - -### 7.2 建议档位 - -| 档位 | 建议权益(USDT) | 适用 | -|------|------------------|------| -| **绝对下限(不推荐)** | ≥ **1,500** | 仅能勉强覆盖保证金 + 小权利金,缓冲极薄,易心态与风控双紧 | -| **最小可用** | ≥ **3,000** | 默认参数下可跑 SIM / 极小实盘验证,仍偏紧 | -| **推荐起步** | ≥ **5,000** | 留出磨损日与波动缓冲,更符合「概率样本」玩法 | -| **较舒适** | ≥ **10,000** | 参数微调、连续多日磨损时更从容 | - -> 缩放规则:若把永续改为 `0.5 ETH`、期权 `1 ETH`,资金需求大约可按比例减半,但费用占比会上升,小目标更难赚。 - -### 7.3 与出场目标的关系 - -- 默认净利目标 **15 USDT**:在费用后属于「小而频」目标,依赖波动日多次兑现。 -- 单日/单组最坏常见情形更接近 **亏掉当期权利金 + 费用**(外加永续路径损益),资金规划应按 **多次磨损日** 而非「每组必赚 15」来留余量。 - -### 7.4 SIM 默认 - -系统默认虚拟权益 `initial_equity = 10,000` USDT(策略设置可改;保存且数值变更时在无持仓下重置账本),**不代表**实盘建议入金。 - -日后币安实盘试跑建议仓位:**永续 0.1 ETH / 期权 0.2 ETH 名义**,并同比下调净利目标。 - ---- - -## 8. 关键可配参数速查 - -| 参数 | 默认 | 类别 | -|------|------|------| -| `perp_qty_eth` | 1 | 仓位 | -| `option_qty_eth` | 2 | 仓位 | -| `leverage` | 3 | 永续 | -| `fee_rate` | 0.0005 | 成本 | -| `exit_mode` | fixed_usdt | 出场 | -| `net_profit_target` | 15 | 出场 | -| `premium_exit_multiple` | 1.0 | 出场 | -| `rest_seconds` | 300 | 节奏 | -| `skip_weekends` | true | 时间 | -| `min_option_hours` | 12 | 选约 | -| `min_option_leverage` | 100 | 选约 | -| `atm_open_offset_enabled` | false | 开仓 ATM 偏差开关 | -| `max_atm_open_offset` | 3 | 开仓 ATM 偏差上限 | -| `close_bid_mark_max_pct` | 30 | 平仓流动性 | - ---- - -## 9. 修订记录 - -| 日期 | 说明 | -|------|------| -| 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 | -| 2026-07-25 | 平仓顺序改为先期权后永续(与开仓同理:薄腿优先) | +# ETH 永续 + 期权对冲策略说明 + +> 依据当前代码逻辑整理(SIM 默认真值参数)。 +> 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md) +> 更新:2026-07-25 + +--- + +## 1. 策略一句话 + +用 **ATM 期权买方** 表达方向弹性,用 **反向永续** 做对冲腿;波动大时争取多轮兑现净盈利,波动小时接受权利金磨损,**到期自动全平**。 + +本质是 **概率与样本**:不追求每天固定轮次,而按行情吃机会。 + +**行情来源可切换(SIM)**:系统设置中可选 **OKX** 或 **币安** 公共行情;成交仍为本机模拟撮合,**不下真单**。 + +| 交易所 | 永续(默认) | 期权 | 说明 | +|--------|--------------|------|------| +| OKX | ETH-USDT-SWAP | ETH-USD_UM(偏 USDC 保证金族) | 当前默认 | +| 币安 | ETHUSDT(USDT-M) | 欧洲期权 ETH-YYMMDD-行权价-C/P(USDT) | 后期实盘优先候选 | + +有持仓时不可切换交易所;切换后自动套用该所合约并重连行情。 + +--- + +## 2. 仓位结构(默认) + +| 腿 | 默认数量 | 方向规则 | 成交方式 | +|----|----------|----------|----------| +| 永续 ETH-USDT-SWAP | **1 ETH** | 见下表 | 市价(吃买一/卖一 + 滑点) | +| 期权 ETH-USD_UM | **2 ETH** 名义 | 只买不卖 | 开仓吃卖一,平仓吃买一 | + +**开仓顺序**:先成交期权 → 确认后再市价成交永续。永续盘口失败则回滚期权扣款,不留半边仓。 + +**平仓顺序**:同样先平期权(买一薄)→ 再瞬时平永续。平期权期间永续对冲先留着;永续盘口失败则回滚期权入账。 + +永续杠杆默认 **3×**(可配)。同时最多 **1 组**仓,禁止叠仓开下一组。 + +### 2.1 开仓方向(ATM 相对现价优先) + +行权价相对标的有偏离时(币安粗档常见),**先按 ATM 偏上/偏下选向**;仅当行权价与标的贴平(≈)时,才回退卖一比价。 + +| 条件 | 期权 | 永续 | bias | +|------|------|------|------| +| ATM 行权价 **<** 标的(如 1850 vs 1859) | 买入 Call | 做空 | `strike_below_spot` | +| ATM 行权价 **>** 标的(如 1875 vs 1859) | 买入 Put | 做多 | `strike_above_spot` | +| ATM ≈ 标的,且 Call 卖一 > Put 卖一 | 买入 Call | 做空 | `call_ask_gt_put` | +| ATM ≈ 标的,且 Put 卖一 > Call 卖一 | 买入 Put | 做多 | `put_ask_gt_call` | +| ATM ≈ 标的且卖一相等 | 不开仓,继续等待 | — | — | + +直觉:偏下行权价用 Call+空,偏上行权价用 Put+多;贴平时再按权利金溢价侧选向。 + +--- + +## 3. 开仓机制 + +### 3.1 流程概览 + +``` +策略运行中 + → 无持仓且不在休息期 + → 非周末跳过(若开启) + → 选到期:剩余时长 ≥ min_option_hours(默认 12h) + → 该到期 ATM 行权价(最接近标的) + → 若开启 atm_open_offset_enabled:|ATM − 标的| ≤ max_atm_open_offset(默认 3)否则跳过 + → 选向:ATM 偏下→Call+空;偏上→Put+多;贴平→卖一比价 + → 期权杠杆 = 标的价 ÷ 卖一权利金 ≥ min_option_leverage(默认 100) + → 先开期权(吃卖一)→ 再市价开永续(一组) + → 锁定 initial_premium = 期权成交价 × 期权名义(不含费) +``` + +### 3.2 选约约束 + +| 参数 | 默认 | 作用 | +|------|------|------| +| `min_option_hours` | 12 | 过滤过近到期,减少刚开仓就到期 | +| `min_option_leverage` | 100 | 权利金相对标的不能太贵(现价/卖一) | +| `atm_open_offset_enabled` | false | 开仓 ATM 偏差限制开关(默认关) | +| `max_atm_open_offset` | 3 | 开关开启后:\|ATM − 标的\| 超过则不开 | +| ATM | — | 同到期、最接近指数/标记价的行权价 | + +无合格合约时:状态停留等待,记录「无合格期权…」,不硬开。 + +### 3.3 时间与节奏 + +| 规则 | 说明 | +|------|------| +| 开仓时间窗 | **已取消**,全天可尝试(仍受选约约束) | +| 周六/周日 | 默认 **跳过新开仓**(上海时区,`skip_weekends`);持仓仍可平 | +| 每日轮次上限 | **不限制**;波动大可能多轮,波动小可能一轮都难 | +| 组间休息 | 全平后默认休息 **300 秒**(`rest_seconds`)再开下一组 | +| 同时持仓 | 最多 1 组 | + +### 3.4 费用(SIM) + +- 永续、期权均按可配 `fee_rate`(默认 0.0005)计费。 +- 滑点按约 1 倍费率计入成交价,手续费另扣。 +- 开仓现金:支付期权权利金 + 开仓手续费;永续开仓主要扣费。 + +--- + +## 4. 平仓机制 + +任一触发 → **该组永续 + 期权全部平掉**(先期权、后永续)。 + +### 4.1 净盈利达标(主出场) + +**净盈利**口径(盯盘用): + +``` +净盈利 ≈ 永续浮盈 + 期权浮盈 − 预估平仓手续费 +期权浮盈 = 当前买一 × 数量 − 初始权利金 +``` + +| 出场模式 | 默认 | 触发条件 | +|----------|------|----------| +| `fixed_usdt` | 目标 **15 USDT** | 净盈利 ≥ `net_profit_target` | +| `premium_multiple` | 倍数 **1.0** | 净盈利 ≥ `initial_premium × premium_exit_multiple` | + +说明:权利金倍数模式下,**1× = 赚回一倍权利金金额的净利**(例如权利金 20.41,目标约 20.41,不是 40.82)。 + +### 4.2 到期自动全平 + +- 到达期权到期时刻(OKX:UTC 08:00 = **上海 16:00**)→ 原因 `expiry`。 +- **绕过**买一深度 / 买一相对标记偏差闸门(与紧急全平同类),尽量卖掉。 +- 策略 **暂停时仍执行**,避免拖过期。 +- 波动小、拖到到期:权利金亏损视为 **预算内成本**,可接受。 + +### 4.3 平仓流动性闸门(常规出场) + +常规净盈利平仓时: + +- 期权买一深度需覆盖平仓名义; +- 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`); +- 不满足 → `liquidity_wait`,继续等待,不改开仓。 + +**到期 / 紧急全平**:绕过上述闸门;期权平仓价取 **max(买一, 标记, 内在价值)**,避免到期盘口枯死把实值期权按近零价结算。 + +### 4.4 其它平仓入口 + +| 原因码 | 含义 | +|--------|------| +| `fixed_usdt` / `premium_multiple` | 净盈利达标 | +| `expiry` | 到期自动全平 | +| `emergency` | 界面紧急全平 | +| `manual` | 手动平仓 | + +--- + +## 5. 策略侧重点 + +| 侧重点 | 说明 | +|--------|------| +| **吃波动,不赌横盘** | 买方期权需要标的走动;大波动利于达标出场 | +| **概率与轮次** | 波动日可能多轮;磨盘日可能零轮或一轮认亏 | +| **确定性时间规则** | 用周末跳过、到期全平,**不做**难以精准的横盘识别 | +| **费用后净利** | 出场看扣完预估平仓费后的净盈利,避免「账面赚、平完亏」 | +| **单组纪律** | 有仓不开下一组,防止重叠风险 | +| **权利金是成本预算** | 小波动拖到期亏权利金,属于策略设计内损耗,不是异常 bug | + +适合对外表述: + +> 适合有波动的时段;震荡磨盘是主要磨损源。不怕单边大波动本身,怕没波动却持续付时间价值。 + +--- + +## 6. 风险点评估 + +### 6.1 主要风险 + +| 风险 | 等级 | 说明 | 缓解(已有/建议) | +|------|------|------|------------------| +| **横盘 / 低波动** | 高 | Theta 消耗权利金;永续几乎不贡献利润 | 周末跳过;到期认亏全平;接受磨损日 | +| **方向选错** | 中高 | 卖一比价并非预测圣杯;错向时期权可能失利,永续端也可能承压 | 净盈利达标才兑现;到期强平止损时间 | +| **开平滑点与手续费** | 中 | 双边 taker + 滑点侵蚀小目标利润 | 目标默认 15U 需覆盖费用;勿把目标设过低 | +| **期权流动性** | 中 | 平仓买一薄、偏差大 → 等待 | 流动性闸门;到期/紧急可绕过 | +| **杠杆与保证金** | 中 | 永续 3×,标的急跌/急涨挤压保证金 | 控制名义、保留资金缓冲;实盘注意强平线 | +| **同到期 ATM 拥挤** | 低–中 | 临近到期价差与盘口恶化 | `min_option_hours`;到期强平 | +| **SIM≠实盘** | 高(若直接外推) | 本地撮合、深度与成交假设简化 | 小资金实盘验证后再放大 | +| **交易所限流 / 断连** | 中 | 行情中断导致延误开平 | 健康检查;部署侧监控 | + +### 6.2 明确不做什么(边界) + +- 不做卖方期权(不收权利金那套)。 +- 不做横盘「智能识别」开仓过滤。 +- 不做多组并行。 +- 当前主路径为 **SIM**;实盘需另行授权与二次确认(见商业化方案)。 + +### 6.3 损益直觉 + +- **有利波动**:期权端弹性(2 ETH 名义)常快于永续(1 ETH)的对冲损耗 → 易达净利目标。 +- **不利 / 无波动**:权利金贬值 + 费用 → 拖到到期全平,单组亏损量级约在 **权利金量级 + 双边费用**(再叠加永续期间浮亏/浮盈)。 + +--- + +## 7. 最小资金推荐 + +以下按 **默认仓位**(永续 1 ETH、期权 2 ETH 名义、杠杆 3×)估算,标的价按 **ETH ≈ 1,800–2,200 USDT** 量级。 + +### 7.1 单组占用粗算 + +| 项目 | 约略金额(USDT) | +|------|------------------| +| 永续保证金(名义÷杠杆) | ≈ 1,800–2,200 ÷ 3 ≈ **600–750** | +| 期权权利金(ATM、短期限,2 ETH) | 常见约 **15–60**(行情波动大时更高) | +| 开平手续费 / 滑点缓冲 | **20–50** | +| 逆向波动缓冲(避免过紧) | **300–800**(经验缓冲,非精确 VaR) | + +### 7.2 建议档位 + +| 档位 | 建议权益(USDT) | 适用 | +|------|------------------|------| +| **绝对下限(不推荐)** | ≥ **1,500** | 仅能勉强覆盖保证金 + 小权利金,缓冲极薄,易心态与风控双紧 | +| **最小可用** | ≥ **3,000** | 默认参数下可跑 SIM / 极小实盘验证,仍偏紧 | +| **推荐起步** | ≥ **5,000** | 留出磨损日与波动缓冲,更符合「概率样本」玩法 | +| **较舒适** | ≥ **10,000** | 参数微调、连续多日磨损时更从容 | + +> 缩放规则:若把永续改为 `0.5 ETH`、期权 `1 ETH`,资金需求大约可按比例减半,但费用占比会上升,小目标更难赚。 + +### 7.3 与出场目标的关系 + +- 默认净利目标 **15 USDT**:在费用后属于「小而频」目标,依赖波动日多次兑现。 +- 单日/单组最坏常见情形更接近 **亏掉当期权利金 + 费用**(外加永续路径损益),资金规划应按 **多次磨损日** 而非「每组必赚 15」来留余量。 + +### 7.4 SIM 默认 + +系统默认虚拟权益 `initial_equity = 10,000` USDT(策略设置可改;保存且数值变更时在无持仓下重置账本),**不代表**实盘建议入金。 + +日后币安实盘试跑建议仓位:**永续 0.1 ETH / 期权 0.2 ETH 名义**,并同比下调净利目标。 + +--- + +## 8. 关键可配参数速查 + +| 参数 | 默认 | 类别 | +|------|------|------| +| `perp_qty_eth` | 1 | 仓位 | +| `option_qty_eth` | 2 | 仓位 | +| `leverage` | 3 | 永续 | +| `fee_rate` | 0.0005 | 成本 | +| `exit_mode` | fixed_usdt | 出场 | +| `net_profit_target` | 15 | 出场 | +| `premium_exit_multiple` | 1.0 | 出场 | +| `rest_seconds` | 300 | 节奏 | +| `skip_weekends` | true | 时间 | +| `min_option_hours` | 12 | 选约 | +| `min_option_leverage` | 100 | 选约 | +| `atm_open_offset_enabled` | false | 开仓 ATM 偏差开关 | +| `max_atm_open_offset` | 3 | 开仓 ATM 偏差上限 | +| `close_bid_mark_max_pct` | 30 | 平仓流动性 | + +--- + +## 9. 修订记录 + +| 日期 | 说明 | +|------|------| +| 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 | +| 2026-07-25 | 平仓顺序改为先期权后永续(与开仓同理:薄腿优先) | +| 2026-07-26 | 到期/紧急平仓:期权价不低于内在价值,修复垃圾盘口错杀实值 | diff --git a/scripts/repair_expiry_settle.py b/scripts/repair_expiry_settle.py new file mode 100644 index 0000000..344fe5b --- /dev/null +++ b/scripts/repair_expiry_settle.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""一键修复服务器上因到期垃圾盘口导致的期权错结算(G-20260725-01)。 + +用法(本机): + $env:DEPLOY_PASS='...' + python scripts/repair_expiry_settle.py +""" + +from __future__ import annotations + +import os +import sys +import time + +HOST = os.environ.get("DEPLOY_HOST", "47.236.184.99") +USER = os.environ.get("DEPLOY_USER", "root") +PASSWORD = os.environ.get("DEPLOY_PASS", "") +ROOT = os.environ.get("DEPLOY_ROOT", "/opt/eth_hedge_sim") +DB = f"{ROOT}/backend/data/hedge.db" +GROUP = os.environ.get("REPAIR_GROUP_ID", "G-20260725-01") + + +REMOTE_PY = r''' +import sqlite3, time +DB = "%(db)s" +GROUP = "%(group)s" +FEE = 0.0005 + +con = sqlite3.connect(DB) +con.row_factory = sqlite3.Row +g = con.execute("SELECT * FROM groups WHERE group_id=?", (GROUP,)).fetchone() +if not g: + print("group missing", GROUP) + raise SystemExit(1) +if g["status"] != "closed" or g["close_reason"] != "expiry": + print("skip: not closed expiry", dict(g)) + raise SystemExit(0) + +fills = list(con.execute( + "SELECT * FROM fills WHERE group_id=? ORDER BY ts_ms", (GROUP,) +).fetchall()) +o_open = next(f for f in fills if f["leg"]=="option" and f["action"]=="open") +o_close = next(f for f in fills if f["leg"]=="option" and f["action"]=="close") +p_open = next(f for f in fills if f["leg"]=="perp" and f["action"]=="open") +p_close = next(f for f in fills if f["leg"]=="perp" and f["action"]=="close") + +strike = float(g["strike"] or 1860) +# 永续平仓为买回空头:fill≈ask*(1+f) → spot≈ask +perp_fill = float(p_close["fill_px"]) +spot = perp_fill / (1.0 + FEE) +intrinsic = max(spot - strike, 0.0) if str(g["option_side"])=="call" else max(strike - spot, 0.0) +old_base = float(o_close["base_px"] or o_close["fill_px"]) +new_base = max(old_base, intrinsic) +if abs(new_base - old_base) < 1e-9: + print("already ok", old_base, intrinsic) + raise SystemExit(0) + +qty = float(o_close["qty_eth"]) +new_fill = new_base * (1.0 - FEE) +new_notional = new_fill * qty +new_fee = new_notional * FEE +new_slip = abs(new_fill - new_base) * qty +old_cash = float(o_close["notional"]) - float(o_close["fee"]) +new_cash = new_notional - new_fee +cash_delta = new_cash - old_cash + +opt_entry = float(o_open["fill_px"]) +opt_pnl = (new_fill - opt_entry) * qty +perp_entry = float(p_open["fill_px"]) +perp_side = str(p_open["side"]) +if perp_side == "long": + perp_pnl = (float(p_close["fill_px"]) - perp_entry) * float(p_open["qty_eth"]) +else: + perp_pnl = (perp_entry - float(p_close["fill_px"])) * float(p_open["qty_eth"]) +net = perp_pnl + opt_pnl - float(p_close["fee"]) - new_fee + +# fees on group: replace option close fee contribution +old_opt_fee = float(o_close["fee"]) +old_opt_slip = float(o_close["slip"] or 0) +fees = float(g["fees"] or 0) - old_opt_fee + new_fee +slip = float(g["slip_cost"] or 0) - old_opt_slip + new_slip + +now = int(time.time() * 1000) +meta = con.execute("SELECT equity, available FROM ledger_meta WHERE id=1").fetchone() +eq = float(meta["equity"]) + cash_delta +av = float(meta["available"]) + cash_delta + +con.execute("BEGIN") +con.execute( + "UPDATE fills SET base_px=?, fill_px=?, fee=?, slip=?, notional=? WHERE group_id=? AND leg='option' AND action='close'", + (new_base, new_fill, new_fee, new_slip, new_notional, GROUP), +) +con.execute( + "UPDATE groups SET realized_pnl=?, fees=?, slip_cost=?, note=? WHERE group_id=?", + (net, fees, slip, f"repaired_intrinsic:{intrinsic:.4f}", GROUP), +) +con.execute( + "UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1", + (eq, av, now), +) +con.execute( + "INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)", + (GROUP, "repair_option_intrinsic", cash_delta, eq, + f"repair {GROUP}: option close {old_base:.4f}->{new_base:.4f} intrinsic={intrinsic:.4f}", now), +) +# also fix close_option entry amount if present +row = con.execute( + "SELECT id, amount FROM ledger_entries WHERE group_id=? AND kind='close_option' ORDER BY id DESC LIMIT 1", + (GROUP,), +).fetchone() +if row: + con.execute( + "UPDATE ledger_entries SET amount=?, note=? WHERE id=?", + (new_cash, f"close option expiry (repaired intrinsic {intrinsic:.4f})", row["id"]), + ) +con.commit() +print({ + "group": GROUP, + "spot": spot, + "intrinsic": intrinsic, + "old_base": old_base, + "new_base": new_base, + "cash_delta": cash_delta, + "new_equity": eq, + "new_realized_pnl": net, +}) +''' % {"db": DB, "group": GROUP} + + +def main() -> int: + if not PASSWORD: + print("Set DEPLOY_PASS", file=sys.stderr) + return 2 + import paramiko + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(HOST, username=USER, password=PASSWORD, timeout=30) + cmd = "python3 - <<'PY'\n" + REMOTE_PY + "\nPY" + _, stdout, stderr = client.exec_command(cmd) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + print(out) + if err: + print(err, file=sys.stderr) + client.close() + return code + + +if __name__ == "__main__": + raise SystemExit(main())