diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 62e6fe5..ebef8c2 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -121,6 +121,19 @@ OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_ALLOW_MARKET_CLOSE=false +# ============================================================================= +# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md) +# ============================================================================= +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_LIVE_ORDER=false +HEDGE_PLAN_OPEN_ORDER=options_first +HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true +HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false +HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true + # ============================================================================= # 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) # ============================================================================= diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 25196be..1a484e6 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6754,6 +6754,8 @@ def render_main_page(page="trade", embed_mode=None): exchange_display=EXCHANGE_DISPLAY_NAME, options_enabled=OKX_OPTIONS_ENABLED, options_nav_visible=True, + hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), + hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, risk_status=risk_status, @@ -9015,6 +9017,7 @@ _MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_pr _OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") _OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") _OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") +_HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.js") @app.route("/static/ai_review_render.js") @@ -9059,6 +9062,13 @@ def static_options_settings_js(): return send_file(_OPTIONS_SETTINGS_JS, mimetype="application/javascript; charset=utf-8") +@app.route("/static/hedge_plan.js") +def static_hedge_plan_js(): + if not os.path.isfile(_HEDGE_PLAN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_HEDGE_PLAN_JS, mimetype="application/javascript; charset=utf-8") + + @app.route("/export/review_md/") @login_required def export_review_md(rid): @@ -9537,6 +9547,10 @@ from lib.options.options_register import install_options_trading install_options_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +from lib.hedge_plan.hedge_plan_register import install_hedge_plan + +install_hedge_plan(app, _REPO_ROOT, app_module=sys.modules[__name__]) + _purge_key_monitors_if_full_margin() diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md new file mode 100644 index 0000000..4cac6af --- /dev/null +++ b/docs/对冲计划开发方案.md @@ -0,0 +1,661 @@ +# OKX 对冲计划 — 开发方案 + +> 状态:**方案冻结**(实现前对照本页;改需求先改本文). +> 范围:**仅 `crypto_monitor_okx` 实例**;中控不做对冲开平. +> 相关策略说明:[期权对冲方案分析.md](./期权对冲方案分析.md) + +--- + +## 1. 目标与命名 + +在 OKX 实例增加独立模块 **「对冲计划」**,把「行情选腿 → 情景测算 →(条件满足时)自动开仓 → 规则退出 → 独立复盘统计」串成一套可状态跟踪的计划. + +| 产品名 | 英文键 | 含义 | +|--------|--------|------| +| **永期对冲** | `perp_options` | 永续(子账户) + 买方期权(主账户) | +| **期期对冲** | `options_options` | 主账户内两条买方期权腿 | + +页面/导航展示用中文名;API/DB 用英文键. + +**不做(本方案外):** + +- 卖方期权、组合单原子成交 +- 中控代下单 +- 跨所对冲 +- 替代现有关键位/策略/期权页(可共存,但同标的限制见 §9) + +--- + +## 2. 计仓模式门禁 + +沿用 `[docs/position-sizing-mode.md](./position-sizing-mode.md)`: + +| `POSITION_SIZING_MODE` | 永期对冲 | 期期对冲 | +|------------------------|----------|----------| +| `risk`(以损定仓 / **非全仓**) | **仅测算**(左右行情 + 情景表);**禁止开仓/启动计划** | **允许开仓**(测算 + 启动计划) | +| `full_margin`(全仓杠杆) | **允许开仓** | **允许开仓** | + +说明: + +- **永期**依赖永续全仓名义与保证金节奏,故开仓仅限全仓. +- **期期**不占永续保证金,非全仓也允许开期权腿;非全仓下 UI 隐藏/禁用「启动永期」,仍可做永期**只读测算**. +- 切模式后若存在 `active` 永期计划,禁止切到 `risk`,或强制要求先结束计划(实现时二选一并写死校验). + +额外硬门: + +- 须 `OKX_OPTIONS_ENABLED=true` 且期权 API 可用. +- 实际开仓须实例允许实盘(`LIVE_ORDER` 等与永续/期权现有开关一致),并对冲计划自有开关见 §10. + +--- + +## 3. 永期对冲 — 仓位与选期权 + +### 3.1 永续开仓量(全仓) + +与现网全仓逻辑一致,对冲计划固定用于 **BTC / ETH**: + +``` +可用保证金 = 合约账户可用 USDT +占用保证金 = 可用 × FULL_MARGIN_BUFFER_RATIO(默认 0.98) +杠杆 = BTC_LEVERAGE / ETH 对应杠杆(默认 10x) +名义 ≈ 占用保证金 × 杠杆 +张数 = amount_to_precision(名义 / 价格 / 合约面值) +``` + +页面左侧展示:**建议张数、名义、止损亏损额、止盈盈利额**(用户填止盈价/止损价后按该仓位即时算). + +用户流程: + +1. 选标的 ETH/BTC、方向(多/空). +2. 系统按全仓 ×0.98×10x 算出永续张数与到止盈/止损的 U 盈亏. +3. **再据此挑选右侧期权**(保费、张数、行权价),使「止损时保险腿」与「止盈保费损耗」可接受. +4. 通过情景表确认后启动计划. + +### 3.2 期权腿选取 + +- 行情自动拉 OKX 期权链(复用 `build_option_chain`). +- **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**. +- 权利金默认按 **卖一 ask** 估算;开仓限价买入. + +### 3.3 左右布局 + +``` +左:永续列表行情(标记/买卖一) + 方向/建议张数/开仓价/止盈/止损 +右:期权列表(可「选用」一条腿) +下:情景测算 → [保存草稿] [启动计划] +``` + +--- + +## 4. 期期对冲 — 仓位与选腿 + +### 4.1 报价与选腿 + +- **T 型报价链**(复用期权页 T 型样式/数据结构). +- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). +- 预算受 `OKX_OPTIONS_TRADE_BUDGET_USDC` 等既有约束;可拆预算到两腿. + +### 4.2 目标价 + +用户填 **预判价格 S\*** (「价格能到的位置」): + +- 系统标明在 S\* 时哪条腿为 **盈利方**、哪条为 **亏损方**. +- 到达规则见 §5.2. + +### 4.3 左右布局 + +``` +左:指数价 + 到期日 + 预算 + 目标价 S* +右:T 型链,依次选用两腿 +下:情景测算 → [保存草稿] [启动计划] +``` + +非全仓模式下期期布局同上(无永续区). + +--- + +## 5. 退出规则(冻结) + +### 5.1 永期对冲 + +| 事件 | 永续 | 期权 | 计划是否结束 | 设计意图 | +|------|------|------|--------------|----------| +| **永续止盈触发** | 交易所 TP 平仓 | **不强制平**(保险腿可自生自灭/人工) | **算结束** | 对冲计划以永续兑现目标收口 | +| **永续止损触发** | 交易所 SL 平仓 | **必须强制平仓** | **算结束** | 保护机制 | +| 期权单独到期 | — | 结算 | 若计划已因止盈结束则只更新腿快照,不再改计划合计 | | +| 人工结束计划 | 可选平永续 | 按选项 | **算结束** | | + +**计划结束时盈亏口径(写入 `realized_pnl_*`,推送与统计共用):** + +| 结束原因 | 公式(≈U,1:1) | 字段落库 | +|----------|--------------|----------| +| **止盈** `perp_tp` | **永续止盈已实现盈利 − 期权已付权利金** | `realized_pnl_perp` = 止盈盈利;`realized_pnl_options` = **−premium_total**(按权利金全额计成本,不论期权是否仍持仓);`realized_pnl_total` = 上两式之和 | +| **止损** `perp_sl` | **期权平仓盈利 − 永续止损亏损额** | `realized_pnl_options` = 期权强制平后已实现;`realized_pnl_perp` = 永续止损已实现(为负或记亏损额);`realized_pnl_total` = 期权盈利 − \|永续亏损\|(即有符号相加) | + +说明: + +- 止盈时期权**物理上可不平**,但 **计划账** 已按「保费打掉」收口,后续期权 thrift/到期盈亏 **不再回写计划合计**(可在腿上另记备注/浮盈,不进 `realized_pnl_total`). +- 止损时期权必须先强平再结账,用真实平仓盈亏,不是只扣权利金. + +永续侧 TP/SL:沿用实例 **交易所条件单**,监控识别成交后触发计划结束逻辑 + 微信推送. + +### 5.2 期期对冲 + +| 事件 | 盈利方 | 亏损方 | 计划是否结束 | +|------|--------|--------|--------------| +| **标的价到达用户目标价 S\*** | **自动平仓** | **不平**,持有至到期 | 平盈利腿后计划可标 `closing`;**全部腿终态后结束**(亏损腿到期后结账) | +| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损**(通常 ≈ −全部权利金,或到期结算净值 < 0 的合计) | +| 到期时组合合计仍盈利 | — | 到期结算 | **算结束**;按实际结算盈亏入账 | +| 未达 S\* 至到期 | 两腿均到期 | | 同上,按结算合计结束 | + +判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损). + +盈利方判定规则仍按前文(触达 S\* 时按浮盈较大一侧平仓;皆亏则等到期). + +--- + +### 5.3 企业微信推送(起止必发) + +| 时机 | 是否必发 | 内容要点 | +|------|----------|----------| +| **计划开始**(开仓成功 → `active`) | **必发** | 类型(永期/期期)、标的方向、关键价位、张数/保费、计划 id | +| **计划结束** | **必发** | 结束原因、`realized_pnl_total`、分项(永续/期权)、是否止盈/止损/到期亏损 | +| 半腿失败 / 强平失败 | 必发告警 | 便于人工介入 | +| 目标价平掉盈利腿(期期中间态) | 建议发 | 注明亏损腿仍持有 | + +结束推送触发点与「算结束」一致:永期止盈、永期止损、期期到期收口(含无盈利总亏)、人工结束等. + +--- + +## 6. 情景测算(开仓前必显) + +### 6.1 永期 + +| 情景 | 含义 | +|------|------| +| 止盈 | 永续到 TP 的盈利 − 期权保费(期权按不强制平时的损耗估算) | +| 宽止损 | 永续到 SL 的亏损 + 期权平仓估值(强制平,用 mark/买一估算) | +| 到期横盘 | 永续≈0 + 期权权利金全损 | +| 价格扫描 | index ± 若干档合计 | + +核心输出:**宽止损合计亏损**(人工评估是否开仓). + +### 6.2 期期 + +| 情景 | 含义 | +|------|------| +| 到达 S\* | 盈利腿兑现估值 − 已付总保费中亏损腿残留 | +| 到期横盘 | 双腿权利金近似全损 | +| 到期大涨/大跌 | 结算内在价值 | + +--- + +## 7. 数据来源(行情自动) + +| 侧 | 来源 | +|----|------| +| 永续行情/规格 | OKX 子账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 | +| 期权链 | `build_option_chain` / `/api/options/chain`(本实例直连,无需中控代理) | +| 指数价 | 期权 `index_px`,左右对齐 | + +报价刷新:页面手动刷新 + 计划编辑态可选 10~30s 自动刷新. + +--- + +## 8. 自动开仓编排 + +### 8.1 永期(仅全仓) + +建议默认顺序:**先期权、后永续**(期权失败成本低;永续失败则提示处理刚开的期权). + +``` +校验 full_margin + LIVE + 无冲突计划 +→ 期权限价买入(卖一) +→ 永续全仓张数市价开仓 + 挂交易所 TP/SL +→ 写 hedge_plans / legs,状态 active +``` + +部分失败补偿(最小集): + +| 情况 | 动作 | +|------|------| +| 期权成、永续败 | 告警;建议自动平期权(可配置)或转人工 | +| 永续成、期权败 | 告警;可选撤永续或重试期权;计划标 `partial` | + +### 8.2 期期(全仓与非全仓均可开) + +``` +校验 LIVE + 期权资金 +→ 腿1 限价买 +→ 腿2 限价买 +→ active;记录 target_price S* +``` + +两腿间勿留长时间单腿敞口;第二腿失败则标 `partial` 并告警. + +--- + +## 9. 状态机与冲突 + +``` +draft → opening → active → closing → closed + ↘ partial / failed +draft → cancelled +``` + +冲突规则: + +- **同标的同时至多 1 条 active 永期计划**(全局可 `MAX_ACTIVE_HEDGE_PLANS`). +- 永期 active 时与全仓「单仓」一致:**不与额外永续仓并存**(启动前校验无其它持仓,或本计划即为该仓). +- 期权页对手动平「计划绑定 inst」应提示归属对冲计划. + +--- + +## 10. 历史记录、统计与复盘(独立) + +**入口:** OKX 顶栏 **「对冲计划」** 页内三个 Tab(不单开顶栏项): + +| Tab | 路由建议 | 内容 | +|-----|----------|------| +| **计划** | `/hedge-plan` | 新建 / 草稿 / 进行中 | +| **历史** | `/hedge-plan?tab=history` | 已结束与取消的计划列表 + 详情复盘 | +| **统计** | `/hedge-plan?tab=stats` | 独立统计看板 | + +**不得**并入普通「交易记录与复盘」`/records`、全站「统计分析」`/stats`、策略交易记录. +腿可可选关联 `options_trades` / 监控 id,但 **计划合计盈亏与胜率只读本模块表**. + +币种展示约定:永续腿 USDT、期权腿 USDC;合计列标注 **「≈U(1:1)」**,不做实时汇率换算. + +--- + +### 10.1 数据落库 + +库文件:OKX 实例 `crypto.db`(与其它表同库). + +#### `hedge_plans`(一条计划) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_type` | TEXT | `perp_options` 永期 / `options_options` 期期 | +| `status` | TEXT | draft / opening / active / closing / closed / partial / failed / cancelled | +| `underlying` | TEXT | BTC / ETH | +| `direction` | TEXT | 永期:long/short;期期可空 | +| `entry_mark` | REAL | 开仓参考价(标记/指数快照) | +| `tp` | REAL | 永期止盈价;可空 | +| `sl` | REAL | 永期止损价;可空 | +| `target_price` | REAL | 期期目标价 S\*;可空 | +| `sizing_mode_at_open` | TEXT | 开仓时 `risk`/`full_margin` 快照 | +| `perp_size` | REAL | 永期张数/币量快照;期期空 | +| `margin` | REAL | 占用保证金快照 | +| `leverage` | REAL | 杠杆快照 | +| `premium_total` | REAL | 期权已付权利金合计(USDC) | +| `realized_pnl_perp` | REAL | 永续已实现(USDT);止盈为正,止损为负 | +| `realized_pnl_options` | REAL | 期权账:止盈场景记 **−权利金**;止损场景记 **强平真实盈亏** | +| `realized_pnl_total` | REAL | 见 §5.1 / §5.2 公式;统计与微信共用此值 | +| `stats_bucket` | TEXT | 可选冗余:`tp` / `sl` / `oo_expiry_loss` / `oo_target` / `other` 便于统计筛选 | +| `close_reason` | TEXT | 见下表枚举 | +| `wechat_start_sent` | INTEGER | 开仓推送是否已发 | +| `wechat_end_sent` | INTEGER | 结束推送是否已发 | +| `note` | TEXT | 人工复盘短评 | +| `created_at` | TEXT | | +| `opened_at` | TEXT | 首次腿成交时间 | +| `closed_at` | TEXT | **计划结束时间**(止盈/止损/到期收口等) | +| `preview_json` | TEXT | 开仓前情景测算快照(可选) | + +**`close_reason` 枚举** + +| 值 | 含义 | 是否算计划结束 | 合计口径 | +|----|------|----------------|----------| +| `perp_tp` | 永续止盈 | **是**(立刻 closed) | **止盈盈利 − 权利金** | +| `perp_sl` | 永续止损 + 期权强制平 | **是** | **期权盈利 − 永续亏损** | +| `target_win_leg` | 期期已平盈利腿(中间态可暂不 closed) | 腿未齐前可不结束 | 待亏损腿到期后定合计 | +| `oo_expiry_loss` | 期期到期且合计无盈利 | **是** | **总亏损**(settled ≤ 0,常 ≈ −保费) | +| `oo_expiry_win` | 期期到期合计仍盈利 | **是** | 实际到期合计 | +| `expiry` | 其它到期收口 | **是** | 实际结算 | +| `manual` | 人工结束 | **是** | 按当时已实现 | +| `partial_fail` | 半腿失败收尾 | **是** | 按补偿结果 | +| `cancelled` | 未真正开仓取消 | 是(无盈亏) | 0 | + +说明:止盈结束时期权腿可标 `hold_to_expiry`/`orphaned_after_tp`,**计划已 closed**,后续期权盈亏不回写 `realized_pnl_total`. + +#### `hedge_plan_legs`(一条腿) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_id` | INTEGER FK | | +| `leg_role` | TEXT | `perp` / `option_hedge` / `option_a` / `option_b` | +| `symbol` 或 `inst_id` | TEXT | 永续符号或期权合约 id | +| `opt_type` | TEXT | C/P;永续空 | +| `strike` | REAL | 期权行权价 | +| `side` | TEXT | long/short 或 buy | +| `size` | REAL | 张数或币量 | +| `avg_open` | REAL | 开仓均价/权利金单价 | +| `premium` | REAL | 该腿已付权利金(期权) | +| `status` | TEXT | open / closed / hold_to_expiry | +| `linked_monitor_id` | INTEGER | 可选,永续监控 | +| `options_trade_id` | INTEGER | 可选,期权成交表 | +| `realized_pnl` | REAL | 该腿已实现 | +| `close_reason` | TEXT | 腿级原因 | +| `opened_at` / `closed_at` | TEXT | | + +--- + +### 10.2 历史列表(列定义) + +筛选:**类型**(全部/永期/期期)、**状态**、**标的**、**日期**(按 `opened_at` 或 `closed_at`). + +| 列 | 来源 | 展示 | +|----|------|------| +| ID | id | `#12` | +| 类型 | plan_type | 永期对冲 / 期期对冲 | +| 标的 | underlying + direction | 如 `ETH 多` / `ETH 双买` | +| 状态 | status | 中文标签 | +| 开仓时间 | opened_at | | +| 结束时间 | closed_at | 进行中显示 — | +| 平仓原因 | close_reason | 中文(止盈离场/止损联动平/目标价平盈利腿/到期…) | +| 保费 | premium_total | `x.xx USDC` | +| 合计盈亏 | realized_pnl_total | 着色 +/- ,单位 ≈U | +| 腿摘要 | legs | 如 `永续✓ · Put持仓` / `Call已平 · Put到期` | +| 操作 | | 详情 | + +行操作:**详情**(主)、可选「补写短评」. + +--- + +### 10.3 计划详情 / 复盘页(字段) + +从历史点进去的详情页 = **主复盘面**,分块如下. + +#### A. 计划摘要 + +| 项 | 字段 | +|----|------| +| 类型 / 标的 / 方向 | plan_type, underlying, direction | +| 状态 / 平仓原因 | status, close_reason | +| 时间线 | created_at → opened_at → closed_at | +| 开仓时计仓 | sizing_mode_at_open, margin, leverage, perp_size | +| 关键价位 | entry_mark, tp, sl(永期), target_price(期期) | +| 盈亏 | realized_pnl_perp / realized_pnl_options / realized_pnl_total | +| 开仓情景快照 | preview_json 折叠展示(止盈合计/宽止损合计等) | + +#### B. 腿明细表 + +| 列 | 说明 | +|----|------| +| 角色 | 永续 / 保险期权 / 期期腿A/B | +| 合约 | symbol / inst_id | +| 数量 | size | +| 开仓价/保费 | avg_open, premium | +| 状态 | open / closed / 持有至到期 | +| 盈亏 | realized_pnl | +| 平仓原因 | close_reason | +| 关联 | 链到期权成交或监控(有则显示) | + +#### C. 复盘短评(必做入口) + +| 项 | 说明 | +|----|------| +| `note` | 多行文本,可空;保存 `PATCH /api/hedge-plan//note` | +| 提示文案 | 建议写:开仓理由、结果是否符合情景测算、下次调整 | + +**不做(本期):** 复盘截图上传、填入「交易记录与复盘」表单、纳入 AI 日/周复盘. +**可后置:** 以计划摘要生成 AI 点评(独立按钮,不写进 trade_records). + +#### D. 终态示例文案(便于复盘理解) + +| 场景 | 详情页状态说明 | 合计 | +|------|----------------|------| +| 永期止盈 | 「计划已结束(止盈);期权腿可不强平,账上已扣全部权利金」 | 止盈盈利 − 权利金 | +| 永期止损 + 期权已强平 | 「计划已结束(止损保护:期权已联动平仓)」 | 期权盈利 − 永续亏损 | +| 期期到期无盈利 | 「计划已结束(到期无盈利)」 | 总亏损(计入统计) | +| 期期达 S\* 后亏损腿仍持有 | 「盈利腿已平;待亏损腿到期后结账」 | 暂不入 closed 统计,或单独「收尾中」 | + +--- + +### 10.4 统计页(独立看板) + +**筛选:** 日期区间、类型(全部/永期/期期)、标的、结束桶(`tp`/`sl`/`oo_expiry_loss`/…). + +**聚合规则:** 仅 `status=closed`;胜场 = `realized_pnl_total > 0`. + +#### 永期口径(冻结) + +| 统计桶 | `close_reason` | 单笔盈亏公式 | 汇总 | +|--------|----------------|--------------|------| +| **止盈统计** | `perp_tp` | **止盈盈利 − 期权权利金** | sum / 笔数 / 胜率 | +| **止损统计** | `perp_sl` | **期权盈利 − 永续亏损** | sum / 笔数 /「保护后净亏」均值 | + +实现校验示例: + +``` +# 止盈 +realized_pnl_total = pnl_perp_tp - premium_total + +# 止损(亏损额取绝对值) +realized_pnl_total = pnl_option_close - abs(pnl_perp_sl) +# 等价有符号: pnl_option_close + pnl_perp_sl(后者为负) +``` + +#### 期期口径(冻结) + +| 统计桶 | 条件 | 单笔盈亏 | +|--------|------|----------| +| **到期无盈利** | 到期收口且合计 ≤ 0 | **总亏损**写入 `realized_pnl_total`(负值),计入区间净亏与「到期亏损」汇总 | +| 目标价路径 | 盈利腿已平 + 亏损腿到期后 | 两腿 realized 之和 | +| 到期仍盈利 | 合计 > 0 | 实际结算合计 | + +#### 总览卡片 + +| 指标 | 计算 | +|------|------| +| 计划笔数 | count(closed) | +| 胜率 | 胜场 / 笔数 | +| 区间净盈亏 | sum(realized_pnl_total) | +| 止盈桶净盈亏 | sum where stats_bucket=tp | +| 止损桶净盈亏 | sum where stats_bucket=sl | +| 期期到期亏损合计 | sum where oo_expiry_loss(绝对值或带符号合计) | +| 总保费支出 | sum(premium_total) | +| 平均持仓时长 | avg(closed_at − opened_at) | + +#### 分类型卡片 + +永期、期期各一套:笔数、胜率、净盈亏、平均保费;永期再拆 **止盈桶 / 止损桶**. + +#### 退出结构 + +| 指标 | 过滤 | +|------|------| +| 止盈结束笔数 | `perp_tp` | +| 止损结束笔数 | `perp_sl` | +| 期期到期无盈利笔数 | `oo_expiry_loss` | +| 目标价路径完结 | 含 `target_win_leg` 后收尾 | +| 半腿/失败 | `partial_fail` / failed | + +#### 简易表(可选) + +最近 N 条已结束计划迷你列表,点击跳详情. + +**导出(P5 可选):** CSV. + +--- + +### 10.5 与现有页面关系 + +| 现有页 | 关系 | +|--------|------| +| 交易记录与复盘 | **不写入**;永续腿若系统仍落 `trade_records`,可标记来源「对冲计划#id」,但人工复盘以对冲详情为准 | +| 统计分析 | **不合并**对冲净盈亏到全站数字(避免重复或口径混乱) | +| 期权页成交/持仓 | 腿 `options_trade_id` 可跳转对照;期权页仍可看单腿 | +| 中控 | V1 不聚合;V2 可选只读摘要 | + +--- + +### 10.6 API(历史 / 统计 / 复盘) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/hedge-plan/list` | 进行中+草稿;`status` 过滤 | +| GET | `/api/hedge-plan/history` | 已结束列表;类型/日期/标的 | +| GET | `/api/hedge-plan/` | 详情 = 计划 + legs + note + preview | +| PATCH | `/api/hedge-plan//note` | 保存复盘短评 | +| GET | `/api/hedge-plan/stats` | query:`from`,`to`,`plan_type`,`underlying` → 总览+分类型+退出结构 | + +--- + +## 11. 监控线程 + +在 `crypto_okx` 同进程内新增 **`hedge_plan_monitor_loop`**(可与 `options_monitor_loop` 并列): + +| 职责 | | +|------|--| +| 永期 | 侦测永续 TP/SL → 按 §5.1 结束计划并结账;SL 时强制平期权 | +| 期期 | 侦测价触 S\* → 平盈利腿;到期无盈利 → 结束并记总亏损 | +| 微信 | **开始必推、结束必推**(§5.3);半腿/强平失败告警 | +| 幂等 | `wechat_start_sent` / `wechat_end_sent` 防重复推送 | + +--- + +## 12. 配置项与前端 env 页 + +对冲相关开关 **一律在 OKX 实例「env 配置」页维护**,不要求 SSH 改 `.env`. +实现对齐现有白名单模式(`lib/env/env_ui_manifest.py` 的「期权账户」分组). + +### 12.1 前端分组 + +在 env 配置页新增独立卡片,标题 **「对冲计划」**: + +- **仅 OKX** 展示(Binance/Gate 不出现) +- 放在 **「期权账户」下方**(依赖期权模块) +- 卡片说明文案建议: + - 永期开仓还要求「交易执行」里计仓模式为 **全仓**(`POSITION_SIZING_MODE=full_margin`) + - 真实下单还与「交易所与实盘」→ `LIVE_TRADING_ENABLED`、本卡片 `HEDGE_PLAN_LIVE_ORDER` 同时开启 + - `HEDGE_PLAN_ENABLED` 关闭时隐藏顶栏「对冲计划」并拒绝启动计划 + +### 12.2 本分组字段(前端可配) + +| 变量 | 前端标签 | 默认 | 控件 | 热更新 | 说明 | +|------|----------|------|------|--------|------| +| `HEDGE_PLAN_ENABLED` | 启用对冲计划 | false | bool | 热更优先 | 总开关:导航 + API | +| `HEDGE_PLAN_LIVE_ORDER` | 允许对冲真实下单 | false | bool | 热更 | 关则只测算/草稿 | +| `HEDGE_PLAN_OPEN_ORDER` | 永期开仓顺序 | options_first | select:`options_first`/`perp_first` | 热更 | 默认先期权后永续 | +| `HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** | +| `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** | +| `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 | +| `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 | +| `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 | +| `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 | + +**不放入本分组、沿用已有卡片:** + +| 已有位置 | 键 | 对冲用途 | +|----------|-----|----------| +| 交易执行 | `POSITION_SIZING_MODE` | 全仓才允许永期开仓 | +| 交易执行 | `FULL_MARGIN_BUFFER_RATIO` | 默认 0.98 | +| 交易执行 | `BTC_LEVERAGE` | BTC/ETH 档(默认 10) | +| 交易所与实盘 | `LIVE_TRADING_ENABLED` | 总实盘门 | +| 期权账户 | `OKX_OPTIONS_*` | 期权 API、预算、标的 | + +### 12.3 `.env.example` 片段(实现时写入 OKX) + +```env +# --- 对冲计划(仅 OKX;前端 env「对冲计划」) --- +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_LIVE_ORDER=false +HEDGE_PLAN_OPEN_ORDER=options_first +HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true +HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false +HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true +``` + +### 12.4 编码触点 + +| 文件 | 改动 | +|------|------| +| `lib/env/env_ui_manifest.py` | `_HEDGE_PLAN_SECTION`,`exchanges={"okx"}`,接入 `ui_sections_for_exchange` | +| `crypto_monitor_okx/.env.example` | 增加上表键与分组注释 | +| `lib/env/env_schema.py` | 纳入 `HOT_RELOAD_EXACT`(及必要时重启列表) | +| `docs/env配置说明.md` | 上线时补「对冲计划」小节 | + +推荐分期:**P0 先做 env 白名单 + 开关可读**,页面按 `HEDGE_PLAN_ENABLED` 显隐导航. + +--- + +## 13. 前端与路由(实现要点) + +- 导航:OKX 实例顶栏 **「对冲计划」**(display prefs 可加 `show_nav_hedge_plan`). +- 路由建议:`/hedge-plan`(主)、`/hedge-plan/history`、`/hedge-plan/stats`(或单页 Tab). +- 模板/静态:`lib/options` 旁新增 `lib/hedge_plan/`(或 `lib/hedge/`),复用: + - 期权链 CSS/T 型渲染思路(`options_panel.js` / `options-strike-table--t`) + - `compute_full_margin_sizing` / `position_sizing_lib` + - 永续下单 + TPSL、期权限价开平 API + +API 草图: + +- `GET /api/hedge-plan/market` — 永续行情 + 全仓试算 +- `GET /api/hedge-plan/options-chain` — 期权链 +- `POST /api/hedge-plan/preview` — 情景测算 +- `GET /api/hedge-plan/list` / `history` / `` / `stats` +- `PATCH /api/hedge-plan//note` — 复盘短评 +- `POST /api/hedge-plan` — 保存草稿/启动 +- `POST /api/hedge-plan//cancel|close` + +非全仓请求启动永期 → `400` 明确:「永期对冲开仓仅全仓模式可用」. + +--- + +## 14. 分期实施 + +| 阶段 | 交付 | 验收 | +|------|------|------| +| **P0** | env「对冲计划」分组 + 页框 + 行情 + 永期列表/期期 T + 情景测算 + 门禁 | env 可改开关;非全仓无法启动永期;全仓可看建议张数 | +| **P1** | 表结构 + 草稿/列表 | DB 可查 | +| **P2** | 永期自动开仓(全仓) | 双腿成交入计划 | +| **P3** | 监控:止损强制平期权;止盈不平期权 | 用例测 TP/SL 分支 | +| **P4** | 期期开仓 + 目标价只平盈利腿 | 达价仅平一侧 | +| **P5** | 历史列表列 + 详情复盘(note) + 统计看板 + 微信 | 与 /records /stats 隔离;止损/止盈文案可区分 | + +建议顺序严格;P3 规则错误会误平保险腿,上线前用 dry-run / paper flags. + +--- + +## 15. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| [期权对冲方案分析.md](./期权对冲方案分析.md) | 策略观念;本模块是其「计划化 + 自动执行」实现 | +| [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) | P0 交付与口径校验 | +| [期权方案.md](./期权方案.md) / [期权用法.md](./期权用法.md) | 期权 API、仅买方、限价规则必须遵守 | +| [position-sizing-mode.md](./position-sizing-mode.md) | 全仓公式与缓冲 0.98 | + +本模块上线后,可在《期权对冲方案分析》末尾增加「系统对冲计划」链接指向本文. + +--- + +## 16. 已拍板规则摘要(校验清单) + +- [x] 放在 **OKX 实例**,名 **永期对冲 / 期期对冲** +- [x] 永期止盈 → **期权不强制平**,但 **计划算结束**;统计 = **止盈盈利 − 权利金** +- [x] 永期止损 → **期权必须强制平**,计划结束;统计 = **期权盈利 − 永续亏损** +- [x] 期期按目标价 → **只平盈利方**;到期无盈利 → **算结束并统计总亏损** +- [x] 对冲计划 **开始与结束均企业微信推送** +- [x] **独立历史 + 独立统计 + 计划详情复盘**(短评 note;不进普通交易复盘) +- [x] 永期开仓 **仅全仓**;非全仓永期只算账 +- [x] 非全仓允许 **期期**开仓;全仓永期+期期均可 +- [x] 永期张数:**ETH/BTC 10x 全仓 × 0.98**,先算盈亏再选期权 +- [x] 行情自动;永期期权 **列表式**;期期 **T 型** +- [x] 对冲开关在前端 **env 配置 →「对冲计划」** 维护(仅 OKX);计仓/杠杆/期权 API 复用已有分组 + +--- + +## 17. 免责与边界 + +- 双账户非原子成交,存在半腿风险. +- 止盈结束账上按权利金全额计成本;期权若仍持仓,后续行情 **不再改计划合计**,属设计意图. +- 止盈后期权可能继续损耗直至到期,与「计划已结束」并存. +- 买方权利金可能全部损失;期期到期无盈利记总亏损,属设计意图. +- 本文不构成投资建议. diff --git a/docs/对冲计划策略与P0校验.md b/docs/对冲计划策略与P0校验.md new file mode 100644 index 0000000..2fcb309 --- /dev/null +++ b/docs/对冲计划策略与P0校验.md @@ -0,0 +1,109 @@ +# 对冲计划策略说明与 P0 校验 + +> 配套实现方案:[对冲计划开发方案.md](./对冲计划开发方案.md) +> 本文记录 **策略口径** 与 **P0 代码校验**,确认可继续 P1+. + +--- + +## 1. 策略摘要 + +| 类型 | 账户 | 作用 | +|------|------|------| +| **永期对冲** | 永续子账户 + 期权主账户买方 | 全仓做方向,期权买保险 | +| **期期对冲** | 仅期权主账户双买方 | 目标价兑现盈利腿,亏损腿到期 | + +### 永期结束与统计 + +| 事件 | 期权处理 | 计划 | 统计公式 | +|------|----------|------|----------| +| 止盈 | 不强平 | **结束** | **止盈盈利 − 权利金** | +| 止损 | **强制平** | **结束** | **期权盈利 − 永续亏损**(有符号相加) | + +### 期期结束与统计 + +| 事件 | 处理 | 统计 | +|------|------|------| +| 达目标价 | 只平盈利腿 | 待亏损腿到期后结账 | +| 到期无盈利 | **结束** | **总亏损** | + +起止均企业微信推送(P2+ 监控落地后再接). + +### 门禁 + +- 永期开仓:**仅** `POSITION_SIZING_MODE=full_margin` +- 非全仓:永期可测算不可开;期期 P0 起可测算(开仓后续版本) +- env:OKX「对冲计划」分组;须 `HEDGE_PLAN_ENABLED=true` 才显示导航 + +--- + +## 2. P0 已交付 + +| 项 | 状态 | +|----|------| +| env 白名单「对冲计划」9 字段 | 有 | +| OKX `.env.example` 字段 | 有 | +| `/hedge-plan` 页(永期列表 / 期期 T) | 有 | +| `/api/hedge-plan/market|options-chain|preview|gates` | 有 | +| 全仓建议张数(可用×0.98×10x) | 有 | +| 情景测算止盈/止损口径 | 有 | +| 启动开仓 | **禁用**(明示 P0) | +| 历史/统计/监控/微信 | **未做**(P1–P5) | + +关键文件: + +- `lib/hedge_plan/hedge_plan_calc_lib.py` +- `lib/hedge_plan/hedge_plan_register.py` +- `lib/hedge_plan/templates/hedge_plan_panel.html` +- `lib/common/static/hedge_plan.js` +- `tests/test_hedge_plan_calc.py` + +--- + +## 3. 校验清单(可行性) + +### 计算口径 + +``` +止盈: perp_pnl(tp) - premium +止损: option_expiry_pnl(spot=sl) + perp_pnl(sl) +期期到期无盈利: expiry_flat_total <= 0 → 记总亏损 +``` + +单测覆盖:`tests/test_hedge_plan_calc.py`. + +### 门禁 + +- `risk` + 永期 → `can_start=false`,文案含「全仓」 +- `HEDGE_PLAN_ENABLED=false` → 导航隐藏(服务端 template 读 env) + +### 行情 + +- 永续:`exchange.fetch_ticker` + `get_available_trading_usdt` + `compute_full_margin_sizing` +- 期权:`build_option_chain`(与期权页同源) + +### 已知边界(非 P0 bug) + +1. 建议张数未强制 `amount_to_precision`(开仓阶段再对齐交易所精度). +2. 止盈账扣全额权利金,与期权是否仍持仓无关(策略如此). +3. 热更新 `HEDGE_PLAN_ENABLED` 后需刷新页面才显隐导航. +4. 嵌入壳 Tab 已注册 `hedge_plan`;中控能力勾选若需显式「对冲」可后续加. + +--- + +## 4. 服务器启用步骤 + +```bash +# env 配置页 → 对冲计划 → HEDGE_PLAN_ENABLED=true → 保存 +# 或服务器: +cd /opt/crypto_monitor/crypto_monitor_okx +# 确保 .env 含 HEDGE_PLAN_* 字段后 +pm2 restart crypto_okx --update-env +``` + +验收: + +1. 顶栏出现「对冲计划」 +2. 永期可见建议张数(全仓时) +3. 点「计算」得到止盈/止损合计 +4. 「启动计划」禁用 +5. env 页可见「对冲计划」分组 diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js new file mode 100644 index 0000000..89ec163 --- /dev/null +++ b/lib/common/static/hedge_plan.js @@ -0,0 +1,509 @@ +/** + * OKX 对冲计划 P0:行情 + 永期列表 / 期期 T + 情景测算 + 门禁. + */ +(function () { + const root = document.getElementById("hedge-plan-root"); + if (!root) return; + + const state = { + mode: "perp_options", + underlying: root.getAttribute("data-default-underly") || "ETH", + chain: null, + selected: null, + legA: null, + legB: null, + market: null, + }; + + function $(id) { + return document.getElementById(id); + } + + async function apiJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok) throw new Error(data.msg || res.statusText || "请求失败"); + return data; + } + + function fmt(v, d) { + if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; + return Number(v).toFixed(d == null ? 2 : d); + } + + function optTypeForDirection(dir) { + return dir === "short" ? "C" : "P"; + } + + function syncModeUI() { + document.querySelectorAll(".hp-mode-btn").forEach(function (b) { + b.classList.toggle("active", b.getAttribute("data-mode") === state.mode); + }); + const po = $("hp-po-layout"); + const oo = $("hp-oo-layout"); + if (po) po.classList.toggle("hidden", state.mode !== "perp_options"); + if (oo) oo.classList.toggle("hidden", state.mode !== "options_options"); + } + + function setGateLine(gates) { + const el = $("hp-gate-line"); + if (!el) return; + if (!gates) { + el.textContent = ""; + return; + } + const parts = [ + "计仓:" + (gates.is_full_margin ? "全仓" : "非全仓"), + "测算:" + (gates.can_preview ? "可" : "否"), + "开仓:" + (gates.can_start ? "可" : "否"), + ]; + if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; ")); + el.textContent = parts.join(" · "); + const start = $("hp-start-btn"); + if (start) start.disabled = !gates.can_start; + } + + async function loadGates() { + try { + const d = await apiJson("/api/hedge-plan/gates?plan_type=" + encodeURIComponent(state.mode)); + setGateLine(d); + } catch (e) { + setGateLine({ can_preview: false, can_start: false, reasons: [e.message], is_full_margin: false }); + } + } + + async function loadMarket() { + const dir = ($("hp-direction") && $("hp-direction").value) || "long"; + const d = await apiJson( + "/api/hedge-plan/market?base=" + + encodeURIComponent(state.underlying) + + "&direction=" + + encodeURIComponent(dir) + ); + state.market = d; + setGateLine(d.gates); + const q = $("hp-perp-quote"); + if (q) { + q.innerHTML = + "标记 " + + fmt(d.mark, 2) + + " · 最新 " + + fmt(d.last, 2) + + " · 卖一 " + + fmt(d.ask, 2) + + " · 买一 " + + fmt(d.bid, 2) + + "
面值 " + + fmt(d.contract_size, 4) + + " · 可用 " + + fmt(d.available_usdt, 2) + + " U"; + } + const sz = $("hp-sizing-line"); + if (sz) { + if (d.full_margin_sizing) { + const s = d.full_margin_sizing; + sz.textContent = + "全仓建议:保证金 " + + fmt(s.margin_capital, 2) + + "U × " + + s.leverage + + "x → 名义 " + + fmt(s.notional_value, 2) + + "U · 建议约 " + + fmt(d.suggest_contracts, 4) + + " 张"; + } else { + sz.textContent = "非全仓或不具备保证金数据时仅手动填张数;永期开仓需全仓."; + } + } + const entry = $("hp-entry"); + if (entry && d.entry_ref && !entry.value) entry.value = d.entry_ref; + const contracts = $("hp-contracts"); + if (contracts && d.suggest_contracts != null && !contracts.value) { + contracts.value = d.suggest_contracts; + } + const label = $("hp-opt-type-label"); + if (label) label.textContent = d.suggested_opt_type === "C" ? "Call" : "Put"; + } + + function fillExpSelect(sel, chain) { + if (!sel) return; + const prev = sel.value; + sel.innerHTML = ''; + (chain.expiries || []).forEach(function (e) { + const opt = document.createElement("option"); + opt.value = String(e.exp_time); + const dt = new Date(Number(e.exp_time)); + opt.textContent = dt.toLocaleString(); + sel.appendChild(opt); + }); + if (prev) sel.value = prev; + if (!sel.value && chain.expiries && chain.expiries[0]) { + sel.value = String(chain.expiries[0].exp_time); + } + } + + async function loadChain() { + const d = await apiJson( + "/api/hedge-plan/options-chain?underlying=" + encodeURIComponent(state.underlying) + ); + state.chain = d; + const idx = $("hp-index-line"); + if (idx) idx.textContent = "指数 " + fmt(d.index_px, 2) + " · " + (d.inst_family || ""); + const ooIdx = $("hp-oo-index"); + if (ooIdx) ooIdx.textContent = "指数 " + fmt(d.index_px, 2); + fillExpSelect($("hp-exp-select"), d); + fillExpSelect($("hp-oo-exp-select"), d); + renderListStrikes(); + renderTStrikes(); + if (d.index_px && $("hp-target") && !$("hp-target").value) { + $("hp-target").value = d.index_px; + } + } + + function currentExp(selectId) { + const sel = $(selectId); + const expMs = sel && sel.value; + if (!expMs || !state.chain) return null; + return (state.chain.expiries || []).find(function (e) { + return String(e.exp_time) === String(expMs); + }); + } + + function renderListStrikes() { + const tbody = $("hp-strike-tbody"); + if (!tbody) return; + const dir = ($("hp-direction") && $("hp-direction").value) || "long"; + const want = optTypeForDirection(dir); + const exp = currentExp("hp-exp-select"); + tbody.innerHTML = ""; + if (!exp) { + tbody.innerHTML = '请选择到期日'; + return; + } + const list = (exp.contracts || []).filter(function (c) { + return String(c.opt_type || "").toUpperCase() === want; + }); + if (!list.length) { + tbody.innerHTML = '无匹配合约'; + return; + } + list.forEach(function (c) { + const tr = document.createElement("tr"); + tr.innerHTML = + "" + + c.strike + + "" + + c.opt_type + + "" + + fmt(c.ask, 4) + + "" + + fmt(c.bid, 4) + + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-pick").forEach(function (btn) { + btn.addEventListener("click", function () { + const inst = btn.getAttribute("data-inst"); + const c = list.find(function (x) { + return x.inst_id === inst; + }); + if (!c) return; + state.selected = c; + const el = $("hp-sel-inst"); + if (el) el.textContent = c.inst_id; + updatePremiumLine(); + }); + }); + } + + function updatePremiumLine() { + const line = $("hp-premium-line"); + if (!line || !state.selected) { + if (line) line.textContent = ""; + return; + } + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + const ct = Number(state.selected.ct_mult || 0.01); + const ask = Number(state.selected.ask || 0); + const prem = ask * sheets * ct; + line.textContent = "预估权利金 ≈ " + fmt(prem, 4) + " USDC"; + } + + function buildStraddleRows(contracts) { + const map = {}; + (contracts || []).forEach(function (c) { + const key = String(c.strike); + if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; + const o = (c.opt_type || "").toUpperCase(); + if (o === "C") map[key].call = c; + else if (o === "P") map[key].put = c; + }); + return Object.keys(map) + .map(function (k) { + return map[k]; + }) + .sort(function (a, b) { + return Number(a.strike) - Number(b.strike); + }); + } + + function renderTStrikes() { + const tbody = $("hp-oo-tbody"); + if (!tbody) return; + const exp = currentExp("hp-oo-exp-select"); + tbody.innerHTML = ""; + if (!exp) { + tbody.innerHTML = '请选择到期日'; + return; + } + const rows = buildStraddleRows(exp.contracts); + rows.forEach(function (row) { + const tr = document.createElement("tr"); + const callAsk = row.call ? fmt(row.call.ask, 4) : "—"; + const putAsk = row.put ? fmt(row.put.ask, 4) : "—"; + tr.innerHTML = + "" + + callAsk + + '' + + (row.call + ? '' + : "—") + + '' + + row.strike + + "" + + putAsk + + '' + + (row.put + ? '' + : "—") + + ""; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) { + btn.addEventListener("click", function () { + const inst = btn.getAttribute("data-inst"); + const exp2 = currentExp("hp-oo-exp-select"); + const c = (exp2.contracts || []).find(function (x) { + return x.inst_id === inst; + }); + if (!c) return; + if (!state.legA) state.legA = c; + else if (!state.legB || state.legB.inst_id === state.legA.inst_id) state.legB = c; + else { + state.legA = c; + state.legB = null; + } + renderOoLegs(); + }); + }); + } + + function renderOoLegs() { + const el = $("hp-oo-legs"); + if (!el) return; + function one(tag, c) { + if (!c) return tag + ": —"; + return ( + tag + + ": " + + c.opt_type + + " K" + + c.strike + + " ask=" + + fmt(c.ask, 4) + + " (" + + c.inst_id + + ")" + ); + } + el.innerHTML = one("腿A", state.legA) + "
" + one("腿B", state.legB); + } + + function legPayload(c, sheets) { + return { + opt_type: c.opt_type, + strike: c.strike, + sheets: sheets, + ct_mult: c.ct_mult || 0.01, + ask: c.ask, + inst_id: c.inst_id, + }; + } + + async function runPreview() { + const tbody = $("hp-result-tbody"); + const summary = $("hp-summary"); + try { + let body; + if (state.mode === "options_options") { + if (!state.legA || !state.legB) throw new Error("请选用两条期权腿"); + const target = Number(($("hp-target") && $("hp-target").value) || 0); + if (!target) throw new Error("请填写目标价"); + body = { + plan_type: "options_options", + target_price: target, + index_px: (state.chain && state.chain.index_px) || target, + leg_a: legPayload(state.legA, 1), + leg_b: legPayload(state.legB, 1), + }; + } else { + if (!state.selected) throw new Error("请选用期权腿"); + const entry = Number(($("hp-entry") && $("hp-entry").value) || 0); + const tp = Number(($("hp-tp") && $("hp-tp").value) || 0); + const sl = Number(($("hp-sl") && $("hp-sl").value) || 0); + const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0); + const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1); + if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数"); + body = { + plan_type: "perp_options", + direction: ($("hp-direction") && $("hp-direction").value) || "long", + entry: entry, + tp: tp, + sl: sl, + contracts: contracts, + contract_size: (state.market && state.market.contract_size) || 0.01, + opt_type: state.selected.opt_type, + strike: state.selected.strike, + sheets: sheets, + ct_mult: state.selected.ct_mult || 0.01, + ask: state.selected.ask, + index_px: state.chain && state.chain.index_px, + }; + } + const d = await apiJson("/api/hedge-plan/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + setGateLine(d.gates); + const s = d.summary || {}; + if (summary) { + if (d.plan_type === "perp_options") { + summary.innerHTML = + "止盈合计 " + + fmt(s.tp_total) + + " · 止损合计 " + + fmt(s.sl_total) + + " · 保费 " + + fmt(s.premium_paid) + + (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); + } else { + summary.innerHTML = + "目标价合计 " + + fmt(s.at_target_total) + + " · 到期现价 " + + fmt(s.expiry_flat_total) + + " · 保费 " + + fmt(s.premium_paid) + + (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); + } + } + tbody.innerHTML = ""; + (d.scenarios || []).forEach(function (sc) { + const tr = document.createElement("tr"); + let mid; + if (sc.perp_pnl != null) { + mid = "永续 " + fmt(sc.perp_pnl); + } else { + mid = "A " + fmt(sc.leg_a_pnl) + " / B " + fmt(sc.leg_b_pnl); + } + const optCol = sc.options_pnl != null ? fmt(sc.options_pnl) : "—"; + tr.innerHTML = + "" + + (sc.label || sc.id) + + "" + + fmt(sc.spot) + + "" + + mid + + "" + + optCol + + "" + + fmt(sc.total) + + "" + + (sc.note || "") + + ""; + tbody.appendChild(tr); + }); + } catch (e) { + if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; + if (summary) summary.textContent = ""; + } + } + + function bind() { + document.querySelectorAll(".hp-mode-btn").forEach(function (b) { + b.addEventListener("click", function () { + state.mode = b.getAttribute("data-mode") || "perp_options"; + syncModeUI(); + loadGates(); + }); + }); + document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) { + b.addEventListener("click", function () { + state.underlying = b.getAttribute("data-uly") || "ETH"; + document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (x) { + x.classList.toggle("active", x.getAttribute("data-uly") === state.underlying); + }); + state.selected = null; + state.legA = null; + state.legB = null; + void refreshAll(); + }); + }); + const dir = $("hp-direction"); + if (dir) { + dir.addEventListener("change", function () { + void loadMarket().then(function () { + renderListStrikes(); + }); + }); + } + if ($("hp-refresh")) $("hp-refresh").addEventListener("click", function () { + void refreshAll(); + }); + if ($("hp-load-chain")) $("hp-load-chain").addEventListener("click", function () { + void loadChain(); + }); + if ($("hp-oo-load-chain")) $("hp-oo-load-chain").addEventListener("click", function () { + void loadChain(); + }); + if ($("hp-exp-select")) + $("hp-exp-select").addEventListener("change", renderListStrikes); + if ($("hp-oo-exp-select")) + $("hp-oo-exp-select").addEventListener("change", renderTStrikes); + if ($("hp-sheets")) $("hp-sheets").addEventListener("input", updatePremiumLine); + if ($("hp-preview-btn")) $("hp-preview-btn").addEventListener("click", function () { + void runPreview(); + }); + } + + async function refreshAll() { + await loadGates(); + try { + await loadMarket(); + } catch (e) { + const q = $("hp-perp-quote"); + if (q) q.textContent = e.message || String(e); + } + try { + await loadChain(); + } catch (e) { + const tbody = $("hp-strike-tbody"); + if (tbody) tbody.innerHTML = '' + (e.message || e) + ""; + } + } + + syncModeUI(); + bind(); + void refreshAll(); +})(); diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js index 849aafe..f8d9d23 100644 --- a/lib/common/static/instance_settings_prefs.js +++ b/lib/common/static/instance_settings_prefs.js @@ -26,6 +26,8 @@ records: "show_nav_records", stats: "show_nav_stats", options: "show_nav_options", + "hedge-plan": "show_nav_hedge_plan", + hedge_plan: "show_nav_hedge_plan", risk_policy: "show_nav_risk_policy", env_config: "show_nav_env_config", }; @@ -48,6 +50,8 @@ records: "show_nav_records", stats: "show_nav_stats", options: "show_nav_options", + "hedge-plan": "show_nav_hedge_plan", + hedge_plan: "show_nav_hedge_plan", risk_policy: "show_nav_risk_policy", env_config: "show_nav_env_config", }; diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 32705b2..596067c 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -82,6 +82,15 @@ HOT_RELOAD_EXACT = frozenset({ "APP_PASSWORD", "APP_AUTH_DISABLED", "WECHAT_WEBHOOK", + "HEDGE_PLAN_ENABLED", + "HEDGE_PLAN_LIVE_ORDER", + "HEDGE_PLAN_OPEN_ORDER", + "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", + "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", + "HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", + "MAX_ACTIVE_HEDGE_PLANS", + "HEDGE_PLAN_MONITOR_POLL_SECONDS", + "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", }) SENSITIVE_EXACT = frozenset({ diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 4821a5b..93f557f 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -125,6 +125,22 @@ _OPTIONS_SECTION: dict[str, Any] = { ], } +_HEDGE_PLAN_SECTION: dict[str, Any] = { + "title": "对冲计划", + "exchanges": frozenset({"okx"}), + "fields": [ + ("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"), + ("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘开关与;P0 仅测算"), + ("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), + ("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), + ("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), + ("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"), + ("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"), + ("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"), + ("HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION", "半腿失败时自动平期权", ""), + ], +} + # 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页) _RUNTIME_ENV_DEFAULTS: dict[str, str] = { @@ -191,6 +207,8 @@ def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]: sections.extend(_SHARED_SECTIONS) if ex in _OPTIONS_SECTION.get("exchanges", frozenset()): sections.append(_OPTIONS_SECTION) + if ex in _HEDGE_PLAN_SECTION.get("exchanges", frozenset()): + sections.append(_HEDGE_PLAN_SECTION) return sections diff --git a/lib/hedge_plan/__init__.py b/lib/hedge_plan/__init__.py new file mode 100644 index 0000000..300c2bd --- /dev/null +++ b/lib/hedge_plan/__init__.py @@ -0,0 +1 @@ +# hedge_plan package diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py new file mode 100644 index 0000000..1507482 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -0,0 +1,320 @@ +"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO).""" +from __future__ import annotations + +from typing import Any, Optional + + +def _f(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def perp_coin_amount(*, contracts: float, contract_size: float) -> float: + return float(contracts) * float(contract_size or 1.0) + + +def perp_pnl( + *, + direction: str, + entry: float, + exit_px: float, + contracts: float, + contract_size: float, +) -> float: + coins = perp_coin_amount(contracts=contracts, contract_size=contract_size) + d = (direction or "long").strip().lower() + if d == "short": + return (float(entry) - float(exit_px)) * coins + return (float(exit_px) - float(entry)) * coins + + +def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float: + """卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult.""" + return float(ask) * float(sheets) * float(ct_mult or 0.01) + + +def option_expiry_pnl( + *, + opt_type: str, + strike: float, + spot: float, + sheets: float, + ct_mult: float, + premium_paid: float, +) -> float: + o = (opt_type or "").strip().upper() + intrinsic_per_coin = 0.0 + if o in ("C", "CALL"): + intrinsic_per_coin = max(0.0, float(spot) - float(strike)) + elif o in ("P", "PUT"): + intrinsic_per_coin = max(0.0, float(strike) - float(spot)) + else: + return -float(premium_paid) + value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01) + return value - float(premium_paid) + + +def suggest_contracts_from_notional( + *, + notional: float, + entry: float, + contract_size: float, +) -> float: + if entry <= 0 or contract_size <= 0 or notional <= 0: + return 0.0 + return float(notional) / (float(entry) * float(contract_size)) + + +def build_perp_options_preview( + *, + direction: str, + entry: float, + tp: float, + sl: float, + contracts: float, + contract_size: float, + opt_type: str, + strike: float, + sheets: float, + ct_mult: float, + premium_paid: float, + index_px: Optional[float] = None, +) -> dict[str, Any]: + """ + 永期情景. + 止盈账:永续止盈盈利 - 权利金. + 止损账:期权到期内在(按 SL 价) - 永续止损亏损额. + """ + d = (direction or "long").strip().lower() + pnl_tp_perp = perp_pnl( + direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size + ) + pnl_sl_perp = perp_pnl( + direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size + ) + # 止盈统计口径 + tp_total = float(pnl_tp_perp) - float(premium_paid) + # 止损:期权按 SL 价结算内在 - |永续亏损| + opt_at_sl = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=sl, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float( + pnl_sl_perp + ) + # 有符号相加更稳:期权盈亏 + 永续盈亏 + sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp) + + spot = float(index_px) if index_px is not None else float(entry) + opt_flat = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=spot, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + flat_total = 0.0 + float(opt_flat) + + opt_at_tp = option_expiry_pnl( + opt_type=opt_type, + strike=strike, + spot=tp, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=premium_paid, + ) + + return { + "plan_type": "perp_options", + "direction": d, + "contracts": contracts, + "coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size), + "premium_paid": round(float(premium_paid), 6), + "scenarios": [ + { + "id": "tp", + "label": "止盈(计划结束口径)", + "spot": tp, + "perp_pnl": round(pnl_tp_perp, 4), + "options_pnl": round(-float(premium_paid), 4), + "total": round(tp_total, 4), + "note": "止盈盈利 − 权利金;期权可不强平", + }, + { + "id": "sl", + "label": "止损(计划结束口径)", + "spot": sl, + "perp_pnl": round(pnl_sl_perp, 4), + "options_pnl": round(opt_at_sl, 4), + "total": round(sl_total_signed, 4), + "note": "期权盈利 − 永续亏损(有符号相加);期权须强平", + }, + { + "id": "flat", + "label": "到期·现价附近", + "spot": spot, + "perp_pnl": 0.0, + "options_pnl": round(opt_flat, 4), + "total": round(flat_total, 4), + "note": "示意:永续未动,期权按到期内在", + }, + { + "id": "expiry_tp", + "label": "到期·止盈价", + "spot": tp, + "perp_pnl": round(pnl_tp_perp, 4), + "options_pnl": round(opt_at_tp, 4), + "total": round(pnl_tp_perp + opt_at_tp, 4), + "note": "若期权拿到 TP 价到期(参考)", + }, + { + "id": "expiry_sl", + "label": "到期·止损价", + "spot": sl, + "perp_pnl": round(pnl_sl_perp, 4), + "options_pnl": round(opt_at_sl, 4), + "total": round(pnl_sl_perp + opt_at_sl, 4), + "note": "与止损口径相近(期权用内在)", + }, + ], + "summary": { + "tp_total": round(tp_total, 4), + "sl_total": round(sl_total_signed, 4), + "premium_paid": round(float(premium_paid), 4), + "hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp), + }, + } + + +def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: + loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0 + if loss <= 1e-12: + return None + if float(opt_pnl) <= 0: + return 0.0 + return round(float(opt_pnl) / loss * 100.0, 2) + + +def build_options_options_preview( + *, + target_price: float, + index_px: float, + leg_a: dict[str, Any], + leg_b: dict[str, Any], +) -> dict[str, Any]: + """期期情景:目标价 / 到期现价 / 到期两边.""" + + def _leg_pnl(leg: dict[str, Any], spot: float) -> float: + return option_expiry_pnl( + opt_type=str(leg.get("opt_type") or ""), + strike=float(leg["strike"]), + spot=spot, + sheets=float(leg.get("sheets") or 0), + ct_mult=float(leg.get("ct_mult") or 0.01), + premium_paid=float(leg.get("premium_paid") or 0), + ) + + prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) + a_t = _leg_pnl(leg_a, target_price) + b_t = _leg_pnl(leg_b, target_price) + at_target = a_t + b_t + win_leg = "a" if a_t >= b_t else "b" + a_flat = _leg_pnl(leg_a, index_px) + b_flat = _leg_pnl(leg_b, index_px) + flat_total = a_flat + b_flat + expiry_loss = flat_total if flat_total <= 0 else flat_total + + return { + "plan_type": "options_options", + "premium_paid": round(prem, 6), + "target_price": target_price, + "winner_at_target": win_leg, + "scenarios": [ + { + "id": "target", + "label": "到达目标价", + "spot": target_price, + "leg_a_pnl": round(a_t, 4), + "leg_b_pnl": round(b_t, 4), + "total": round(at_target, 4), + "note": f"盈利方≈腿{win_leg.upper()}(可平);亏损方默认到期", + }, + { + "id": "expiry_flat", + "label": "到期·现价(无突破)", + "spot": index_px, + "leg_a_pnl": round(a_flat, 4), + "leg_b_pnl": round(b_flat, 4), + "total": round(flat_total, 4), + "note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值", + }, + { + "id": "max_premium_loss", + "label": "最大保费损耗", + "spot": None, + "leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4), + "leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4), + "total": round(-prem, 4), + "note": "双腿权利金全部损失", + }, + ], + "summary": { + "at_target_total": round(at_target, 4), + "expiry_flat_total": round(expiry_loss, 4), + "premium_paid": round(prem, 4), + "expiry_is_loss": flat_total <= 0, + }, + } + + +def gate_status( + *, + hedge_enabled: bool, + sizing_mode: str, + plan_type: str, + options_enabled: bool, +) -> dict[str, Any]: + from lib.trade.position_sizing_lib import is_full_margin_mode + + full = is_full_margin_mode(sizing_mode) + pt = (plan_type or "").strip().lower() + can_preview = True + can_start = False + reasons: list[str] = [] + if not hedge_enabled: + can_start = False + reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)") + if not options_enabled: + can_preview = False + can_start = False + reasons.append("期权模块未启用") + if pt == "perp_options": + if not full: + can_start = False + reasons.append("永期开仓仅全仓模式可用(当前可测算)") + elif hedge_enabled and options_enabled: + can_start = False + reasons.append("P0 仅测算,真实开仓将在后续版本开放") + elif pt == "options_options": + if hedge_enabled and options_enabled: + can_start = False + reasons.append("P0 仅测算,真实开仓将在后续版本开放") + return { + "hedge_enabled": hedge_enabled, + "options_enabled": options_enabled, + "sizing_mode": sizing_mode, + "is_full_margin": full, + "plan_type": pt, + "can_preview": can_preview, + "can_start": can_start, + "reasons": reasons, + } diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py new file mode 100644 index 0000000..9fe91b9 --- /dev/null +++ b/lib/hedge_plan/hedge_plan_register.py @@ -0,0 +1,327 @@ +"""OKX 对冲计划:P0 测算页与 API 注册.""" +from __future__ import annotations + +import os +from typing import Any + +from flask import Flask, jsonify, request +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.hedge_plan.hedge_plan_calc_lib import ( + build_options_options_preview, + build_perp_options_preview, + gate_status, + option_premium_total, + suggest_contracts_from_notional, +) +from lib.trade.position_sizing_lib import ( + compute_full_margin_sizing, + load_position_sizing_mode, +) + + +def _env_bool(key: str, default: bool = False) -> bool: + raw = (os.getenv(key) or "").strip().lower() + if not raw: + return default + return raw in ("1", "true", "yes", "on") + + +def attach_hedge_plan_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "hedge_plan", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def install_hedge_plan(app: Flask, repo_root: str, app_module: Any) -> None: + attach_hedge_plan_templates(app, repo_root) + cfg = _build_cfg(app_module) + app.extensions["hedge_plan_cfg"] = cfg + register_hedge_plan_routes(app, cfg) + + +def _build_cfg(app_module: Any) -> dict[str, Any]: + from lib.exchange.okx_options_lib import build_option_chain + + return { + "get_db": app_module.get_db, + "login_required": app_module.login_required, + "render_main_page": app_module.render_main_page, + "exchange": getattr(app_module, "exchange", None), + "exchange_options": getattr(app_module, "exchange_options", None), + "get_available_trading_usdt": getattr(app_module, "get_available_trading_usdt", None), + "get_contract_size": getattr(app_module, "get_contract_size", None), + "normalize_exchange_symbol": getattr(app_module, "normalize_exchange_symbol", None), + "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), + "build_option_chain": build_option_chain, + "btc_leverage": int(getattr(app_module, "BTC_LEVERAGE", 10) or 10), + "alt_leverage": int(getattr(app_module, "ALT_LEVERAGE", 5) or 5), + "full_margin_buffer": float(getattr(app_module, "FULL_MARGIN_BUFFER_RATIO", 0.98) or 0.98), + "funds_decimals": int(getattr(app_module, "FUNDS_DECIMALS", 2) or 2), + "options_enabled": _env_bool("OKX_OPTIONS_ENABLED", False), + "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(), + "chain_max_dte": float(os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS") or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS") or "14"), + } + + +def _hedge_enabled() -> bool: + return _env_bool("HEDGE_PLAN_ENABLED", False) + + +def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None: + lr = cfg["login_required"] + + @app.route("/hedge-plan") + @lr + def page_hedge_plan(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("hedge_plan") + if redir is not None: + return redir + return cfg["render_main_page"]("hedge_plan") + + @app.route("/api/hedge-plan/gates") + @lr + def api_hedge_gates(): + plan_type = (request.args.get("plan_type") or "perp_options").strip() + return jsonify( + { + "ok": True, + **gate_status( + hedge_enabled=_hedge_enabled(), + sizing_mode=load_position_sizing_mode(), + plan_type=plan_type, + options_enabled=bool(cfg.get("options_enabled")), + ), + } + ) + + @app.route("/api/hedge-plan/market") + @lr + def api_hedge_market(): + base = (request.args.get("base") or cfg.get("default_underly") or "ETH").strip().upper() + if base not in ("BTC", "ETH"): + return jsonify({"ok": False, "msg": "对冲计划仅支持 BTC/ETH"}), 400 + direction = (request.args.get("direction") or "long").strip().lower() + if direction not in ("long", "short"): + direction = "long" + data, err = _fetch_perp_market(cfg, base) + if err: + return jsonify({"ok": False, "msg": err}), 400 + sizing_mode = load_position_sizing_mode() + gates = gate_status( + hedge_enabled=_hedge_enabled(), + sizing_mode=sizing_mode, + plan_type="perp_options", + options_enabled=bool(cfg.get("options_enabled")), + ) + out = { + "ok": True, + "base": base, + "direction": direction, + "suggested_opt_type": "P" if direction == "long" else "C", + **data, + "gates": gates, + "sizing_mode": sizing_mode, + } + return jsonify(out) + + @app.route("/api/hedge-plan/options-chain") + @lr + def api_hedge_options_chain(): + if not cfg.get("options_enabled"): + return jsonify({"ok": False, "msg": "期权模块未启用"}), 400 + ex = cfg.get("exchange_options") + if ex is None: + return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400 + u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper() + try: + chain = cfg["build_option_chain"]( + ex, + u, + max_dte_days=float(cfg.get("chain_max_dte") or 14), + itm_only=False, + itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), + ) + except Exception as e: + return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500 + return jsonify({"ok": True, **chain, "chain_max_dte_days": cfg.get("chain_max_dte")}) + + @app.route("/api/hedge-plan/preview", methods=["POST"]) + @lr + def api_hedge_preview(): + body = request.get_json(silent=True) or {} + plan_type = (body.get("plan_type") or "perp_options").strip().lower() + gates = gate_status( + hedge_enabled=_hedge_enabled(), + sizing_mode=load_position_sizing_mode(), + plan_type=plan_type, + options_enabled=bool(cfg.get("options_enabled")), + ) + if not gates.get("can_preview"): + return jsonify({"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可测算"]), "gates": gates}), 400 + try: + if plan_type == "options_options": + data = _preview_oo(body) + else: + data = _preview_po(body) + except ValueError as e: + return jsonify({"ok": False, "msg": str(e)}), 400 + except Exception as e: + return jsonify({"ok": False, "msg": f"测算失败: {e}"}), 500 + return jsonify({"ok": True, "gates": gates, **data}) + + +def _preview_po(body: dict[str, Any]) -> dict[str, Any]: + direction = str(body.get("direction") or "long").lower() + entry = float(body["entry"]) + tp = float(body["tp"]) + sl = float(body["sl"]) + contracts = float(body["contracts"]) + contract_size = float(body.get("contract_size") or 0.01) + opt_type = str(body.get("opt_type") or ("P" if direction == "long" else "C")) + strike = float(body["strike"]) + sheets = float(body.get("sheets") or 1) + ct_mult = float(body.get("ct_mult") or 0.01) + ask = body.get("ask") + premium = body.get("premium_paid") + if premium is None: + if ask is None: + raise ValueError("缺少权利金或卖一价") + premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult) + index_px = body.get("index_px") + return build_perp_options_preview( + direction=direction, + entry=entry, + tp=tp, + sl=sl, + contracts=contracts, + contract_size=contract_size, + opt_type=opt_type, + strike=strike, + sheets=sheets, + ct_mult=ct_mult, + premium_paid=float(premium), + index_px=float(index_px) if index_px is not None else None, + ) + + +def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: + target = float(body["target_price"]) + index_px = float(body.get("index_px") or target) + leg_a = body.get("leg_a") or {} + leg_b = body.get("leg_b") or {} + for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): + if not leg.get("strike"): + raise ValueError(f"缺少 {name} 行权价") + if leg.get("premium_paid") is None and leg.get("ask") is not None: + leg["premium_paid"] = option_premium_total( + ask=float(leg["ask"]), + sheets=float(leg.get("sheets") or 1), + ct_mult=float(leg.get("ct_mult") or 0.01), + ) + if leg.get("premium_paid") is None: + raise ValueError(f"缺少 {name} 权利金") + return build_options_options_preview( + target_price=target, + index_px=index_px, + leg_a=leg_a, + leg_b=leg_b, + ) + + +def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], str | None]: + ex = cfg.get("exchange") + if ex is None: + return {}, "永续交易所未初始化" + ensure = cfg.get("ensure_markets_loaded") + if callable(ensure): + try: + ensure() + except Exception as e: + return {}, f"加载市场失败: {e}" + norm = cfg.get("normalize_exchange_symbol") + sym = f"{base}/USDT:USDT" + if callable(norm): + try: + sym = norm(f"{base}/USDT") + except Exception: + sym = f"{base}/USDT:USDT" + mark = bid = ask = last = None + try: + t = ex.fetch_ticker(sym) + last = _sf(t.get("last")) + mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None + if mark is None: + mark = _sf(t.get("mark")) or last + bid = _sf(t.get("bid")) + ask = _sf(t.get("ask")) + except Exception as e: + return {}, f"拉永续行情失败: {e}" + + cs = 0.01 + get_cs = cfg.get("get_contract_size") + if callable(get_cs): + try: + cs = float(get_cs(sym) or 0.01) + except Exception: + cs = 0.01 + + available = None + get_av = cfg.get("get_available_trading_usdt") + if callable(get_av): + try: + available = get_av() + except Exception: + available = None + + entry = float(mark or last or 0) + sizing = None + suggest_contracts = None + if available is not None and entry > 0: + sizing, _serr = compute_full_margin_sizing( + symbol=sym, + available_usdt=float(available), + capital_base=float(available), + buffer_ratio=float(cfg.get("full_margin_buffer") or 0.98), + btc_leverage=int(cfg.get("btc_leverage") or 10), + alt_leverage=int(cfg.get("alt_leverage") or 5), + funds_decimals=int(cfg.get("funds_decimals") or 2), + ) + if sizing: + suggest_contracts = suggest_contracts_from_notional( + notional=float(sizing["notional_value"]), + entry=entry, + contract_size=cs, + ) + + return { + "exchange_symbol": sym, + "mark": mark, + "last": last, + "bid": bid, + "ask": ask, + "contract_size": cs, + "available_usdt": available, + "full_margin_sizing": sizing, + "suggest_contracts": round(suggest_contracts, 6) if suggest_contracts is not None else None, + "entry_ref": entry or None, + }, None + + +def _sf(v: Any) -> float | None: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html new file mode 100644 index 0000000..ec80121 --- /dev/null +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -0,0 +1,144 @@ +
+ {% if not hedge_plan_enabled %} +
对冲计划未启用:请在 env配置 → 对冲计划 打开 HEDGE_PLAN_ENABLED(可热更).
+ {% endif %} + {% if not options_enabled %} +
期权模块未启用,无法拉期权链.请先配置期权账户.
+ {% endif %} + +
+
+

对冲计划 P0 测算

+
+ + + +
+
+

+
+ +
+
+

永续(列表行情)

+
+ + + +
+
加载中…
+
+ + + + +
+

+
+
+

期权(列表) · Put

+
+ + +
+
+
+ + + + + + + + + + + + + +
行权价类型卖一/张买一操作
请刷新期权链
+
+
+ + + +
+
+
+ + + +
+
+

情景测算

+
+ + +
+
+
+
+ + + + + + + + + + + + + + +
情景现货价永续/腿盈亏期权盈亏合计≈U说明
填写参数后点计算
+
+
+
+ diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index 273652b..e5ad58b 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -15,6 +15,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { "show_nav_risk_policy": True, "show_nav_env_config": True, "show_nav_options": True, + "show_nav_hedge_plan": True, "show_settings_transfer": True, "show_settings_export": True, "show_settings_password": True, @@ -30,6 +31,7 @@ DISPLAY_LABELS: dict[str, str] = { "show_nav_risk_policy": "风控说明", "show_nav_env_config": "env配置", "show_nav_options": "期权", + "show_nav_hedge_plan": "对冲计划", "show_settings_transfer": "资金划转", "show_settings_export": "数据导出", "show_settings_password": "账户密码修改", @@ -45,6 +47,7 @@ NAV_TAB_ALLOWED: dict[str, str] = { "risk_policy": "show_nav_risk_policy", "env_config": "show_nav_env_config", "options": "show_nav_options", + "hedge_plan": "show_nav_hedge_plan", } @@ -104,6 +107,7 @@ def display_meta_for_ui() -> list[dict[str, Any]]: "show_nav_risk_policy", "show_nav_env_config", "show_nav_options", + "show_nav_hedge_plan", ] settings_keys = [ "show_settings_transfer", diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index 01b2f0c..2577194 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -16,6 +16,7 @@ EMBED_TABS: tuple[str, ...] = ( "strategy", "strategy_records", "options", + "hedge_plan", "records", "stats", "risk_policy", @@ -32,6 +33,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = { "/strategy/roll": "strategy", "/strategy/records": "strategy_records", "/options": "options", + "/hedge-plan": "hedge_plan", "/records": "records", "/stats": "stats", "/risk_policy": "risk_policy", diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 05c1955..34a8d78 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -293,6 +293,8 @@ {% include 'strategy_records_page.html' %} {% elif page == 'options' %} {% include 'options_panel.html' %} + {% elif page == 'hedge_plan' %} + {% include 'hedge_plan_panel.html' %} {% endif %} diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index d83cc75..bade13c 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -46,6 +46,9 @@ {% if options_nav_visible and display.show_nav_options %} 期权 {% endif %} + {% if hedge_plan_nav_visible and display.show_nav_hedge_plan %} + 对冲计划 + {% endif %} {% if display.show_nav_risk_policy %} 风控说明 {% endif %} diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 7da4f15..d8355b1 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -133,6 +133,9 @@ {% if options_nav_visible and display.show_nav_options %} 期权 {% endif %} + {% if hedge_plan_nav_visible and display.show_nav_hedge_plan %} + 对冲计划 + {% endif %} {% if display.show_nav_risk_policy %} 风控说明 {% endif %} @@ -144,7 +147,7 @@ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} {% include 'instance_header_panel.html' %} - {% if page not in ('settings', 'risk_policy', 'env_config', 'options') %} + {% if page not in ('settings', 'risk_policy', 'env_config', 'options', 'hedge_plan') %} {% include 'instance_top_bar.html' %} {% endif %} @@ -361,6 +364,8 @@ {% include 'strategy_records_page.html' %} {% elif page == 'options' %} {% include 'options_panel.html' %} + {% elif page == 'hedge_plan' %} + {% include 'hedge_plan_panel.html' %} {% endif %} diff --git a/tests/test_hedge_plan_calc.py b/tests/test_hedge_plan_calc.py new file mode 100644 index 0000000..a895711 --- /dev/null +++ b/tests/test_hedge_plan_calc.py @@ -0,0 +1,86 @@ +"""对冲计划 P0 测算口径单测.""" +import unittest + +from lib.hedge_plan.hedge_plan_calc_lib import ( + build_options_options_preview, + build_perp_options_preview, + gate_status, + option_expiry_pnl, + option_premium_total, + perp_pnl, +) + + +class TestHedgePlanCalc(unittest.TestCase): + def test_perp_tp_accounting_is_profit_minus_premium(self): + p = build_perp_options_preview( + direction="long", + entry=3200, + tp=3400, + sl=3000, + contracts=50, + contract_size=0.01, + opt_type="P", + strike=3100, + sheets=10, + ct_mult=0.01, + premium_paid=8, + index_px=3200, + ) + self.assertEqual(p["summary"]["tp_total"], 92.0) + self.assertEqual(p["scenarios"][0]["options_pnl"], -8.0) + + def test_perp_sl_accounting_is_option_plus_perp_signed(self): + p = build_perp_options_preview( + direction="long", + entry=3200, + tp=3400, + sl=3000, + contracts=50, + contract_size=0.01, + opt_type="P", + strike=3100, + sheets=10, + ct_mult=0.01, + premium_paid=8, + index_px=3200, + ) + self.assertEqual(p["summary"]["sl_total"], -98.0) + self.assertEqual(p["summary"]["hedge_ratio_at_sl"], 2.0) + + def test_option_premium_and_expiry(self): + self.assertEqual(option_premium_total(ask=80, sheets=1, ct_mult=0.01), 0.8) + self.assertEqual( + option_expiry_pnl( + opt_type="P", strike=3100, spot=3000, sheets=10, ct_mult=0.01, premium_paid=8 + ), + 2.0, + ) + + def test_gate_perp_requires_full_margin_for_start_message(self): + g = gate_status( + hedge_enabled=True, + sizing_mode="risk", + plan_type="perp_options", + options_enabled=True, + ) + self.assertTrue(g["can_preview"]) + self.assertFalse(g["can_start"]) + self.assertTrue(any("全仓" in r for r in g["reasons"])) + + def test_oo_expiry_loss_flag(self): + a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} + p = build_options_options_preview(target_price=3500, index_px=3200, leg_a=a, leg_b=b) + self.assertEqual(p["summary"]["premium_paid"], 10) + self.assertTrue(p["summary"]["expiry_is_loss"]) + + def test_perp_short_pnl(self): + self.assertEqual( + perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1), + 10, + ) + + +if __name__ == "__main__": + unittest.main()