101 Commits

Author SHA1 Message Date
dekun b63f6f0962 修复中控轮询连锁唤醒导致 CPU 居高不下:轮询加最小间隔并取消 board 每轮强制刷新看板。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 12:35:41 +08:00
dekun 75f50fe083 交易所 API 改为仅服务器配置:前端去掉密钥、新机示例为空,并防止坏钥反复请求触发 Gate 封 IP。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 12:21:43 +08:00
dekun f675f9997a 刷新 instance_settings_prefs 缓存版本,确保本位门控显隐生效
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:30:05 +08:00
dekun 081afeba76 币本位 env 隐藏门控 USDC 倍数项,USDC 模式隐藏币本位倍数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:29:46 +08:00
dekun e38039d99a 目标门控模式改为下拉:权利金×倍数 / 净盈亏阈值
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:07:10 +08:00
dekun c1a84013b9 目标门控 env 默认值写入 example 与 UI,避免前端输入框空白
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:06:23 +08:00
dekun fe346571b1 目标平仓门控改为 USDT 口径:env 可配权利金倍数与净盈亏阈值,币本位默认×1.05
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:01:06 +08:00
dekun 5f9901db0f 修复 Gate 全仓持仓保证金误读为浮盈亏:全仓用 value/杠杆+平仓费估算,API margin 若等于 unrealised_pnl 则弃用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 15:50:34 +08:00
dekun 5e2f332bdd 期权链列名「距平衡」改为「平衡价差」
列表表头与下单面板标签统一为平衡价差。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 08:23:07 +08:00
dekun aa1a2da2b7 距平衡改为行权价到平衡价的价差
Call 为 BE−K、Put 为 K−BE;链/下单/持仓统一;顺带修复 format_position_row 中 row_mode 引用顺序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 08:21:31 +08:00
dekun 57cca5554e 划转默认折叠,修复币本位到期平衡计算
币本位权利金为币报价,到期平衡按 OKX 结算公式 K/(1±p) 计算;链/持仓/跨式平衡带同步修正。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 08:15:33 +08:00
dekun aa14688a7e 持仓区增加账户内划转,买一平仓规则并入左侧开平仓说明
持仓卡片底部可折叠划转面板(资金/交易 USDT/USDC);移除持仓区重复平仓规则,统一到左侧开平仓规则说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 08:11:19 +08:00
dekun fe5bb923d7 期权页增加重试卖回ETH按钮,平仓后可手动全额卖回交易户标的币
币本位模式下持仓区显示重试按钮与可用余额提示,调用已有 spot-bridge 接口按交易账户全部可用量市价卖回 USDT。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 08:01:19 +08:00
dekun 893a2cc115 币本位期权数据统计按指数折算为U,不再误标USDC导致0.00
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 06:17:55 +08:00
dekun 9421ff7360 期权历史表:盈亏单行显示,收窄合约列
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 21:56:36 +08:00
dekun e261df0009 修复币本位期权历史权利金/盈亏显示0.00;复盘盈亏按指数换算为U
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 21:51:03 +08:00
dekun 1ddfe3f72e 中控期权浮盈只显示U;总浮盈亏按指数把币本位浮盈计入USDT
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 18:13:39 +08:00
dekun 467f4f092f 顶栏实时盈亏只显示U;交易账户USDT/ETH多行展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 18:05:42 +08:00
dekun 8b5080bdda 币本位期权开仓/翻倍提醒/翻倍平仓/目标平仓/手动平仓微信推送:按ETH/BTC计价并补齐全平必发
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 18:02:12 +08:00
dekun 9bb05113f3 修复币本位实时盈亏闪烁:禁止ETH盈亏与U混加后被两位小数抹成0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 17:51:46 +08:00
dekun f5c553844f 币本位盈亏双显ETH/U(按指数换算);平仓卖币改为卖光交易户可用余额
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 17:45:32 +08:00
dekun d4d2110412 顶栏加固:交易账户/期权字段缺省与币金额格式化吞掉Undefined,降低再发500风险
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 17:33:10 +08:00
dekun 2e2d5ddda0 修复OKX顶栏500:去掉与embed_context_extras重复的options_funding_label注入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 17:28:30 +08:00
dekun b763937ec2 币本位实时盈亏/期权浮盈按ETH展示,避免两位小数抹成0.00U
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:31:02 +08:00
dekun a7bec5e121 修复币本位期权残档判定:内在价值按币报价(S-K)/S,避免与美元点差混比误杀有效买一
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:21:54 +08:00
dekun b0c331aa26 修复币本位顶栏改动导致 Gate/Binance 顶栏 UndefinedError 500
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:56:30 +08:00
dekun 87910ed71a 币本位买币改为按最大可开张数×权利金×可配缓冲,不全额兑换USDT
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:33:48 +08:00
dekun 4fb3be35ef 单笔期权默认改为币本位;未配置时按coin处理
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:05:44 +08:00
dekun 6c49c7d51c 币本位持仓卡权利金/回收/门控改为按ETH/BTC展示,避免误标USDC与两位小数抹零
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:36:34 +08:00
dekun 6c9825284f 币本位开仓遇保证金不足时自动减半张数重试一次
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:28:42 +08:00
dekun 70f6cc2e7b 单测同步币本位张数手续费缓冲
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:25:05 +08:00
dekun 9f3360e968 币本位开仓:张数留手续费缓冲,延长买币落账等待并修正51008文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:24:54 +08:00
dekun ed3f4898dd 币本位余额:trading缺USDT时从swap补齐,避免预算误判为0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:23:17 +08:00
dekun 15694e8ea8 隐藏粉尘级ETH/BTC余额,避免顶栏显示0币
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:19:35 +08:00
dekun f3de3763bb 修复币本位报价误走USDC,中控补资金账户并隐藏零币余额
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:08:28 +08:00
dekun 73efad6fa5 币本位顶栏去掉期权资金/交易列,交易账户显示USDT/ETH/BTC
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:58:43 +08:00
dekun a84554e613 币本位顶栏资金显示USDT+ETH,单笔期权本位改为下拉选择
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:38:39 +08:00
dekun 1314349fe5 修复币本位期权杠杆显示:按1/卖一而非指数/卖一
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:18:15 +08:00
dekun 72c84bb993 实现OKX单笔期权币本位与USDT桥复利(中控只读,不改Gate)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:10:29 +08:00
dekun dd8fbae0dd 文档:币本位方案补充中控只读识别与不改Gate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:58:53 +08:00
dekun 1c2d6ef3a9 文档:登记快照标签 snapshot/20260820
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:51:41 +08:00
dekun 2028251fc1 文档:新增OKX单笔期权币本位与USDT桥开发方案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 11:46:04 +08:00
dekun 339f5e6db0 文档:新增实盘下单盘口深度预览开发方案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 21:25:19 +08:00
dekun 71a91484a3 白名单仅一币时选择币种默认显示 env 币种(关键位/实盘下单共用)。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 19:27:05 +08:00
dekun 86cf722117 修复翻倍倍数输入被刷回1:持仓轮询重绘时保留草稿,聚焦输入时跳过重绘。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:20:45 +08:00
dekun 2a33f74252 修复翻倍倍数无法手输:未勾选开启时不再 disabled,应用/勾选只控制是否监控。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:17:09 +08:00
dekun 271865fa3d 修复全仓复利关闭后仍无法开仓:前端不再残留选中全仓,后端强制改指定张数。
审计:开关关闭时隐藏全仓芯片并自动勾选指定张数;报价/余额热同步 compound_full_enabled;API 将 compound_full 归一为 sheets(缺张数默认1);单测覆盖开关开关两种归一路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:14:53 +08:00
dekun 9c19afc8d4 修正期权开仓 51008 文案:不再误报资金账户 USDT,按 USDC/USDT 区分提示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:08:16 +08:00
dekun 8605efa2ed 翻倍出场监控中按钮改为取消;中控目标监控列显示倍数如1倍。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 10:37:31 +08:00
dekun cd23ea74a6 单独期权增加翻倍出场:可开关、自选倍数(默认1倍=盈利等于权利金),达标后买一限价平。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 10:31:29 +08:00
dekun 8dda7500df 修复期权页 Jinja 语法错误导致 HTTP 500。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 10:19:37 +08:00
dekun 886b6dcc5b 修复全仓复利开启时单笔预算仍显示:flex 覆盖了 hidden,改为强制隐藏。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 10:16:47 +08:00
dekun a8d6795837 全仓复利开启时隐藏并禁用单笔预算;关闭后才显示可用打满预算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 10:00:50 +08:00
dekun 51e454b0f6 增加独立的全仓复利开关,与上限开关分离;关闭时隐藏下单模式并拒绝开仓。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 09:52:13 +08:00
dekun 1522117eeb 单独期权增加全仓复利模式:可选上限开关,仅允许一仓
用期权户全部可用×缓冲开仓,默认不设上限;开启上限后按 env 封顶,全仓时禁止已有持仓再开。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 09:45:36 +08:00
dekun a354811a6e 修复期权链拉取触发 OKX 50011 限频
为 instruments 加进程缓存并在限频时回退旧数据,前端遇 50011 不再连打重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:31:46 +08:00
dekun 3d7d754ba3 单独期权下单预估改为显示盈亏比
目标位指数仅作到期实值参考,展示盈利÷本合约权利金;持仓目标行同步显示盈亏比。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:10:04 +08:00
dekun 38e3e00fe9 情景测算按到期实值反推盈亏比达标现货价
达标情景现货价按权利金价值与行权价反推,便于对照到期后效果。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:01:21 +08:00
dekun a1bf760a28 修正期期盈亏比口径:按总权利金计算,残值按本合约
盈利腿触发改为盈利金额/总权利金;亏损腿残值20%仍相对该合约自身权利金。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:51:50 +08:00
dekun c5d3d9d6c1 期期出场改盈亏比:达目标平盈利腿,亏损腿残值20%或到期平
将上/下破目标价替换为盈亏比(盈利金额/初始权利金,默认2);残值平需买一流动性且权利金≤初始20%。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:44:45 +08:00
dekun e26a67176c fix(hub): default HUB_ALLOW_PUBLIC on to avoid cloud 403 forbidden
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:31:59 +08:00
dekun 860ebef4a7 fix(options): expose chain DTE in env UI and show nearest expiries for OO
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:54:03 +08:00
dekun a90876d772 fix(options): expand-all widens money filter so more strikes show
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:45:02 +08:00
dekun 245b85ad27 feat(instance): add exchange account ledger tab with SSE sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 09:51:45 +08:00
dekun 425fc701bc fix(hedge): equal-height right shell card with strategy status
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 10:02:59 +08:00
dekun eca6d091e9 feat(hedge): option-primary watch entry with leverage gate
Start strategy arms a watching plan instead of opening immediately; list filters by leverage; type is a dropdown defaulting to OTM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 09:27:42 +08:00
dekun 6ab27cebcd fix(hedge): show option list by interval; leverage only at start
Keep capital/select params on one row, reload chain when interval changes, and stop filtering the chain by option leverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 09:18:36 +08:00
dekun 20e0c2cb9f refactor(hedge): env-driven option-primary UI with grouped params
Move mode switch to HEDGE_PLAN_OPTION_PRIMARY, split left into capital/select/exit groups, and show perp quote above options with leverage auto-match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 09:05:32 +08:00
dekun c3c7243dd7 fix(hedge): do not apply option leverage gate to ITM/ATM chain list
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 08:55:33 +08:00
dekun f7e5329915 fix(hedge): hide mode-specific fields and default to option-primary
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 08:49:24 +08:00
dekun e7e733d9a9 fix(hedge): filter options-chain by min hours and strike interval for option-primary
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 08:38:11 +08:00
dekun afe361ce47 feat(hedge): add option-primary mode for perp+options plans
Add UI switch for Call+short/Put+long, premium x0.95 sizing, option-first open, and K+/-points exits with fee-aware net PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 08:29:26 +08:00
dekun 0b8e5a0914 Fix hub funds overview double-counting OKX USDT with options.
Only add options USDC/USDG onto perpetual USDT totals, repair historical double-counted snapshots, and label the options line as USDC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 10:30:34 +08:00
dekun 4bc238b014 Fix hub options float summary blank when bid book is invalid.
Stop treating total_received=0 as a real bid recycle, fall back to exchange upl for display totals, and keep row/summary aligned.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 09:47:14 +08:00
dekun d41028b766 Fix dashboard PnL using spot contract size of 1.
Prefer perpetual symbols and normalize before market.contractSize lookup so Gate BTC float matches ~0.4U not thousands.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:20:43 +08:00
dekun 75a4175522 Fix instance dashboard order PnL columns showing empty dashes.
Compute float_pnl and tp_profit from mark/entry/contracts using each exchange contract size during dashboard enrich.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 14:12:57 +08:00
dekun 993189ce13 Limit OO T-quote to 3 Call and 3 Put by default with leverage columns.
Drop the scroll box, add a show-all toggle after refresh, and show K/ask leverage on both sides.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 09:27:01 +08:00
dekun 2181c81aba Show selected state on OO money filters and recommend buttons.
Muted idle chips, cyan checkmark when active so 平/虚 and 推荐跨式/双虚 are obvious after click.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 09:17:07 +08:00
dekun baf928d064 Highlight selected Call/Put legs on the OO hedge T-quote board.
Selected buttons show 腿A/腿B and accent styling so the active strikes are obvious.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 09:13:00 +08:00
dekun f19500bcd9 Fix options PnL backfill matching when the same contract is traded twice.
Match exchange history by sheets and open time so an earlier close is not overwritten with the later trade's PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 09:04:30 +08:00
dekun 25ed46e3f2 Default options chain to ATM plus 3 ITM and 3 OTM.
Apply the same window in list and T views; expand-all remains available on both.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 08:55:34 +08:00
dekun 7f22bffbc6 Shorten force-close window to 5m and grey-out open during blocks.
Unify Gate/OKX/Binance: disable the open button with a side note during force-close, cooloff, and daily freeze, and enforce the same gate server-side.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 08:38:00 +08:00
dekun 7352d10254 Fix hub total floating PnL by excluding OKX options from swap agent.
Option legs were scored with linear swap math and then added again from the options snapshot, inflating 总浮盈亏 and 持有仓位.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:13:22 +08:00
dekun 3f6e67661b Lock hedge-plan option selection to ITM/ATM (perp) and ATM/OTM (OO).
Server validate on preview/start, UI filters and recommend templates, plus usability/security audit doc.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:50:35 +08:00
dekun 09a763e47d Merge OKX dual APIs into one OKX_API_* account for perp and options.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:28:33 +08:00
dekun 22e6b68e6d Add OKX env toggle to show or hide perpetual funds in the header.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:15 +08:00
dekun 55e94fe059 Restore Gate intraday close/TP-SL controls and document force-close rules.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 08:16:02 +08:00
dekun 747434a65e Add index-over-ask leverage column to options chain list.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 10:26:11 +08:00
dekun f6ea0dc399 Add all-time stats tab with monthly breakdown on instance analytics.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 09:49:47 +08:00
dekun a00699aec3 Fix TP/SL exit classification when exchange fill slips past the tight band.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 09:38:28 +08:00
dekun 4b4dca9e3c Fix strategy-logic P0s from audit: monitor false-flat, fill-confirmed open/close, mode gates.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:56:18 +08:00
dekun fa7ff739a0 Fix env form grid class so mode refresh keeps two-column layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:28:16 +08:00
dekun fdbbde08df Refresh env UI on trade-mode change; hide hedge review tabs in options mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:38:19 +08:00
dekun 83ce50b24e Add OKX three-way trade mode for options vs hedge.
Env OKX_TRADE_MODE selects standalone options, perp hedge, or OO hedge; hide the other module UI and use group or position limits per mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:28:50 +08:00
dekun f9c2a63cbc Preflight options position limit for dual-leg OO hedges.
Reject OO start when max active is under 2 free slots so limit=1 cannot open a half straddle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:17:17 +08:00
dekun 94c65cbd6e Add OKX options max active positions env gate.
Enforce concurrent option contract count on standalone and hedge buys; editable in env UI with hot reload (0 = unlimited).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:12:50 +08:00
dekun 644dcdf092 Rename points-mode label to absolute coin counts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:57:50 +08:00
dekun d89aff3ad6 Use absolute coin counts in perp-options points mode.
Treat 2:4 as 2 perp + 4 option coins instead of normalizing to 1:2, and disable embed page caching so hub iframe picks up trade UI updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:54:16 +08:00
dekun 9f3395de2f Add sideways max-loss to perpetual-options calculator.
Show premium wipeout plus flat round-trip perp fees as case C for both size and points modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 22:38:20 +08:00
dekun 28a329cb63 Restructure live order form into labeled rows for clearer hierarchy.
Group policy selects, SL/RR fields, options, and submit so the unlabeled RR input and scrambled checkbox/price row no longer fight for space.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:24:17 +08:00
dekun 1c2c012dd7 Align snapshot/20260728-2 hash with tag target.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:41:53 +08:00
173 changed files with 18183 additions and 2258 deletions
+5 -4
View File
@@ -76,10 +76,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单) # 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true LIVE_TRADING_ENABLED=true
# Binance API Key(需开通合约,万向划转等权限) # Binance API(仅服务器 .env 配置;新机保持为空,填真钥后 pm2 restart --update-env;勿用占位符以免鉴权狂打)
BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY BINANCE_API_KEY=
# Binance API Secret BINANCE_API_SECRET=
BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET
# 保证金模式:cross=全仓,isolated=逐仓 # 保证金模式:cross=全仓,isolated=逐仓
BINANCE_MARGIN_MODE=cross BINANCE_MARGIN_MODE=cross
# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向 # 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向
@@ -190,6 +189,8 @@ AUTO_TRANSFER_BJ_HOUR=8
FORCE_CLOSE_BJ_HOUR=0 FORCE_CLOSE_BJ_HOUR=0
# 是否启用强制清仓(默认关闭,true 才会在整点执行) # 是否启用强制清仓(默认关闭,true 才会在整点执行)
FORCE_CLOSE_ENABLED=false FORCE_CLOSE_ENABLED=false
# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓)
FORCE_CLOSE_GRACE_MINUTES=5
# 推送与AI超时(秒) # 推送与AI超时(秒)
WECHAT_TIMEOUT_SECONDS=10 WECHAT_TIMEOUT_SECONDS=10
+130 -41
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path: if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT) sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import ( from lib.ai.ai_review_lib import (
build_journal_ai_chart_path, build_journal_ai_chart_path,
@@ -257,6 +262,7 @@ from lib.common.history_window_lib import (
utc_window_to_utc_sql_strings, utc_window_to_utc_sql_strings,
) )
from lib.trade.trade_result_lib import ( from lib.trade.trade_result_lib import (
classify_exit_by_levels,
count_winning_trades, count_winning_trades,
filter_trade_records_excluding_miss, filter_trade_records_excluding_miss,
normalize_result_with_pnl, normalize_result_with_pnl,
@@ -346,8 +352,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz() APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() BINANCE_API_KEY = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() BINANCE_API_SECRET = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
# hedge=双向持仓(需 positionSide);oneway / single=单向持仓 # hedge=双向持仓(需 positionSide);oneway / single=单向持仓
_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() _raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
@@ -489,6 +495,8 @@ if BINANCE_API_KEY and BINANCE_API_SECRET:
exchange.apiKey = BINANCE_API_KEY exchange.apiKey = BINANCE_API_KEY
exchange.secret = BINANCE_API_SECRET exchange.secret = BINANCE_API_SECRET
MARKETS_LOADED = False MARKETS_LOADED = False
# 鉴权失败后停止私有 API(资金/持仓),避免坏钥反复请求;尤其 Gate 易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = { ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0, "updated_at": 0.0,
"funding_usdt": None, "funding_usdt": None,
@@ -1843,8 +1851,38 @@ def _compute_period_metrics(trades):
} }
def _bounds_for_month_key(ym):
"""ym: YYYY-MM → 该自然月首末日(北京日历)."""
y, m = [int(x) for x in str(ym).split("-", 1)]
start = f"{y:04d}-{m:02d}-01"
if m == 12:
end = f"{y:04d}-12-31"
else:
end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d")
return start, end
def _build_monthly_stats_rows(conn, all_tr, seg_key):
"""按北京交易日所在自然月聚合;新月在前."""
by_month = {}
for p, t, td in all_tr:
if not td or len(str(td)) < 7:
continue
mk = str(td)[:7]
by_month.setdefault(mk, []).append((p, t, td))
rows = []
for mk in sorted(by_month.keys(), reverse=True):
metrics = _compute_period_metrics(by_month[mk])
ms, me = _bounds_for_month_key(mk)
metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key)
metrics["range_label"] = f"{ms} ~ {me}"
metrics["month_key"] = mk
rows.append(metrics)
return rows
def compute_stats_bundle(conn, trading_day, now_dt=None): def compute_stats_bundle(conn, trading_day, now_dt=None):
"""日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入.""" """日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
now_dt = now_dt or app_now() now_dt = now_dt or app_now()
pnls = _load_completed_trade_pnls(conn) pnls = _load_completed_trade_pnls(conn)
total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
@@ -1856,26 +1894,37 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day]
week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end]
month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end]
all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t]
dm = _compute_period_metrics(day_tr) dm = _compute_period_metrics(day_tr)
wm = _compute_period_metrics(week_tr) wm = _compute_period_metrics(week_tr)
mm = _compute_period_metrics(month_tr) mm = _compute_period_metrics(month_tr)
am = _compute_period_metrics(all_tr)
dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key)
wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key)
mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key)
am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key)
dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)"
wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)"
mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)"
return dm, wm, mm tds = [td for _, _, td in all_tr if td]
if tds:
am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)"
else:
am["range_label"] = "全部历史(暂无平仓)"
am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key)
return dm, wm, mm, am
segments = [] segments = []
seg_defs = effective_stats_segment_defs( seg_defs = effective_stats_segment_defs(
STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
) )
for seg_key, seg_title, _meta in seg_defs: for seg_key, seg_title, _meta in seg_defs:
dm, wm, mm = slice_metrics(seg_key) dm, wm, mm, am = slice_metrics(seg_key)
segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) segments.append(
{"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am}
)
dm, wm, mm = slice_metrics("all") dm, wm, mm, am = slice_metrics("all")
return { return {
"trading_day": trading_day, "trading_day": trading_day,
@@ -1883,6 +1932,7 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
"day": dm, "day": dm,
"week": wm, "week": wm,
"month": mm, "month": mm,
"all": am,
"segments": segments, "segments": segments,
"stats_reset_hour": TRADING_DAY_RESET_HOUR, "stats_reset_hour": TRADING_DAY_RESET_HOUR,
} }
@@ -2816,6 +2866,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready(): def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED: if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (BINANCE_API_KEY and BINANCE_API_SECRET): if not (BINANCE_API_KEY and BINANCE_API_SECRET):
@@ -2850,9 +2902,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured(): def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(BINANCE_API_KEY and BINANCE_API_SECRET) return bool(BINANCE_API_KEY and BINANCE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, BINANCE_API_KEY, BINANCE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
BINANCE_API_KEY = ""
BINANCE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _float_balance_field(val): def _float_balance_field(val):
if val is None or val == "": if val is None or val == "":
return None return None
@@ -3135,11 +3201,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try: try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt()
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try: try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total()
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -3264,6 +3334,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
def precheck_risk(conn, symbol, direction): def precheck_risk(conn, symbol, direction):
now = app_now() now = app_now()
from lib.trade.account_risk_lib import account_risk_blocks_trading from lib.trade.account_risk_lib import account_risk_blocks_trading
from lib.trade.force_close_lib import force_close_blocks_new_open
ok_risk, risk_reason = account_risk_blocks_trading( ok_risk, risk_reason = account_risk_blocks_trading(
conn, conn,
@@ -3273,6 +3344,13 @@ def precheck_risk(conn, symbol, direction):
) )
if not ok_risk: if not ok_risk:
return False, risk_reason return False, risk_reason
fc_block, fc_note = force_close_blocks_new_open(
FORCE_CLOSE_ENABLED,
FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
)
if fc_block:
return False, fc_note or "强制清仓窗口内暂不可开仓"
if not trading_day_reset_allows_new_open(now): if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
from lib.trade.account_risk_lib import position_limit_reached from lib.trade.account_risk_lib import position_limit_reached
@@ -3365,7 +3443,7 @@ def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
def get_contract_size(exchange_symbol): def get_contract_size(exchange_symbol):
ensure_markets_loaded() ensure_markets_loaded()
market = exchange.market(exchange_symbol) market = exchange.market(normalize_exchange_symbol(exchange_symbol))
return float(market.get("contractSize") or 1) return float(market.get("contractSize") or 1)
@@ -3534,7 +3612,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False): def ensure_markets_loaded(force=False):
global MARKETS_LOADED global MARKETS_LOADED
if force or not MARKETS_LOADED: if force or not MARKETS_LOADED:
exchange.load_markets(reload=force) try:
exchange.load_markets(reload=force)
except Exception as e:
# 坏钥时立刻去掉签名再拉公开 markets,避免反复鉴权(尤其勿拖累同机 Gate)
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True MARKETS_LOADED = True
@@ -4261,29 +4347,6 @@ def ms_to_app_local_str(ms):
return app_now_str() return app_now_str()
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None."""
try:
tp = float(take_profit)
sl = float(stop_loss)
ex = float(exit_price)
trig = float(trigger_price)
except (TypeError, ValueError):
return None
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
if direction == "long":
if ex >= tp - band:
return "止盈"
if ex <= sl + band:
return "止损"
else:
if ex <= tp + band:
return "止盈"
if ex >= sl - band:
return "止损"
return None
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None."""
if not (BINANCE_API_KEY and BINANCE_API_SECRET): if not (BINANCE_API_KEY and BINANCE_API_SECRET):
@@ -6910,8 +6973,10 @@ def force_close_before_reset():
if not FORCE_CLOSE_ENABLED: if not FORCE_CLOSE_ENABLED:
return return
now = app_now() now = app_now()
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓
if now.hour != FORCE_CLOSE_BJ_HOUR: from lib.trade.force_close_lib import is_force_close_executing
if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)):
return return
conn = get_db() conn = get_db()
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
@@ -7283,14 +7348,22 @@ def render_main_page(page="trade", embed_mode=None):
position_limit_count = count_position_limit_active_monitors(conn) position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn) risk_status = hub_account_risk_status(conn)
can_trade = can_trade_new_open( from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
_open_gate = resolve_manual_open_gate(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=position_limit_count, active_count=position_limit_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True), risk_status=risk_status,
force_close_enabled=FORCE_CLOSE_ENABLED,
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
reset_hour=TRADING_DAY_RESET_HOUR,
) )
can_trade = _open_gate["can_trade"]
open_block_note = _open_gate["open_block_note"]
key_rule_ctx = key_monitor_rule_template_context( key_rule_ctx = key_monitor_rule_template_context(
kline_timeframe=KLINE_TIMEFRAME, kline_timeframe=KLINE_TIMEFRAME,
key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT,
@@ -7357,6 +7430,7 @@ def render_main_page(page="trade", embed_mode=None):
price_refresh_seconds=PRICE_REFRESH_SECONDS, price_refresh_seconds=PRICE_REFRESH_SECONDS,
active_count=position_limit_count, active_count=position_limit_count,
can_trade=can_trade, can_trade=can_trade,
open_block_note=open_block_note,
opens_today=opens_today, opens_today=opens_today,
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
@@ -7544,14 +7618,22 @@ def api_account_snapshot():
header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ)
conn.close() conn.close()
can_trade = can_trade_new_open( from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
_open_gate = resolve_manual_open_gate(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=position_limit_count, active_count=position_limit_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True), risk_status=risk_status,
force_close_enabled=FORCE_CLOSE_ENABLED,
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
reset_hour=TRADING_DAY_RESET_HOUR,
) )
can_trade = _open_gate["can_trade"]
open_block_note = _open_gate["open_block_note"]
available_trading_usdt = get_available_trading_usdt() available_trading_usdt = get_available_trading_usdt()
unrealized_pnl = None unrealized_pnl = None
@@ -7577,6 +7659,7 @@ def api_account_snapshot():
"active_count": position_limit_count, "active_count": position_limit_count,
"max_active_positions": MAX_ACTIVE_POSITIONS, "max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade, "can_trade": can_trade,
"open_block_note": open_block_note,
"opens_today": opens_today, "opens_today": opens_today,
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
@@ -9556,7 +9639,9 @@ register_trade_records_api(
def _dashboard_enrich_orders(items): def _dashboard_enrich_orders(items):
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
return enrich_order_items_with_marks(items, get_price=get_price) return enrich_order_items_with_marks(
items, get_price=get_price, get_contract_size=get_contract_size
)
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
@@ -9569,6 +9654,10 @@ register_instance_dashboard_routes(
hedge_enabled=False, hedge_enabled=False,
) )
from lib.account_ledger.account_ledger_register import install_account_ledger
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="binance")
@app.route("/api/journals") @app.route("/api/journals")
@login_required @login_required
+5 -4
View File
@@ -74,10 +74,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) # 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true LIVE_TRADING_ENABLED=true
# Gate API Key(实盘) # Gate API(仅服务器 .env 配置;新机保持为空,填真钥后重启;错误密钥反复请求易导致 Gate 封 IP)
GATE_API_KEY=REPLACE_WITH_GATE_API_KEY GATE_API_KEY=
# Gate API Secret(实盘) GATE_API_SECRET=
GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET
# 保证金模式:cross=全仓,isolated=逐仓 # 保证金模式:cross=全仓,isolated=逐仓
GATE_TD_MODE=cross GATE_TD_MODE=cross
# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 # 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤
@@ -192,6 +191,8 @@ AUTO_TRANSFER_BJ_HOUR=8
FORCE_CLOSE_BJ_HOUR=0 FORCE_CLOSE_BJ_HOUR=0
# 是否启用强制清仓(默认关闭,true 才会在整点执行) # 是否启用强制清仓(默认关闭,true 才会在整点执行)
FORCE_CLOSE_ENABLED=false FORCE_CLOSE_ENABLED=false
# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓)
FORCE_CLOSE_GRACE_MINUTES=5
# 推送与AI超时(秒) # 推送与AI超时(秒)
WECHAT_TIMEOUT_SECONDS=10 WECHAT_TIMEOUT_SECONDS=10
+205 -69
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path: if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT) sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import ( from lib.ai.ai_review_lib import (
build_journal_ai_chart_path, build_journal_ai_chart_path,
@@ -260,6 +265,7 @@ from lib.common.history_window_lib import (
utc_window_to_utc_sql_strings, utc_window_to_utc_sql_strings,
) )
from lib.trade.trade_result_lib import ( from lib.trade.trade_result_lib import (
classify_exit_by_levels,
count_winning_trades, count_winning_trades,
filter_trade_records_excluding_miss, filter_trade_records_excluding_miss,
normalize_result_with_pnl, normalize_result_with_pnl,
@@ -345,8 +351,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz() APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() GATE_API_KEY = normalize_api_credential(os.getenv("GATE_API_KEY"))
GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() GATE_API_SECRET = normalize_api_credential(os.getenv("GATE_API_SECRET"))
GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) # 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平)
@@ -477,6 +483,8 @@ if GATE_API_KEY and GATE_API_SECRET:
exchange.apiKey = GATE_API_KEY exchange.apiKey = GATE_API_KEY
exchange.secret = GATE_API_SECRET exchange.secret = GATE_API_SECRET
MARKETS_LOADED = False MARKETS_LOADED = False
# 鉴权失败后停止私有 API,避免坏钥反复签名;Gate 尤其易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = { ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0, "updated_at": 0.0,
"funding_usdt": None, "funding_usdt": None,
@@ -1841,45 +1849,80 @@ def _compute_period_metrics(trades):
} }
def _bounds_for_month_key(ym):
"""ym: YYYY-MM → 该自然月首末日(北京日历)."""
y, m = [int(x) for x in str(ym).split("-", 1)]
start = f"{y:04d}-{m:02d}-01"
if m == 12:
end = f"{y:04d}-12-31"
else:
end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d")
return start, end
def _build_monthly_stats_rows(conn, all_tr, seg_key):
"""按北京交易日所在自然月聚合;新月在前."""
by_month = {}
for p, t, td in all_tr:
if not td or len(str(td)) < 7:
continue
mk = str(td)[:7]
by_month.setdefault(mk, []).append((p, t, td))
rows = []
for mk in sorted(by_month.keys(), reverse=True):
metrics = _compute_period_metrics(by_month[mk])
ms, me = _bounds_for_month_key(mk)
metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key)
metrics["range_label"] = f"{ms} ~ {me}"
metrics["month_key"] = mk
rows.append(metrics)
return rows
def compute_stats_bundle(conn, trading_day, now_dt=None): def compute_stats_bundle(conn, trading_day, now_dt=None):
"""日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入.""" """日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
now_dt = now_dt or app_now() now_dt = now_dt or app_now()
pnls = _load_completed_trade_pnls(conn) pnls = _load_completed_trade_pnls(conn)
total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
w_start, w_end = _session_week_bounds(trading_day) w_start, w_end = _session_week_bounds(trading_day)
m_start, m_end = _calendar_month_bounds(now_dt) m_start, m_end = _calendar_month_bounds(now_dt)
def in_week(tr):
return tr[2] and w_start <= tr[2] <= w_end
def in_month(tr):
return tr[2] and m_start <= tr[2] <= m_end
def slice_metrics(seg_key): def slice_metrics(seg_key):
seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)] seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)]
day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day]
week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end]
month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end]
all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t]
dm = _compute_period_metrics(day_tr) dm = _compute_period_metrics(day_tr)
wm = _compute_period_metrics(week_tr) wm = _compute_period_metrics(week_tr)
mm = _compute_period_metrics(month_tr) mm = _compute_period_metrics(month_tr)
am = _compute_period_metrics(all_tr)
dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key)
wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key)
mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key)
am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key)
dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)"
wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)"
mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)"
return dm, wm, mm tds = [td for _, _, td in all_tr if td]
if tds:
am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)"
else:
am["range_label"] = "全部历史(暂无平仓)"
am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key)
return dm, wm, mm, am
segments = [] segments = []
seg_defs = effective_stats_segment_defs( seg_defs = effective_stats_segment_defs(
STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
) )
for seg_key, seg_title, _meta in seg_defs: for seg_key, seg_title, _meta in seg_defs:
dm, wm, mm = slice_metrics(seg_key) dm, wm, mm, am = slice_metrics(seg_key)
segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) segments.append(
{"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am}
)
dm, wm, mm = slice_metrics("all") dm, wm, mm, am = slice_metrics("all")
return { return {
"trading_day": trading_day, "trading_day": trading_day,
@@ -1887,6 +1930,7 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
"day": dm, "day": dm,
"week": wm, "week": wm,
"month": mm, "month": mm,
"all": am,
"segments": segments, "segments": segments,
"stats_reset_hour": TRADING_DAY_RESET_HOUR, "stats_reset_hour": TRADING_DAY_RESET_HOUR,
} }
@@ -2510,6 +2554,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready(): def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED: if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (GATE_API_KEY and GATE_API_SECRET): if not (GATE_API_KEY and GATE_API_SECRET):
@@ -2544,9 +2590,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured(): def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(GATE_API_KEY and GATE_API_SECRET) return bool(GATE_API_KEY and GATE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, GATE_API_KEY, GATE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
GATE_API_KEY = ""
GATE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启;勿反复试错以免 Gate 封 IP): {exc}"
)
def _extract_usdt_total(balance): def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {} total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2804,11 +2864,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try: try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt()
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try: try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"]) ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"])
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -2934,6 +2998,7 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
def precheck_risk(conn, symbol, direction): def precheck_risk(conn, symbol, direction):
now = app_now() now = app_now()
from lib.trade.account_risk_lib import account_risk_blocks_trading from lib.trade.account_risk_lib import account_risk_blocks_trading
from lib.trade.force_close_lib import force_close_blocks_new_open
ok_risk, risk_reason = account_risk_blocks_trading( ok_risk, risk_reason = account_risk_blocks_trading(
conn, conn,
@@ -2943,6 +3008,13 @@ def precheck_risk(conn, symbol, direction):
) )
if not ok_risk: if not ok_risk:
return False, risk_reason return False, risk_reason
fc_block, fc_note = force_close_blocks_new_open(
FORCE_CLOSE_ENABLED,
FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
)
if fc_block:
return False, fc_note or "强制清仓窗口内暂不可开仓"
if not trading_day_reset_allows_new_open(now): if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
from lib.trade.account_risk_lib import position_limit_reached from lib.trade.account_risk_lib import position_limit_reached
@@ -3035,7 +3107,7 @@ def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
def get_contract_size(exchange_symbol): def get_contract_size(exchange_symbol):
ensure_markets_loaded() ensure_markets_loaded()
market = exchange.market(exchange_symbol) market = exchange.market(normalize_exchange_symbol(exchange_symbol))
return float(market.get("contractSize") or 1) return float(market.get("contractSize") or 1)
@@ -3300,7 +3372,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False): def ensure_markets_loaded(force=False):
global MARKETS_LOADED global MARKETS_LOADED
if force or not MARKETS_LOADED: if force or not MARKETS_LOADED:
exchange.load_markets(reload=force) try:
exchange.load_markets(reload=force)
except Exception as e:
# Gate 对无效签名/坏钥敏感,失败后立即改公开 markets,勿反复带钥请求
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True MARKETS_LOADED = True
@@ -3716,46 +3796,99 @@ def _coerce_float(*values):
return None return None
def _gate_is_cross_margin(position, info):
mode = str(position.get("marginMode") or info.get("pos_margin_mode") or "").lower()
if "cross" in mode:
return True
lev = _coerce_float(info.get("leverage"), position.get("leverage"))
return lev is not None and lev == 0
def _gate_effective_leverage(position, info, order_leverage=None):
lev = _coerce_float(position.get("leverage"), info.get("leverage"))
if lev is not None and lev > 0:
return lev
cross_lev = _coerce_float(info.get("cross_leverage_limit"))
if cross_lev is not None and cross_lev > 0:
return cross_lev
if order_leverage is not None:
try:
ol = float(order_leverage)
if ol > 0:
return ol
except (TypeError, ValueError):
pass
return None
def _gate_estimated_initial_margin(notional, leverage):
"""Gate App 口径:仓位价值/杠杆 + 预估平仓 taker 费(0.075%)."""
if notional is None or notional <= 0 or leverage is None or leverage <= 0:
return None
return notional / float(leverage) + notional * 0.00075
def _gate_margin_matches_unrealized(margin, unrealized):
if margin is None or unrealized is None:
return False
return abs(float(margin) - float(unrealized)) <= max(0.02, abs(float(unrealized)) * 0.05)
def _gate_resolve_initial_margin(position, info, *, notional, unrealized, order_leverage=None):
"""全仓下 API margin 偶发等于 unrealised_pnl;优先 value/杠杆,逐仓仍信 API."""
api_margin = _coerce_float(
info.get("initial_margin"),
position.get("initialMargin"),
position.get("collateral"),
position.get("margin"),
info.get("margin"),
info.get("iso_margin"),
info.get("position_margin"),
info.get("initialMargin"),
)
eff_lev = _gate_effective_leverage(position, info, order_leverage)
estimated = _gate_estimated_initial_margin(notional, eff_lev) if eff_lev else None
if _gate_is_cross_margin(position, info):
if estimated and estimated > 0:
if (
api_margin is None
or api_margin <= 0
or _gate_margin_matches_unrealized(api_margin, unrealized)
or api_margin < estimated * 0.6
):
return estimated
if api_margin is not None and api_margin > 0 and not _gate_margin_matches_unrealized(
api_margin, unrealized
):
return api_margin
return estimated
if api_margin is not None and api_margin > 0:
return api_margin
return estimated
def parse_ccxt_position_metrics(position, order_leverage=None): def parse_ccxt_position_metrics(position, order_leverage=None):
""" """
ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底). ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底).
App仓位保证金对齐时优先用 initialMargin;缺失时再尝试 info 内字段. 全仓优先 value/cross_leverage_limit(+平仓费);API margin unrealised_pnl 则弃用.
""" """
if not position: if not position:
return None return None
p = position p = position
info = p.get("info", {}) or {} info = p.get("info", {}) or {}
# Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
if initial is None or initial <= 0:
initial = _coerce_float(
info.get("margin"),
info.get("cross_margin"),
info.get("iso_margin"),
info.get("initial_margin"),
info.get("position_margin"),
info.get("initialMargin"),
)
notional = _coerce_float(p.get("notional"), p.get("notionalValue")) notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
if notional is None or notional <= 0: if notional is None or notional <= 0:
notional = _coerce_float(info.get("value")) notional = _coerce_float(info.get("value"))
if notional is not None: if notional is not None:
notional = abs(notional) notional = abs(notional)
# 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近)
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
try:
lev = float(order_leverage)
if lev > 0:
approx = notional / lev
if approx > 0:
initial = approx
except (TypeError, ValueError):
pass
unrealized = _coerce_float( unrealized = _coerce_float(
p.get("unrealizedPnl"), p.get("unrealizedPnl"),
info.get("unrealised_pnl"), info.get("unrealised_pnl"),
info.get("unrealized_pnl"), info.get("unrealized_pnl"),
) )
initial = _gate_resolve_initial_margin(
p, info, notional=notional, unrealized=unrealized, order_leverage=order_leverage
)
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice"))
out = {} out = {}
if initial is not None and initial > 0: if initial is not None and initial > 0:
@@ -3892,29 +4025,6 @@ def ms_to_app_local_str(ms):
return app_now_str() return app_now_str()
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None."""
try:
tp = float(take_profit)
sl = float(stop_loss)
ex = float(exit_price)
trig = float(trigger_price)
except (TypeError, ValueError):
return None
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
if direction == "long":
if ex >= tp - band:
return "止盈"
if ex <= sl + band:
return "止损"
else:
if ex <= tp + band:
return "止盈"
if ex >= sl - band:
return "止损"
return None
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None."""
if not (GATE_API_KEY and GATE_API_SECRET): if not (GATE_API_KEY and GATE_API_SECRET):
@@ -6539,8 +6649,10 @@ def force_close_before_reset():
if not FORCE_CLOSE_ENABLED: if not FORCE_CLOSE_ENABLED:
return return
now = app_now() now = app_now()
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓
if now.hour != FORCE_CLOSE_BJ_HOUR: from lib.trade.force_close_lib import is_force_close_executing
if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)):
return return
conn = get_db() conn = get_db()
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
@@ -7061,14 +7173,22 @@ def render_main_page(page="trade", embed_mode=None):
position_limit_count = count_position_limit_active_monitors(conn) position_limit_count = count_position_limit_active_monitors(conn)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn) risk_status = hub_account_risk_status(conn)
can_trade = can_trade_new_open( from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
_open_gate = resolve_manual_open_gate(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=position_limit_count, active_count=position_limit_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True), risk_status=risk_status,
force_close_enabled=FORCE_CLOSE_ENABLED,
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
reset_hour=TRADING_DAY_RESET_HOUR,
) )
can_trade = _open_gate["can_trade"]
open_block_note = _open_gate["open_block_note"]
key_rule_ctx = key_monitor_rule_template_context( key_rule_ctx = key_monitor_rule_template_context(
kline_timeframe=KLINE_TIMEFRAME, kline_timeframe=KLINE_TIMEFRAME,
key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT,
@@ -7132,6 +7252,7 @@ def render_main_page(page="trade", embed_mode=None):
price_refresh_seconds=PRICE_REFRESH_SECONDS, price_refresh_seconds=PRICE_REFRESH_SECONDS,
active_count=position_limit_count, active_count=position_limit_count,
can_trade=can_trade, can_trade=can_trade,
open_block_note=open_block_note,
opens_today=opens_today, opens_today=opens_today,
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
@@ -7340,14 +7461,22 @@ def api_account_snapshot():
header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ)
conn.close() conn.close()
can_trade = can_trade_new_open( from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
_open_gate = resolve_manual_open_gate(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=position_limit_count, active_count=position_limit_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True), risk_status=risk_status,
force_close_enabled=FORCE_CLOSE_ENABLED,
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
reset_hour=TRADING_DAY_RESET_HOUR,
) )
can_trade = _open_gate["can_trade"]
open_block_note = _open_gate["open_block_note"]
available_trading_usdt = get_available_trading_usdt() available_trading_usdt = get_available_trading_usdt()
unrealized_pnl = None unrealized_pnl = None
@@ -7376,6 +7505,7 @@ def api_account_snapshot():
"active_count": position_limit_count, "active_count": position_limit_count,
"max_active_positions": MAX_ACTIVE_POSITIONS, "max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade, "can_trade": can_trade,
"open_block_note": open_block_note,
"opens_today": opens_today, "opens_today": opens_today,
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
@@ -9404,7 +9534,9 @@ register_trade_records_api(
def _dashboard_enrich_orders(items): def _dashboard_enrich_orders(items):
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
return enrich_order_items_with_marks(items, get_price=get_price) return enrich_order_items_with_marks(
items, get_price=get_price, get_contract_size=get_contract_size
)
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
@@ -9417,6 +9549,10 @@ register_instance_dashboard_routes(
hedge_enabled=False, hedge_enabled=False,
) )
from lib.account_ledger.account_ledger_register import install_account_ledger
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="gate")
@app.route("/api/journals") @app.route("/api/journals")
@login_required @login_required
+49 -14
View File
@@ -79,12 +79,14 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) # 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true LIVE_TRADING_ENABLED=true
# OKX API Key(实盘) # =============================================================================
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY # OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示)
# OKX API Secret(实盘) # 新机保持为空;填真钥后 pm2 restart --update-env(含子代理)
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET # 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填
# OKX API Passphrase(实盘) # =============================================================================
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE OKX_API_KEY=
OKX_API_SECRET=
OKX_API_PASSPHRASE=
# 保证金模式:cross=全仓,isolated=逐仓 # 保证金模式:cross=全仓,isolated=逐仓
OKX_TD_MODE=cross OKX_TD_MODE=cross
# 持仓模式:hedge=双向持仓,net=单向净持仓 # 持仓模式:hedge=双向持仓,net=单向净持仓
@@ -99,40 +101,70 @@ OKX_POSITION_INST_TYPE=SWAP
EXCHANGE_DISPLAY_NAME=OKX EXCHANGE_DISPLAY_NAME=OKX
# 企业微信推送里展示的账户备注 # 企业微信推送里展示的账户备注
# OKX_ACCOUNT_LABEL= # OKX_ACCOUNT_LABEL=
# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧
OKX_SHOW_PERP_FUNDS=true
# ============================================================================= # =============================================================================
# 期权(主账户 API,与永续子账户 OKX_API_* 分离;修改后须重启 PM2) # 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
# 详见 docs/期权方案.md 与 docs/期权用法.md # 详见 docs/期权方案.md 与 docs/期权用法.md
# ============================================================================= # =============================================================================
OKX_OPTIONS_ENABLED=false OKX_OPTIONS_ENABLED=false
OKX_OPTIONS_API_KEY= # 以下 OKX_OPTIONS_API_* 已废弃,请勿再配置(仅兼容旧部署回填)
OKX_OPTIONS_API_SECRET= # OKX_OPTIONS_API_KEY=
OKX_OPTIONS_API_PASSPHRASE= # OKX_OPTIONS_API_SECRET=
OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权 # OKX_OPTIONS_API_PASSPHRASE=
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_BUDGET_BUFFER=0.95
# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走)
OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10
OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false
OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
OKX_TRADE_MODE=options
# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0
OKX_OPTIONS_DEFAULT_UNDERLY=ETH OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 # 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
OKX_OPTIONS_MAX_DTE_DAYS=2 OKX_OPTIONS_MAX_DTE_DAYS=2
OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14 OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14
OKX_SUB_ACCOUNT_NAME=
OKX_OPTIONS_ITM_MAX_DIST_USD=30 OKX_OPTIONS_ITM_MAX_DIST_USD=30
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值)
OKX_OPTIONS_CLOSE_GATE_MODE=premium
# 全局倍数可选;留空则按本位用下方 COIN/USDC
OKX_OPTIONS_CLOSE_RECYCLE_MULT=
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2
OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 # 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# ============================================================================= # =============================================================================
# 对冲计划(仅 OKX;前端 env「对冲计划」;详见 docs/对冲计划开发方案.md) # 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md)
# ============================================================================= # =============================================================================
# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读)
HEDGE_PLAN_ENABLED=false HEDGE_PLAN_ENABLED=false
# 页面 Tab 显示(默认全部显示,可单独关闭;不影响已有进行中/历史计划)
HEDGE_PLAN_SHOW_PERP_OPTIONS=true HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_LIVE_ORDER=false HEDGE_PLAN_LIVE_ORDER=false
# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换)
HEDGE_PLAN_OPTION_PRIMARY=true
HEDGE_PLAN_OPEN_ORDER=options_first HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
@@ -147,6 +179,7 @@ HEDGE_PLAN_OO_BIAS_RATIO=0.7
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效 # 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更
MAX_ACTIVE_HEDGE_PLANS=1 MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15 HEDGE_PLAN_MONITOR_POLL_SECONDS=15
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false) # 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false)
@@ -253,6 +286,8 @@ AUTO_TRANSFER_BJ_HOUR=8
FORCE_CLOSE_BJ_HOUR=0 FORCE_CLOSE_BJ_HOUR=0
# 是否启用强制清仓(默认关闭,true 才会在整点执行) # 是否启用强制清仓(默认关闭,true 才会在整点执行)
FORCE_CLOSE_ENABLED=false FORCE_CLOSE_ENABLED=false
# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓)
FORCE_CLOSE_GRACE_MINUTES=5
# 推送与AI超时(秒) # 推送与AI超时(秒)
WECHAT_TIMEOUT_SECONDS=10 WECHAT_TIMEOUT_SECONDS=10
+267 -79
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path: if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT) sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import ( from lib.ai.ai_review_lib import (
build_journal_ai_chart_path, build_journal_ai_chart_path,
@@ -256,6 +261,7 @@ from lib.common.history_window_lib import (
utc_window_to_utc_sql_strings, utc_window_to_utc_sql_strings,
) )
from lib.trade.trade_result_lib import ( from lib.trade.trade_result_lib import (
classify_exit_by_levels,
count_winning_trades, count_winning_trades,
filter_trade_records_excluding_miss, filter_trade_records_excluding_miss,
normalize_result_with_pnl, normalize_result_with_pnl,
@@ -342,16 +348,29 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz() APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
OKX_API_KEY = os.getenv("OKX_API_KEY", "")
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "") def _promote_legacy_options_api_keys() -> None:
"""1B: OKX_API_* 为空时,用废弃的 OKX_OPTIONS_API_* 回填到进程环境."""
if (os.getenv("OKX_API_KEY") or "").strip():
return
legacy_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip()
legacy_secret = (os.getenv("OKX_OPTIONS_API_SECRET") or "").strip()
legacy_pass = (os.getenv("OKX_OPTIONS_API_PASSPHRASE") or "").strip()
if not (legacy_key and legacy_secret and legacy_pass):
return
os.environ["OKX_API_KEY"] = legacy_key
os.environ["OKX_API_SECRET"] = legacy_secret
os.environ["OKX_API_PASSPHRASE"] = legacy_pass
_promote_legacy_options_api_keys()
OKX_API_KEY = normalize_api_credential(os.getenv("OKX_API_KEY"))
OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET"))
OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on") OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on")
OKX_OPTIONS_API_KEY = os.getenv("OKX_OPTIONS_API_KEY", "")
OKX_OPTIONS_API_SECRET = os.getenv("OKX_OPTIONS_API_SECRET", "")
OKX_OPTIONS_API_PASSPHRASE = os.getenv("OKX_OPTIONS_API_PASSPHRASE", "")
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10")) OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
OKX_SUB_ACCOUNT_NAME = (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip()
OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross")
OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX" EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"
@@ -466,7 +485,7 @@ os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(ORDER_CHART_DIR, exist_ok=True) os.makedirs(ORDER_CHART_DIR, exist_ok=True)
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
# 换成 OKX 永续 # 同一套 OKX_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同)
exchange = ccxt.okx({ exchange = ccxt.okx({
"enableRateLimit": True, "enableRateLimit": True,
"options": {"defaultType": "swap"}, # OKX 用 swap 表示永续 "options": {"defaultType": "swap"}, # OKX 用 swap 表示永续
@@ -486,12 +505,13 @@ exchange_options = ccxt.okx(
) )
if OKX_CCXT_PROXIES: if OKX_CCXT_PROXIES:
exchange_options.proxies = OKX_CCXT_PROXIES exchange_options.proxies = OKX_CCXT_PROXIES
if OKX_OPTIONS_API_KEY and OKX_OPTIONS_API_SECRET and OKX_OPTIONS_API_PASSPHRASE: if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE:
exchange_options.apiKey = OKX_OPTIONS_API_KEY exchange_options.apiKey = OKX_API_KEY
exchange_options.secret = OKX_OPTIONS_API_SECRET exchange_options.secret = OKX_API_SECRET
exchange_options.password = OKX_OPTIONS_API_PASSPHRASE exchange_options.password = OKX_API_PASSPHRASE
MARKETS_LOADED = False MARKETS_LOADED = False
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = { ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0, "updated_at": 0.0,
"funding_usdt": None, "funding_usdt": None,
@@ -1842,8 +1862,38 @@ def _compute_period_metrics(trades):
} }
def _bounds_for_month_key(ym):
"""ym: YYYY-MM → 该自然月首末日(北京日历)."""
y, m = [int(x) for x in str(ym).split("-", 1)]
start = f"{y:04d}-{m:02d}-01"
if m == 12:
end = f"{y:04d}-12-31"
else:
end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d")
return start, end
def _build_monthly_stats_rows(conn, all_tr, seg_key):
"""按北京交易日所在自然月聚合;新月在前."""
by_month = {}
for p, t, td in all_tr:
if not td or len(str(td)) < 7:
continue
mk = str(td)[:7]
by_month.setdefault(mk, []).append((p, t, td))
rows = []
for mk in sorted(by_month.keys(), reverse=True):
metrics = _compute_period_metrics(by_month[mk])
ms, me = _bounds_for_month_key(mk)
metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key)
metrics["range_label"] = f"{ms} ~ {me}"
metrics["month_key"] = mk
rows.append(metrics)
return rows
def compute_stats_bundle(conn, trading_day, now_dt=None): def compute_stats_bundle(conn, trading_day, now_dt=None):
"""日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入.""" """日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入."""
now_dt = now_dt or app_now() now_dt = now_dt or app_now()
pnls = _load_completed_trade_pnls(conn) pnls = _load_completed_trade_pnls(conn)
total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
@@ -1855,26 +1905,37 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day] day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day]
week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end] week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end]
month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end] month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end]
all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t]
dm = _compute_period_metrics(day_tr) dm = _compute_period_metrics(day_tr)
wm = _compute_period_metrics(week_tr) wm = _compute_period_metrics(week_tr)
mm = _compute_period_metrics(month_tr) mm = _compute_period_metrics(month_tr)
am = _compute_period_metrics(all_tr)
dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key) dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key)
wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key) wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key)
mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key) mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key)
am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", seg_key)
dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)" dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)"
wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)" wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)"
mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)" mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)"
return dm, wm, mm tds = [td for _, _, td in all_tr if td]
if tds:
am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)"
else:
am["range_label"] = "全部历史(暂无平仓)"
am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key)
return dm, wm, mm, am
segments = [] segments = []
seg_defs = effective_stats_segment_defs( seg_defs = effective_stats_segment_defs(
STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED
) )
for seg_key, seg_title, _meta in seg_defs: for seg_key, seg_title, _meta in seg_defs:
dm, wm, mm = slice_metrics(seg_key) dm, wm, mm, am = slice_metrics(seg_key)
segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm}) segments.append(
{"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am}
)
dm, wm, mm = slice_metrics("all") dm, wm, mm, am = slice_metrics("all")
return { return {
"trading_day": trading_day, "trading_day": trading_day,
@@ -1882,6 +1943,7 @@ def compute_stats_bundle(conn, trading_day, now_dt=None):
"day": dm, "day": dm,
"week": wm, "week": wm,
"month": mm, "month": mm,
"all": am,
"segments": segments, "segments": segments,
"stats_reset_hour": TRADING_DAY_RESET_HOUR, "stats_reset_hour": TRADING_DAY_RESET_HOUR,
} }
@@ -2429,6 +2491,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_okx_live_ready(): def ensure_okx_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED: if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
@@ -2461,6 +2525,23 @@ def order_row_key_signal_type(row):
return None return None
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
try:
strip_ccxt_credentials(exchange_options)
except Exception:
pass
OKX_API_KEY = ""
OKX_API_SECRET = ""
OKX_API_PASSPHRASE = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _extract_usdt_total(balance): def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {} total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2581,8 +2662,9 @@ def get_exchange_capitals(force=False):
ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding
ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
except Exception: except Exception as e:
pass if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
@@ -2673,6 +2755,7 @@ def trading_day_reset_allows_new_open(now, conn=None):
def precheck_risk(conn, symbol, direction): def precheck_risk(conn, symbol, direction):
now = app_now() now = app_now()
from lib.trade.account_risk_lib import account_risk_blocks_trading from lib.trade.account_risk_lib import account_risk_blocks_trading
from lib.trade.force_close_lib import force_close_blocks_new_open
ok_risk, risk_reason = account_risk_blocks_trading( ok_risk, risk_reason = account_risk_blocks_trading(
conn, conn,
@@ -2682,6 +2765,13 @@ def precheck_risk(conn, symbol, direction):
) )
if not ok_risk: if not ok_risk:
return False, risk_reason return False, risk_reason
fc_block, fc_note = force_close_blocks_new_open(
FORCE_CLOSE_ENABLED,
FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
)
if fc_block:
return False, fc_note or "强制清仓窗口内暂不可开仓"
if not trading_day_reset_allows_new_open(now): if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
from lib.trade.account_risk_lib import position_limit_reached from lib.trade.account_risk_lib import position_limit_reached
@@ -2775,7 +2865,7 @@ def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
def get_contract_size(exchange_symbol): def get_contract_size(exchange_symbol):
try: try:
ensure_markets_loaded() ensure_markets_loaded()
market = exchange.market(exchange_symbol) market = exchange.market(normalize_okx_symbol(exchange_symbol))
return float(market.get("contractSize") or 1) return float(market.get("contractSize") or 1)
except Exception: except Exception:
return 1.0 return 1.0
@@ -2805,7 +2895,14 @@ def build_okx_order_params(direction, reduce_only=False):
def ensure_markets_loaded(force=False): def ensure_markets_loaded(force=False):
global MARKETS_LOADED global MARKETS_LOADED
if force or not MARKETS_LOADED: if force or not MARKETS_LOADED:
exchange.load_markets(reload=force) try:
exchange.load_markets(reload=force)
except Exception as e:
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True MARKETS_LOADED = True
@@ -2921,6 +3018,8 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_
def exchange_private_api_configured(): def exchange_private_api_configured():
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
@@ -3381,29 +3480,6 @@ def ms_to_app_local_str(ms):
return app_now_str() return app_now_str()
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None."""
try:
tp = float(take_profit)
sl = float(stop_loss)
ex = float(exit_price)
trig = float(trigger_price)
except (TypeError, ValueError):
return None
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
if direction == "long":
if ex >= tp - band:
return "止盈"
if ex <= sl + band:
return "止损"
else:
if ex <= tp + band:
return "止盈"
if ex >= sl - band:
return "止损"
return None
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None."""
if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
@@ -6366,8 +6442,10 @@ def force_close_before_reset():
if not FORCE_CLOSE_ENABLED: if not FORCE_CLOSE_ENABLED:
return return
now = app_now() now = app_now()
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx) # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓
if now.hour != FORCE_CLOSE_BJ_HOUR: from lib.trade.force_close_lib import is_force_close_executing
if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)):
return return
conn = get_db() conn = get_db()
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
@@ -6565,8 +6643,8 @@ def render_main_page(page="trade", embed_mode=None):
from lib.instance.instance_embed_context_lib import ( from lib.instance.instance_embed_context_lib import (
embed_render_plan, embed_render_plan,
minimal_stats_bundle, minimal_stats_bundle,
options_funding_label,
profit_loss_ratio_from_trades, profit_loss_ratio_from_trades,
show_perp_funds_enabled,
total_funds_usdt, total_funds_usdt,
trade_records_summary, trade_records_summary,
) )
@@ -6582,22 +6660,39 @@ def render_main_page(page="trade", embed_mode=None):
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
if ( if (
OKX_OPTIONS_ENABLED OKX_OPTIONS_ENABLED
and exchange_options.apiKey and exchange_options.apiKey
and embed_mode != "fragment" and embed_mode != "fragment"
): ):
try: try:
from lib.exchange.okx_options_lib import options_header_balances from lib.exchange.okx_options_lib import options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( _op = options_header_balance_pack(exchange_options)
exchange_options options_trading_usdc = _op.get("trading_usdc")
) options_funding_usdc = _op.get("funding_usdc")
options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
except Exception: except Exception:
options_trading_usdc = None options_trading_usdc = None
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
recommended_capital = get_recommended_capital(current_capital) recommended_capital = get_recommended_capital(current_capital)
key_list = ( key_list = (
conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else []
@@ -6666,14 +6761,22 @@ def render_main_page(page="trade", embed_mode=None):
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn) risk_status = hub_account_risk_status(conn)
can_trade = can_trade_new_open( from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
_open_gate = resolve_manual_open_gate(
time_allows=trading_day_reset_allows_new_open(now, conn), time_allows=trading_day_reset_allows_new_open(now, conn),
active_count=position_limit_count, active_count=position_limit_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True), risk_status=risk_status,
force_close_enabled=FORCE_CLOSE_ENABLED,
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
reset_hour=TRADING_DAY_RESET_HOUR,
) )
can_trade = _open_gate["can_trade"]
open_block_note = _open_gate["open_block_note"]
key_rule_ctx = {} key_rule_ctx = {}
if page in ("key_monitor", "trade") or page in ( if page in ("key_monitor", "trade") or page in (
"strategy", "strategy",
@@ -6714,6 +6817,11 @@ def render_main_page(page="trade", embed_mode=None):
from lib.instance.instance_display_prefs_lib import display_prefs_template_context from lib.instance.instance_display_prefs_lib import display_prefs_template_context
_display_ctx = display_prefs_template_context(get_db) _display_ctx = display_prefs_template_context(get_db)
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
_okx_trade_mode = get_okx_trade_mode()
_hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options")
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx")
template_ctx = dict( template_ctx = dict(
page=page, page=page,
key=key_list, key=key_list,
@@ -6725,17 +6833,22 @@ def render_main_page(page="trade", embed_mode=None):
rate=rate, rate=rate,
profit_loss_ratio=profit_loss_ratio, profit_loss_ratio=profit_loss_ratio,
total_funds=total_funds_usdt( total_funds=total_funds_usdt(
funding_usdt, funding_usdt if _show_perp_funds else None,
current_capital, current_capital if _show_perp_funds else None,
options_trading_usdc, options_trading_usdc,
options_funding_usdc, options_funding_usdc,
options_funding_usdt, None,
options_trading_usdt, None,
), ),
options_funding_usdc=options_funding_usdc, options_funding_usdc=options_funding_usdc,
options_funding_usdt=options_funding_usdt, options_funding_usdt=options_funding_usdt,
options_trading_usdc=options_trading_usdc, options_trading_usdc=options_trading_usdc,
options_trading_usdt=options_trading_usdt, options_trading_usdt=options_trading_usdt,
options_funding_eth=options_funding_eth,
options_trading_eth=options_trading_eth,
options_trading_btc=options_trading_btc,
options_margin_mode=options_margin_mode,
options_underly=options_underly,
trading_day=trading_day, trading_day=trading_day,
daily_start_capital=DAILY_START_CAPITAL, daily_start_capital=DAILY_START_CAPITAL,
current_capital=current_capital, current_capital=current_capital,
@@ -6755,6 +6868,7 @@ def render_main_page(page="trade", embed_mode=None):
price_refresh_seconds=PRICE_REFRESH_SECONDS, price_refresh_seconds=PRICE_REFRESH_SECONDS,
active_count=position_limit_count, active_count=position_limit_count,
can_trade=can_trade, can_trade=can_trade,
open_block_note=open_block_note,
opens_today=opens_today, opens_today=opens_today,
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
@@ -6799,21 +6913,35 @@ def render_main_page(page="trade", embed_mode=None):
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
key_rule_ctx=key_rule_ctx, key_rule_ctx=key_rule_ctx,
funds_fmt=format_funds_u, funds_fmt=format_funds_u,
options_funding_label=options_funding_label, # options_funding_label / trading_account_label 由 embed_context_extras 注入,勿重复写进 dict
exchange_display=EXCHANGE_DISPLAY_NAME, exchange_display=EXCHANGE_DISPLAY_NAME,
options_enabled=OKX_OPTIONS_ENABLED, options_enabled=OKX_OPTIONS_ENABLED,
show_perp_funds=_show_perp_funds or (options_margin_mode == "coin"),
options_nav_visible=True, options_nav_visible=True,
hedge_plan_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), okx_trade_mode=_okx_trade_mode,
hedge_plan_nav_visible=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), options_open_allowed=_okx_trade_mode == "options",
hedge_plan_show_perp_options=os.getenv("HEDGE_PLAN_SHOW_PERP_OPTIONS", "true").lower() hedge_plan_enabled=_hedge_mode_on,
in ("1", "true", "yes", "on"), hedge_plan_nav_visible=_hedge_mode_on,
hedge_plan_show_options_options=os.getenv("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "true").lower() hedge_plan_show_perp_options=_okx_trade_mode == "perp_options",
in ("1", "true", "yes", "on"), hedge_plan_show_options_options=_okx_trade_mode == "options_options",
hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower() hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower()
in ("1", "true", "yes", "on"), in ("1", "true", "yes", "on"),
hedge_plan_option_primary=os.getenv("HEDGE_PLAN_OPTION_PRIMARY", "true").lower()
in ("1", "true", "yes", "on"),
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
options_compound_full_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_usdc=float(
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300"
),
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
options_chain_ask_liq_filter=os.getenv( options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
@@ -6978,19 +7106,38 @@ def api_account_snapshot():
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
options_index_px = None
if OKX_OPTIONS_ENABLED and exchange_options.apiKey: if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
try: try:
from lib.exchange.okx_options_lib import options_header_balances from lib.exchange.okx_options_lib import fetch_index_price, options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( _op = options_header_balance_pack(exchange_options, force=force_refresh)
exchange_options, options_trading_usdc = _op.get("trading_usdc")
force=force_refresh, options_funding_usdc = _op.get("funding_usdc")
) options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
options_index_px = fetch_index_price(exchange_options, options_underly)
except Exception: except Exception:
options_trading_usdc = None options_trading_usdc = None
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
options_index_px = None
recommended_capital = get_recommended_capital(current_capital) recommended_capital = get_recommended_capital(current_capital)
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
@@ -7001,19 +7148,31 @@ def api_account_snapshot():
active_pnl_rows = conn.execute( active_pnl_rows = conn.execute(
"SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'"
).fetchall() ).fetchall()
from lib.instance.instance_embed_context_lib import header_trade_stats_for_window, total_funds_usdt from lib.instance.instance_embed_context_lib import (
header_trade_stats_for_window,
show_perp_funds_enabled,
total_funds_usdt,
)
header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ)
conn.close() conn.close()
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
can_trade = can_trade_new_open( from lib.trade.open_trade_gate_lib import resolve_manual_open_gate
_open_gate = resolve_manual_open_gate(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=position_limit_count, active_count=position_limit_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True), risk_status=risk_status,
force_close_enabled=FORCE_CLOSE_ENABLED,
force_close_bj_hour=FORCE_CLOSE_BJ_HOUR,
now_ms=int(now.timestamp() * 1000),
reset_hour=TRADING_DAY_RESET_HOUR,
) )
can_trade = _open_gate["can_trade"]
open_block_note = _open_gate["open_block_note"]
available_trading_usdt = get_available_trading_usdt() available_trading_usdt = get_available_trading_usdt()
unrealized_pnl = None unrealized_pnl = None
@@ -7049,23 +7208,33 @@ def api_account_snapshot():
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) # 币本位期权盈亏单位为币,禁止与永续 U 混加(且 merge 只保留 2 位会把 0.0019 抹成 0)
if options_margin_mode != "coin":
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl)
except Exception: except Exception:
options_unrealized_pnl = None options_unrealized_pnl = None
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin")
return jsonify({ return jsonify({
"funding_usdt": funding_usdt, "funding_usdt": funding_usdt,
"current_capital": current_capital, "current_capital": current_capital,
"show_perp_funds": _show_perp_funds,
"options_funding_usdc": options_funding_usdc, "options_funding_usdc": options_funding_usdc,
"options_funding_usdt": options_funding_usdt, "options_funding_usdt": options_funding_usdt,
"options_trading_usdc": options_trading_usdc, "options_trading_usdc": options_trading_usdc,
"options_trading_usdt": options_trading_usdt, "options_trading_usdt": options_trading_usdt,
"options_funding_eth": options_funding_eth,
"options_trading_eth": options_trading_eth,
"options_trading_btc": options_trading_btc,
"options_margin_mode": options_margin_mode,
"options_underly": options_underly,
"options_index_px": options_index_px,
"total_funds": total_funds_usdt( "total_funds": total_funds_usdt(
funding_usdt, funding_usdt if _show_perp_funds else None,
current_capital, current_capital if _show_perp_funds else None,
options_trading_usdc, options_trading_usdc,
options_funding_usdc, options_funding_usdc,
options_funding_usdt, None,
options_trading_usdt, None,
), ),
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
"unrealized_pnl": unrealized_pnl, "unrealized_pnl": unrealized_pnl,
@@ -7074,6 +7243,7 @@ def api_account_snapshot():
"active_count": position_limit_count, "active_count": position_limit_count,
"max_active_positions": MAX_ACTIVE_POSITIONS, "max_active_positions": MAX_ACTIVE_POSITIONS,
"can_trade": can_trade, "can_trade": can_trade,
"open_block_note": open_block_note,
"opens_today": opens_today, "opens_today": opens_today,
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
@@ -7457,9 +7627,14 @@ def api_price_snapshot():
) )
options_unrealized_pnl = None options_unrealized_pnl = None
options_index_px = None
options_margin_mode = None
options_underly = None
if OKX_OPTIONS_ENABLED and exchange_options.apiKey: if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
try: try:
from lib.options.options_positions_lib import sum_options_net_pnl_usdc from lib.options.options_positions_lib import sum_options_net_pnl_usdc
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
from lib.exchange.okx_options_lib import fetch_index_price
opt_cfg = app.extensions.get("options_cfg") opt_cfg = app.extensions.get("options_cfg")
if opt_cfg: if opt_cfg:
@@ -7468,6 +7643,9 @@ def api_price_snapshot():
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
options_margin_mode = normalize_options_margin_mode()
options_underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = fetch_index_price(exchange_options, options_underly)
except Exception: except Exception:
options_unrealized_pnl = None options_unrealized_pnl = None
@@ -7478,6 +7656,9 @@ def api_price_snapshot():
"position_marks": position_marks, "position_marks": position_marks,
"positions_raw_count": len(all_swap_positions), "positions_raw_count": len(all_swap_positions),
"options_unrealized_pnl": options_unrealized_pnl, "options_unrealized_pnl": options_unrealized_pnl,
"options_index_px": options_index_px,
"options_margin_mode": options_margin_mode,
"options_underly": options_underly,
**force_close_template_context( **force_close_template_context(
FORCE_CLOSE_ENABLED, FORCE_CLOSE_ENABLED,
FORCE_CLOSE_BJ_HOUR, FORCE_CLOSE_BJ_HOUR,
@@ -9107,9 +9288,12 @@ def _dashboard_fetch_options_positions():
def _dashboard_enrich_orders(items): def _dashboard_enrich_orders(items):
from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks
return enrich_order_items_with_marks(items, get_price=get_price) return enrich_order_items_with_marks(
items, get_price=get_price, get_contract_size=get_contract_size
)
from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled
from lib.instance.instance_dashboard_register import register_instance_dashboard_routes from lib.instance.instance_dashboard_register import register_instance_dashboard_routes
register_instance_dashboard_routes( register_instance_dashboard_routes(
@@ -9118,9 +9302,13 @@ register_instance_dashboard_routes(
get_db=get_db, get_db=get_db,
fetch_options_positions=_dashboard_fetch_options_positions, fetch_options_positions=_dashboard_fetch_options_positions,
enrich_orders=_dashboard_enrich_orders, enrich_orders=_dashboard_enrich_orders,
hedge_enabled=os.getenv("HEDGE_PLAN_ENABLED", "false").lower() in ("1", "true", "yes", "on"), hedge_enabled=hedge_module_enabled,
) )
from lib.account_ledger.account_ledger_register import install_account_ledger
install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="okx")
@app.route("/api/journals") @app.route("/api/journals")
@login_required @login_required
+2 -2
View File
@@ -40,9 +40,9 @@ bash /opt/crypto_monitor/deploy/manage.sh
- 登录账号: **admin** - 登录账号: **admin**
- 登录密码: **admin123** - 登录密码: **admin123**
- 浏览器配置: 各所 **env 配置**(API,风控) + 中控 **系统设置** - 浏览器配置: 各所 **env 配置**(风控) + 中控 **系统设置**;**交易所 API 仅服务器 `.env` 手改**(新机默认为空)
**无需 SSH 编辑 `.env` 填 API**;密钥由 `bootstrap_deploy_secrets.py` 自动生成. **无需 SSH 编辑 `.env` 填通信/登录类密钥**;交易所 API 须在服务器写入各所 `.env`(新机为空).
| 地址 | 端口 | | 地址 | 端口 |
|------|------| |------|------|
@@ -0,0 +1,279 @@
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
> 状态:**已实现首版**(按本文落地;改需求先改本文).
> 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**.
> **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为).
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md)
---
## 1. 背景与动机
当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平.
实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量.
币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大.
---
## 2. 目标(首版)
1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥).
2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量).
3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT.
4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**.
5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`.
6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**.
7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持.
8. **中控不代下期权单**,但监控/快照/持仓卡片等 **只读字段须能识别币本位**(见 §7.5).
9. **不涉及 Gate** 任何业务改动.
---
## 3. 不做(首版外)
- 对冲计划(永期/期期)币本位腿或双模式混开
- 盘中按单笔切换本位(必须 env + 重启/无仓校验)
- 按权利金精确计算后再买现货(明确不做;见 §5)
- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转)
- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则)
- 多笔并行单笔期权仓(维持「一次一仓」)
- **中控代下 / 中控内嵌开平仓按钮**触发币本位或 USDC 期权下单(开平仍只在 OKX 实例页)
- **任何 Gate 相关改动**(含为「顺便统一」去动 Gate 模板或共享路径上的 Gate 分支)
---
## 4. 模式开关与互斥
### 4.1 env(草案)
| 变量 | 含义 | 默认 |
|------|------|------|
| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `coin` |
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` |
| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` |
| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) |
| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` |
| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) |
| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) |
| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 现货买入相对权利金倍数(也可写 `0.10`=+10%) | `1.10` |
开仓买币:**先按预算估最大可开张数 → 买币 USDT ≈ 张数×卖一权利金×现货缓冲**,不全额把预算换成币。
说明:
- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`.
- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`.
- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致).
### 4.2 切换门禁
| 条件 | 行为 |
|------|------|
| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc``coin` |
| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 |
| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC |
| 无仓且无半成品 | 允许改 env 并重启后生效 |
启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价.
---
## 5. 币本位资金桥与开平流水
### 5.1 开仓(先买满,再开满)
```
1. 读取交易账户 USDT 可用
2. 计算 budget_usdt(§4.1)
3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致)
4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权
- 受:最小张数、卖一深度、单笔一仓规则约束
- 不要求「币数量精确等于权利金」;允许开满后仍残留部分币
5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding
```
### 5.2 平仓(先平期权,再卖回 USDT)
```
1. 按现有纪律买一限价卖出期权(可分批深度)
2. 期权仓清零(或本轮目标完成)后:
现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT
3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利
```
### 5.3 失败回滚(必须)
| 失败点 | 处理 |
|--------|------|
| 现货买入失败 | 不开期权;报错 |
| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 |
| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 |
| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 |
---
## 6. 复利与「人工转走」
### 6.1 口径
- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算).
- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」.
- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`):
- **默认关闭** → 纯靠人工转走控规模.
- **开启** → `min(账户×0.95, N)`,防止单笔过大.
### 6.2 与永续「全仓」的关系
思想同类(吃可用 × 缓冲),但资产不同:
- 永续全仓:USDT 保证金 × 杠杆 → 合约名义
- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金
**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合.
### 6.3 一次一仓
复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品.
---
## 7. 产品与 UI
### 7.1 模式可见性
- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`.
- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态.
- USDC 模式保持现有 USDC 余额与预算展示.
### 7.2 开仓按钮文案(示例)
- 币本位:`买币并开仓(预算 ≈ xx USDT)`
- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT.
### 7.3 对冲
- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算.
- 不在此模式自动把对冲预算改成 USDT 桥.
### 7.4 复盘字段(建议)
单笔 round-trip 尽量可拆:
- 期权腿盈亏(币或折合 USDT)
- 桥兑换盈亏(买币成本 vs 卖币回收)
- 合计 USDT 变化(对复利最有意义)
首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**.
### 7.5 中控只读(要做)与不下单(不做)
中控保持现有分工:**监控只读 + 点「期权」进 OKX 实例操作**;本方案**不**在中控增加开平仓/买币桥按钮.
只读侧须能区分并展示币本位,避免仍按「一律 USDC 权利金」误读.实例上报快照/期权字段建议至少包含:
| 字段(名可调) | 含义 |
|--------------|------|
| `options_margin_mode` | `usdc` \| `coin` |
| 持仓行可辨本位 | 合约族/结算币/标签,卡片上能看出「币本位」或「USDC」 |
| 币本位时预算口径 | 可选:交易户 USDT、本轮 `×0.95` 预估预算、是否触达 N 上限 |
| 桥状态(若有半成品) | 如 `holding` / `pending_sell_spot`(待卖回 USDT),中控只展示与告警,不代执行 |
展示落点(与现网对齐即可,不新开中控交易页):
- OKX 账户监控里的期权区块 / 期权持仓卡片
- 推给教练等用的监控快照文案(若已注入期权行,须带本位标记,避免 AI/人工当成 USDC)
**不做:**中控代下单、中控触发买 ETH/卖 ETH、中控改 env 切模式.
---
## 8. 技术要点
### 8.1 合约与报价
- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径.
- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设).
- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套.
### 8.2 模块建议
| 块 | 职责 |
|----|------|
| 模式读取 + 门禁 | env、有仓拒切、启动一致性 |
| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 |
| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 |
| 定价/张数 | 币本位分支 |
| UI/API | 预算预览、确认、半成品提示 |
| 中控只读 | 消费实例快照中的 `options_margin_mode` 等字段;卡片/文案可识别币本位;**无下单 API** |
| Gate | **不纳入**;禁止为本次需求修改 Gate 树 |
现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同.
共享 `lib/options*` / 快照序列化若调整:仅扩展 OKX 期权载荷;Binance/Gate 账户快照路径保持原样.
### 8.3 权限与账户
- API 需具备:交易账户现货市价、期权开平.
- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划).
### 8.4 测试
- 预算计算:复利开/关、上限开/关、余额边界.
- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功.
- 门禁:有仓切换拒绝;一次一仓.
- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC.
- 中控只读:快照含本位字段时卡片/文案可区分 `usdc`/`coin`.
- Gate:本次 diff **不应出现** `crypto_monitor_gate/` 业务文件变更.
---
## 9. 验收标准
1. `usdc` 模式:单笔期权行为与现网一致.
2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警).
3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时).
4. 上限开关默认关;开启后预算不超过 N.
5. 有持仓或半成品时切换模式被拒绝.
6. 币本位下对冲不能误开币本位腿.
7. 开仓失败自动卖回 USDT,不留下无主现货.
8. 中控:**无**期权下单入口新增;监控/快照/持仓只读能看出当前为币本位或 USDC.
9. Gate:无相关代码改动;Gate 实例行为与改前一致.
---
## 10. 实现顺序建议
1. 模式 env + 有仓/半成品门禁 + OKX 实例 UI 展示当前模式
2. 现货桥(买/卖/回滚/待卖回) + 单测
3. 币本位合约适配 + 卖一开/买一平接入编排
4. 复利预算预览与开仓确认
5. 上限开关
6. 快照字段上报 + **中控只读识别币本位**(卡片/文案;不下单)
7. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔
---
## 11. 决策摘要(已拍板)
| 决策 | 结论 |
|------|------|
| 对冲 | 暂不接币本位 |
| 单笔模式 | env:`usdc``coin` |
| 有持仓切换 | **拒绝** |
| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) |
| 复利 | 交易账户 USDT × 0.95;人工转走控规模 |
| 单笔不超过 N U | **独立开关,默认关闭** |
| 中控 | **不下单**;只读字段/快照**能识别币本位** |
| Gate | **本次不改** |
| 动机 | 币本位流动性往往优于 USDC,利于成交 |
---
## 12. 风险与说明
- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显.
- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待.
- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准.
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
+9 -8
View File
@@ -14,6 +14,7 @@
| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | | **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 |
| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | | **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 |
| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 | | **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 |
| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` |
| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | | **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 |
| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | | **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 |
@@ -61,13 +62,13 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本
| 中文名 | 说明 | 重启 | | 中文名 | 说明 | 重启 |
|--------|------|------| |--------|------|------|
| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | | 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 |
| API Key | 永续子账户 API Key | 需重启 |
| API Secret | 永续子账户 Secret | 需重启 |
| API Passphrase | 仅 OKX 显示 | 需重启 |
| 保证金模式 | 全仓 / 逐仓 | 需重启 | | 保证金模式 | 全仓 / 逐仓 | 需重启 |
| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | | 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 |
| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | | 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 |
| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | | 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 |
| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 |
**交易所 API Key / Secret / Passphrase 不在本页**:请 SSH 编辑各所 `crypto_monitor_*/.env`,新机部署后应为空;配好真钥后重启对应 Flask 与子代理(`pm2 restart … --update-env`).占位符或错误密钥会导致鉴权失败,Gate 上反复请求还可能封 IP.
**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥. **本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥.
@@ -107,8 +108,8 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 切点前禁止新开仓 | | | 切点前禁止新开仓 | |
| 最大同时持仓 | | | 最大同时持仓 | |
| 人工最低盈亏比 | | | 人工最低盈亏比 | |
| 强制清仓开关 | | | 强制清仓开关 | `FORCE_CLOSE_ENABLED`;开启后在指定北京整点小时内,市价平掉本地 active 监控仓 |
| 强制清仓整点(北京) | | | 强制清仓整点(北京) | `FORCE_CLOSE_BJ_HOUR`(023);例 `8` 表示 08:00~08:59;仅扫监控仓,不含交易所裸仓 |
--- ---
@@ -169,8 +170,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 中文名 | 说明 | | 中文名 | 说明 |
|--------|------| |--------|------|
| 启用期权模块 | | | 启用期权模块 | 与永续共用 `OKX_API_*`;不再单独配置期权密钥 |
| 期权 API Key / Secret / Passphrase | 主账户,与永续子账户分离 |
| 期权账户备注 | | | 期权账户备注 | |
| 单笔预算(USDC) | | | 单笔预算(USDC) | |
| 预算缓冲比例 | | | 预算缓冲比例 | |
@@ -196,6 +196,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
- 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG` - 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG`
- 数据:`DB_PATH`,`UPLOAD_DIR` - 数据:`DB_PATH`,`UPLOAD_DIR`
- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空)
- 关键位门控:全部 `KEY_*`,`KLINE_*` - 关键位门控:全部 `KEY_*`,`KLINE_*`
- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*` - 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*`
- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY` - 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY`
@@ -212,7 +213,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 能力 | env 配置 | 系统设置 | 中控系统设置 | | 能力 | env 配置 | 系统设置 | 中控系统设置 |
|------|----------|----------|--------------| |------|----------|----------|--------------|
| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | | 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 |
| 交易所 API | ✅(各所自配) | ❌ | ❌ | | 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ |
| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | | AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) |
| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | | 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 |
| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | | 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ |
+15 -14
View File
@@ -186,21 +186,22 @@
- **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则). - **禁止** 盘中亏着 **手点平仓** 充当止损(破坏统计与连错规则).
- 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计. - 若违规手动平亏:**视为当日纪律失败,建议停手**;复盘结果 **不得** 记为「止损」糊弄统计.
### 9.3 时间出场:仅 0 点(程序已实现) ### 9.3 时间出场:整点强制清仓(程序已实现,可开关)
- **唯一** 时间类出场:**当日 0:00(北京时间)前必须空仓**(赚赔都平).
- **不使用** 下单表单里的 1h / 2h / 4h「开仓后 N 小时平」(`time_close`);与本策略无关. - **不使用** 下单表单里的 1h / 2h / 4h「开仓后 N 小时平」(`time_close`);与本策略无关.
- **程序兜底**(三所共用,Gate 已启用): - **程序兜底**(三所共用;Gate 可用 env 开关):
| env | 说明 | | env | 说明 |
|-----|------| |-----|------|
| `FORCE_CLOSE_ENABLED=true` | 开启整点强制清仓 | | `FORCE_CLOSE_ENABLED` | `true` 开启 / `false` 关闭整点强制清仓 |
| `FORCE_CLOSE_BJ_HOUR=0` | 北京时间 **0 点那一小时**(00:0000:59)执行 | | `FORCE_CLOSE_BJ_HOUR` | 北京时间整点小时(如 `0`=`00:0000:59`,`8`=`08:0008:59`) |
- 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用). - 实现:`force_close_before_reset()`(各实例 `app.py` 后台循环调用).
- 行为:对该小时**active**`order_monitors` **市价全平**,取消交易所触发单,写交易记录. - 行为:开启时,在该整点小时内对仍 **active**`order_monitors` **市价全平**,取消交易所触发单,写交易记录.
- **系统结果字段**:`result = 强制清仓`;备注含「北京时间 0:00 整点风控清仓」. - **系统结果字段**:`result = 强制清仓`;备注含「北京时间 X:00 整点风控清仓」.
- **策略口语「0 点平仓」= 系统「强制清仓」**,统计连错时按 §8 盈亏判定,不按字段名区分. - **仅扫本地监控仓**;交易所裸仓且无 active 监控时**不会**被此逻辑平掉.
- UI:开仓规则说明「平仓 / 委托 / 强制清仓」折叠区 + 顶栏徽章(开启时).
- 持仓卡可手动「平仓 / 委托 / 撤止盈止损」(与纪律策略并行;策略上仍不建议亏着手平充当止损).
> **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计,8 点前禁开等),**不会**自动平仓. > **与 `TRADING_DAY_RESET_HOUR=8` 无关**:后者只切 **交易日**(统计,8 点前禁开等),**不会**自动平仓.
@@ -244,16 +245,16 @@
| 项 | 说明 | | 项 | 说明 |
|----|------| |----|------|
| 日内 profile 判定 | `is_intraday_trading_profile()`(`lib/trade/entry_model_lib.py`) | | 日内 profile 判定 | `is_intraday_trading_profile()`(`lib/trade/entry_model_lib.py`) |
| 0 点强制清仓 | `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR`;`force_close_before_reset()`;结果 **`强制清仓`** | | 点强制清仓 | `FORCE_CLOSE_ENABLED` + `FORCE_CLOSE_BJ_HOUR`;`force_close_before_reset()`;结果 **`强制清仓`** |
| UI 标识 | 顶栏 **强制清仓 已开启** 徽章 + 持仓卡片 **倒计时**(三所 + 中控) | | UI 标识 | 顶栏徽章(开启时) + 开仓规则说明折叠区;持仓卡可手动平仓/委托 |
| 交易记录展示 | 三所 UI / 中控:`强制清仓` 与止损同类 badge | | 交易记录展示 | 三所 UI / 中控:`强制清仓` 与止损同类 badge |
| 三所统一 | 币安 / OKX / Gate 同一函数与 env;**将来改日内只需各所 `.env` 打开,无需改代码** | | 三所统一 | 币安 / OKX / Gate 同一函数与 env;**改日内只需各所 `.env`**,无需改代码 |
Gate 当前建议 env(节选): Gate 示例 env(节选;是否开启按账户纪律决定):
```env ```env
FORCE_CLOSE_ENABLED=true FORCE_CLOSE_ENABLED=false
FORCE_CLOSE_BJ_HOUR=0 FORCE_CLOSE_BJ_HOUR=8
TRADING_DAY_RESET_HOUR=8 TRADING_DAY_RESET_HOUR=8
``` ```
+1 -1
View File
@@ -75,7 +75,7 @@ python3 scripts/bootstrap_deploy_secrets.py
## 4. 实例 env 配置页变更 ## 4. 实例 env 配置页变更
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配. 三所 **env 配置** 页已 **移除「AI 复盘」卡片**.企业微信交易执行等仍各所自配;**交易所 API 仅服务器 `.env`**,前端不再展示.
实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入. 实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入.
@@ -0,0 +1,233 @@
# 实盘下单 · 盘口深度预览 — 开发方案
> 状态:**方案待实现**(按本文落地;改需求先改本文).
> 范围:**三所实例**实盘下单监控(Binance / OKX / Gate);中控嵌入同一表单时一并带上.
> 相关:[manual-order-rr-preview.md](./manual-order-rr-preview.md) · [position-sizing-mode.md](./position-sizing-mode.md) · 期权侧已有「卖一开 / 买一平」深度硬约束(本方案**不照搬硬挡**,首版以预览为主).
---
## 1. 背景与问题
实盘下单表单目前只展示 **标的现价/标记价**,再按止损与计仓模式算出预估风险 / 预估 RR.
- **资金小**:名义仓位通常远小于盘口前几档,市价成交贴近买卖一,现价参考够用.
- **资金大**(尤其 `POSITION_SIZING_MODE=full_margin`):名义 = 可用保证金 × 缓冲 × 杠杆,容易到数十万 U. 市价单会沿对手盘穿档,入场均价偏离「现价」后,止损距离与有效盈亏比都会偏.
典型例子:
| 条件 | 含义 |
|------|------|
| 可用约 1 万 U,20 倍杠杆,全仓 | 计划名义约 **20 万 U** |
| **市价做空** | 立刻卖出 ≈ 20 万 U 名义 → 吃 **买单(bid)** |
| **市价做多** | 立刻买入 ≈ 20 万 U 名义 → 吃 **卖单(ask)** |
用户需要的不是整本订单簿娱乐墙,而是回答:
> 当前计划名义下,对手盘前几档**能不能接住**,接住后的**预估均价 / 滑点**大概多少?
---
## 2. 目标(首版)
在「实盘下单监控」开仓区增加 **计划名义 vs 对手盘深度** 的只读预览:
1. 按当前表单算出的 **计划名义(USDT)****方向**,取对应一侧盘口.
2. 从最优档往外累加,直到累计名义 ≥ 计划名义(或盘口耗尽).
3. 展示:吃到第几档、累计可吸收名义、预估成交均价(VWAP)、相对参考价的滑点(bps 或 %).
4. **不拦截下单**(首版);可选标黄提示,见 §6.
与现有「预估风险 / 预估盈利 / 预估盈亏比」并列,作为下单前参考,不替代服务端风控与交易所真实成交.
---
## 3. 不做(首版外)
- 完整 20/50 档盘口图、深度图动画、WebSocket 持续推送盘口(首版 REST 轮询即可)
- 按深度 **自动缩仓****禁止开仓**(期权硬约束那套;列为二期,见 §10)
- 限价挂单的「挂单价到盘口距离」专项(可后加;首版聚焦市价吃单路径)
- 平仓/止损单穿档预估(开仓侧先做;平仓可二期)
- 改开仓逻辑、改计仓公式、改交易所下单路径
- 中控独立深度页或跨所聚合盘口
---
## 4. 产品规则
### 4.1 对手盘方向
| 用户方向 | 市价开仓动作 | 累加侧 |
|----------|--------------|--------|
| 做多(long) | 买入 | **卖盘 asks**(卖一 → 卖 N) |
| 做空(short) | 卖出 | **买盘 bids**(买一 → 买 N) |
### 4.2 计划名义从哪来
与现有开仓计仓一致,优先复用服务端已有 sizing 口径(避免前后端各算一套):
| 计仓模式 | 计划名义 |
|----------|----------|
| `full_margin` | `notional_value` ≈ 可用 × 缓冲 × 杠杆(与 `compute_full_margin_sizing` 一致) |
| `risk`(以损定仓) | 由风险金额与止损距离反推的仓位名义(与现开仓 `add_order` 路径一致) |
表单未填齐止损/方向/币种、或无法取可用保证金时:深度预览显示「—」,不报错打断填写.
### 4.3 参考价与滑点
- **参考价**:优先与表单现价条同一口径(标记价/最新价,跟现有 `symbol_live_price` / `order_defaults` 一致).
- **预估均价(VWAP)**:按所吃各档 `价格 × 该档名义` 加权.
- **滑点**:
- 做多: `(vwap - ref) / ref`(越正越差)
- 做空: `(ref - vwap) / ref`(越正越差)
- 展示可用 **bps**(1 bps = 0.01%)或 `%`,UI 统一一种即可(建议 bps,大单更直观).
### 4.4 盘口档数
- 请求深度建议 **520 档**(实现时三所取各自 API 稳妥上限,默认 20).
- 累加只展示「覆盖计划名义所需」的档位摘要,不必把未吃到的远档全部渲染.
- 若累加后仍 `< 计划名义`:明确写 **深度不足 / 缺口约 X U**,不要伪装成已完全覆盖.
### 4.5 文案示例(空单 20 万 U)
```
对手盘(买):买一~买4 累计约 23.1 万 U · 预估均价 63480(相对现价约 5 bps)
```
深度不足时:
```
对手盘(买):前 20 档累计约 12.4 万 U · 缺口约 7.6 万 U · 预估均价按已有档估算(仅供参考)
```
---
## 5. 界面位置
放在实盘下单开仓区、现有预览条附近,避免抢主按钮视觉:
| 区域 | 建议 |
|------|------|
| 现价条旁或下方 | 一行摘要即可(§4.5) |
| `#order-plan-preview` | 可增一项「盘口深度」或独立 `#order-depth-preview` |
| 详细档位 | 首版可不展开;若展开,仅列出已累加到的那几档(价/量/累计名义) |
小资金且滑点低于阈值时,可用灰色弱提示「前 N 档已覆盖,滑点可忽略」,避免噪音.
---
## 6. 提示阈值(软提示,不挡单)
建议可配置(`.env`,有默认值),仅影响颜色/文案:
| 变量(草案) | 含义 | 默认建议 |
|------------|------|----------|
| `MANUAL_DEPTH_WARN_BPS` | 预估滑点 ≥ 此值标黄 | `5` |
| `MANUAL_DEPTH_ALERT_BPS` | 预估滑点 ≥ 此值标红/强调 | `15` |
| `MANUAL_DEPTH_SHORTFALL_WARN` | 累计名义 < 计划名义时强调 | 开 |
首版:**不**因此 `disabled` 开仓按钮;与期权「无卖一禁止开仓」区分开.
---
## 7. 技术设计
### 7.1 API(三所各暴露,或抽到 `lib/` 共用 handler)
建议新增(名称可微调):
`GET /api/order_depth_preview`
| 参数 | 说明 |
|------|------|
| `symbol` | 与开仓表单一致 |
| `direction` | `long` / `short` |
| `sl` / `sl_pct` / `fixed_rr` / `sltp_mode` 等 | 以损定仓算名义时需要;全仓模式可只传 symbol+direction |
| 或直接传 `notional_usdt` | 若前端已从其它 preview API 拿到名义,可减少重复计算(**二选一,实现时定一种主路径**) |
响应草案:
```json
{
"ok": true,
"side": "bid",
"ref_px": 63512.3,
"plan_notional_usdt": 200000,
"covered_notional_usdt": 231000,
"shortfall_usdt": 0,
"levels_used": 4,
"vwap": 63480.0,
"slippage_bps": 5.1,
"levels": [
{"px": 63510, "sz": "...", "notional_usdt": 50000, "cum_notional_usdt": 50000}
],
"msg": ""
}
```
失败(拉盘口失败、币种无效):`ok=false` + 简短 `msg`;前端显示「深度暂不可用」,不影响开仓。
### 7.2 交易所盘口
| 所 | 合约盘口 | 注意 |
|----|----------|------|
| Binance | USD-M 深度 | 数量单位换算成 USDT 名义 |
| OKX | swap books | 同左;与期权 `fetch_option_book_depth` **分开**,勿混用期权接口 |
| Gate | futures order book | 同左 |
公共逻辑建议落在 `lib/trade/`(例如 `manual_order_depth_preview_lib.py`):输入档位列表 + 计划名义 + 方向 → 输出 VWAP / 缺口 / levels_used.
各所只负责 **拉 book + 单位换算成 USDT 名义**.
### 7.3 前端
- 共享脚本(建议):`lib/common/static/manual_order_depth_preview.js`
-`manual_order_rr_preview.js` 同样在币种/方向/止损/模式变更时 debounce 刷新
- 轮询间隔建议 3~5s(仅表单可见且字段有效时);切页或无焦点可停
- 三所 `index` / 嵌入 fragment 引入同一脚本
### 7.4 测试
- 纯函数:给定假盘口 + 名义,断言 `levels_used` / `vwap` / `shortfall`
- 方向: long 只吃 ask, short 只吃 bid
- 深度不足与刚好覆盖边界
- 不要求联调真盘口也能合入(真盘口可手工验一次 BTC/山寨对比)
---
## 8. 验收标准
1. 全仓 + 已知杠杆下,预览「计划名义」与开仓实际计仓名义同量级(允许四舍五入误差).
2. 市价空只反映买盘累加;市价多只反映卖盘累加.
3. BTC 厚盘:小名义常显示「前 1~2 档已覆盖、滑点很低」.
4. 人为放大名义或选薄流动性标的:能看到多档累加或「深度不足」.
5. 拉盘口失败时不阻断开仓按钮.
6. 中控嵌入实盘下单同样可见(与实例页同源表单).
---
## 9. 实现顺序建议
1. `lib/trade` 累加/VWAP 纯函数 + 单测
2. 一所(建议 OKX 或当前主力所)拉 book + API + 前端一行预览
3. 抽换算差异,补 Binance / Gate
4. 接入软提示阈值与文案打磨
5. 文档验收记录补进本文或 `docs/更新文档.md`
---
## 10. 二期(明确不做进首版)
| 项 | 说明 |
|----|------|
| 深度不够自动缩名义 | 类似期权 `cap_by_ask_depth` |
| 滑点超阈值二次确认 / 禁止市价 | 产品确认后再做硬门禁 |
| 平仓与止损穿档预估 | 持仓卡或平仓按钮旁 |
| WS 盘口 | 降低 REST 压力、更即时 |
| 限价开仓:挂单价相对盘口位置 | 另一套提示 |
---
## 11. 决策摘要(已拍板)
- **要做**:按计划名义展示「覆盖该名义所需」的对手盘摘要 + 预估均价/滑点.
- **做空看买单,做多看卖单**.
- **首版只展示 + 软提示,不挡单**.
- **不为小资金做整屏盘口墙**;大名义时深度预览才有关键决策价值.
@@ -0,0 +1,32 @@
# 审计修复报告 · 永期「以期权为主」(2026-08-09)
## 范围
新增 `option_primary` 子模式(UI 开关 + 后端校验/开仓/监控),保险模式路径保持不变。
## 审计发现与处置
| 级别 | 问题 | 处置 |
|------|------|------|
| High | 期权已平、永续平仓失败后监控不再重试(双腿均须 open) | 增加 `_tick_po_option_primary_pending`,仅补平永续 |
| High | 双目标触达时期权路径因买一/净利跳过,永续目标永不执行 | 期权路径失败且 `hit_perp` 时 fallthrough 永续目标 |
| High | 两腿仍 open 但期权到期无处理,裸奔永续 | `_tick_po_option_primary_both_expired` 结算期权并平永续 |
| High | 目标点数=0 开仓后易立即触发 | 校验与 `target_hit` 要求点数 **>0** |
| Medium | start 未传 leverage 时被写成 10x | 期权为主缺省杠杆 **100** |
| Medium | 服务端 `moneyness=atm` 未强制 ATM | 文档注明;UI 平值筛选仍严格;间隔门兜底 |
| Medium | 平仓永续盈亏用估价 | 已知;不阻塞平仓,统计近似 |
## 保险模式回归
- `validate_start_body``option_primary` 仍强制 Put/Call + TP/SL 几何
- `build_po_path_plan` 仅在 `option_primary` 时翻转永续方向并去掉 attach_tpsl
- `_tick_po` 仅在 `option_primary` 为假时走原 TP/SL 路径
## 测试
`python -m unittest tests.test_hedge_plan_option_primary tests.test_hedge_plan_orders tests.test_hedge_plan_moneyness -v` — 通过。
## 文档
- 新增 `docs/对冲计划-以期权为主.md`
- 更新 `docs/对冲计划-选约与虚实值.md`
@@ -0,0 +1,80 @@
# 策略与逻辑审计修复报告
- 日期: 2026-07-30
- 范围: OKX 三选一模式 / 永期·期期对冲监控与开平 / 单独期权开平 / 互斥与复盘钳制
- 准则: 以资金与仓位正确性为准(假平仓、未成交落库、跨模式拆组等)
- 复审: 共 3 轮深度复审;最终 **剩余 P0 = 0**
## 修复总览
| 轮次 | 结果 |
|------|------|
| 初审 | 约 10 项 P0 + 多项 P1(监控假平、落库≠成交、模式互斥缺口等) |
| 复审 1 | 18/20 已修;发现 SL 待平可误判 TP、监控可重复启动、单独开仓仍可缩量 |
| 复审 2 | 上述 3 项已修;剩余若干 P1 |
| 复审 3 | P1 再收口(互斥/目标监控 fail-closed、already_flat 二次验仓、监控启动锁);**P0 清零** |
## 已修复关键项(原审计编号)
### 永期监控 / 对冲平仓
- **H1** `live is None` 不再当已平;仅 `live==0` 且过开仓宽限期后处理
- **H2** 止损强平失败不写 `closed`,写 `perp_sl_pending_opt` 并重试
- **H3** TP/SL 分类:歧义偏 SL;未知跳过;`*_pending_opt` 粘滞不再被 mark 反弹改判
- **H4** `_sell_option` 改为 `close_option_by_bid1`,要求 `fully_closed``already_flat` 二次验仓
- **H7** `partial` 计划纳入监控
- **H5** IOC 部分成交后尝试立刻平掉孤儿仓
- **H6** `/start` 进程内锁 + 闸门重检
- **H8** 服务端校验 long↔Put / short↔Call 与 TP/SL 几何
- 卖一深度不足时拒绝缩量成交(对冲买入)
### 单独期权
- **O1** 开仓 IOC + `wait_option_order_full_fill`,成交后再落 `open`
- **O2** 平仓后持仓 `None` 不标 `fully_closed`
- **O3** 禁止期权页 close/target 拆对冲腿;目标监控跳过托管合约
- **O4** 模式/互斥校验异常 fail-closed
- 开仓拒绝卖一深度不足时的静默缩量
- stub 买一路径不再撤掉他人挂单;门控通过改在下单接受后标记
### 三选一模式 / UI
- **M1** Jinja 去掉 `| default(true)`,避免 `False` 显示成 Tab
- **M2** 监控线程始终启动(单独期权也收口遗留计划)+ 单例锁
- **M3** env 展示 `OKX_TRADE_MODE``get_okx_trade_mode()` 一致
- 仪表盘始终展示进行中对冲;`complete-leg` 校验当前模式
- 复盘 API 按模式钳制 `source_type`
## 测试
```text
python -m unittest tests.test_hedge_po_monitor_safety tests.test_hedge_plan_orders \
tests.test_okx_trade_mode tests.test_hedge_options_exclusive \
tests.test_hedge_plan_end tests.test_hedge_partial_manual -v
→ OK (35)
```
新增: `tests/test_hedge_po_monitor_safety.py`(含 None 跳过、分类、SL sticky
## 残留非关键项(P1,可后续迭代)
1. 连续两次持仓列表均为空时,仍可能把「短暂漏仓」当成已平(对冲路径已有二次验仓;目标/手动路径仍单次)
2. 部分成交后若孤儿平仓也失败,需人工处理(已返回 `orphan_close`
3. 期权页对托管腿仍可能显示按钮,但 API 已拒绝
## 主要改动文件
- `lib/hedge_plan/hedge_plan_monitor_lib.py`
- `lib/hedge_plan/hedge_plan_orders_lib.py`
- `lib/hedge_plan/hedge_plan_register.py`
- `lib/hedge_plan/hedge_plan_db.py`
- `lib/hedge_plan/hedge_options_exclusive_lib.py`
- `lib/hedge_plan/templates/hedge_plan_panel.html`
- `lib/options/options_close_exec_lib.py`
- `lib/options/options_register.py`
- `lib/options/options_target_lib.py`
- `lib/options/options_review_register.py`
- `lib/env/env_ui_manifest.py`
- `lib/instance/instance_dashboard_lib.py`
- `tests/test_hedge_po_monitor_safety.py`
## 部署
见本轮 commit + `zk.hyf2.cc` `deploy/pull_and_restart.sh` 结果。
@@ -0,0 +1,26 @@
# 审计修复报告:账户流水(2026-08-10)
## 范围
新增「账户流水」功能:`lib/account_ledger/*``lib/exchange/*_ledger_lib.py`、三所 `app.py` 安装、导航显示开关、前端 SSE 页。
## 结论
**可上线。** 认证、SQL、SSE 载荷范围、单实例隔离与现有实例模式一致。发现 1 项中危并已在同批修复。
## 发现与处理
| 级别 | 问题 | 处理 |
|------|------|------|
| 中 | `POST /api/account_ledger/refresh` 可把 `start_ms` 拉到极早,触发大量交易所分页请求;无冷却 | 同步窗口强制 `LOOKBACK_DAYS` 下限;手动同步默认 30s 冷却 |
| 低 | 导航关闭仍可直连 URL(与数据看板相同,仅 UI 隐藏) | 保持与现有 display pref 一致;embed `tab_allowed` 仍 403 |
| 信息 | 交易所异常文案写入 `last_error` 展示 | 可接受;未记录密钥 |
## 验证
- `python -m unittest tests.test_account_ledger_normalize` 通过
- 三所仅增加 `install_account_ledger`,不改动开仓/风控主路径
## 使用提醒
默认导航关闭;需在系统设置打开「账户流水」。数据来自交易所,首次打开可能需等待一轮后台同步或点「立即同步」。
+71
View File
@@ -0,0 +1,71 @@
# 对冲计划 · 永期「以期权为主」
> 实现日:2026-08-09 · 在现有永期**保险模式**上增加子模式,不新增 `OKX_TRADE_MODE`。
> 模式由 env `HEDGE_PLAN_OPTION_PRIMARY` 切换(默认 `true`),页面标题前显示标识,不可在页内切换。
## 1. 模式对照
| | 保险模式(`OPTION_PRIMARY=false`) | 以期权为主(`true`) |
|--|------------------|-------------------|
| UI 做多 | 永续多 + 买 Put | 买 Call + 永续空 |
| UI 做空 | 永续空 + 买 Call | 买 Put + 永续多 |
| 左卡 | 开仓价 / 张数 / TP / SL | 资金与杠杆 / 选约条件 / 出场条件 三组 |
| 右卡 | 上永续行情 · 下期权链 | 同上;仅展示间隔+类型+杠杆达标候选 |
| 选约 | 仅实值/平值 | 类型下拉(默认虚值)+间隔+杠杆门槛 |
| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **策略启动=盯盘**(status=`watching`),达标后才先期权后市价永续(**不挂**交易所 TP/SL) |
| 出场 | 交易所 TP/SL | 相对 K 的点数目标分叉 |
## 2. 左卡默认
| 字段 | 默认 |
|------|------|
| 权利金 | 用户填(USDC 预算) |
| 永续杠杆 | 100 |
| 期权杠杆 | 实/平 100;虚 200 |
| 期权:永续比例 | 实/平 2;虚 4 |
| 到期时间(最短 h) | 36 |
| 期权间隔(点) | 15 |
| 期权/永续目标位 | 相对 K 点数,须 **>0** |
## 3. 定仓
```
usable = 权利金 × 0.95
eth_qty = floor2(usable / ask) # ETH 名义,两位小数
sheets = floor(eth_qty / ct_mult) # 整张
perp_eth = eth_qty / 比例
contracts = perp_eth / contract_size
```
启动前再拉卖一重算;卖一深度不足则缩量。
## 4. 出场
触达任一目标位(做多 `index ≥ K+N`,做空 `index ≤ KN`)后立即执行:
| 触达 | 规则 |
|------|------|
| **期权目标** | 验买一流动性 + **扣费净利 > 0** → 先平期权再平永续 |
| **永续目标** | 市价平永续;期权 `hold_to_expiry` 至到期结算 |
净利:平仓/卖出手续费**按买入费率**估算(`HEDGE_PLAN_FEE_RATE` / `OKX_TAKER_FEE`,默认 0.0005)。
若期权目标因买一/净利未过、但永续目标已触达 → 改走永续目标。
期权已平永续失败 → `opt_target_perp_pending` 下轮只补平永续。
两腿仍开但期权到期 → 结算期权并平永续,避免裸奔。
## 5. 代码落点
| 文件 | 作用 |
|------|------|
| `lib/hedge_plan/hedge_plan_option_primary_lib.py` | 定仓/方向/目标/净利/校验 |
| `hedge_plan_orders_lib.py` | 路径、开平永续、启动前定仓刷新 |
| `hedge_plan_monitor_lib.py` | `_tick_po_option_primary*` |
| `hedge_plan_register.py` / `hedge_plan_db.py` | preview/start/persist`options-chain?option_primary&min_hours&strike_interval` |
| `hedge_plan.js` + `hedge_plan_panel.html` | env 模式标识、左三组参数、右上永续/右下期权 |
## 6. 测试
```bash
python -m unittest tests.test_hedge_plan_option_primary -v
```
+76
View File
@@ -0,0 +1,76 @@
# 对冲计划 · 选约与虚实值
> 实现日:2026-08-05 · 吸收 `eth_hedge_sim`(比特骆驼)选约几何;退出仍用本仓 TP/SL/S*,**不**移植仿真「净盈亏 15U 离场」。
## 1. 冻结规则
| 计划类型 | 允许虚实值 | 禁止 | 推荐模板 |
|----------|------------|------|----------|
| **永期保险** `perp_options`(开关关) | 实值、平值 | **虚值** | 距指数最近的实值/平值(做多 Put / 做空 Call) |
| **永期以期权为主** `option_primary=1` | 实值、平值、**虚值** | —(间隔+杠杆门) | 做多 Call+永续空 / 做空 Put+永续多;详见 `docs/对冲计划-以期权为主.md` |
| **期期** `options_options` | 平值、虚值 | **实值** | 平值跨式(ATM C+P);双虚值(OTM C+P) |
口径与 `lib/options/options_pricing_lib.option_moneyness` 一致:ATM 带 = `max(指数×0.2%, 2U)`
永期几何兜底(与仿真一致):
- Call 实值/平值:`K ≤ S`
- Put 实值/平值:`K ≥ S`
## 2. 代码落点
| 层 | 文件 | 作用 |
|----|------|------|
| 选约/校验库 | `lib/hedge_plan/hedge_plan_moneyness_lib.py` | `is_itm_or_atm` / `is_atm_or_otm` / `pick_*` / `recommend_oo_legs` / `validate_*` |
| 启动门禁 | `hedge_plan_orders_lib.validate_start_body` | 测算外再拦一遍;防绕过 UI 直 POST |
| 测算 | `hedge_plan_register._preview_po/_preview_oo` | 预览同样拒绝违规腿 |
| UI | `hedge_plan.js` + `hedge_plan_panel.html` | 筛选锁定、推荐按钮、选用前校验 |
| Env | `env_ui_manifest` 永期分组 | `HEDGE_PLAN_ITM_MAX_DIST_USD` / `MIN_OPTION_HOURS` / `MIN_OPTION_LEVERAGE` |
## 3. Env
| 键 | 默认 | 说明 |
|----|------|------|
| `HEDGE_PLAN_ITM_MAX_DIST_USD` | 空→沿用 `OKX_OPTIONS_ITM_MAX_DIST_USD`(常 30) | 永期过深实值上限;0=不限 |
| `HEDGE_PLAN_MIN_OPTION_HOURS` | 8 | 仅当请求带 `hours_to_expiry` 时生效 |
| `HEDGE_PLAN_MIN_OPTION_LEVERAGE` | 0 | `指数/卖一`;0=关闭 |
## 4. 可用性审计
| 项 | 结论 |
|----|------|
| 默认筛选 | 永期默认「实值/平值」;期期默认「平/虚」—减少误选 |
| 推荐一键 | 永期「推荐」;期期「推荐跨式 / 推荐双虚」—降低手选成本 |
| 文案 | 规则说明与 alert 明确禁虚(永期)/禁实(期期) |
| 服务端一致 | UI 过滤可绕过时,preview/start 仍会 400 |
| 兼容旧 API | 未传 `strike` 时从 `inst_id` 解析;未传 `index_px` 时永期用 `entry`、期期用上下破中点 |
| 以期权为主 | 见 `docs/对冲计划-以期权为主.md`:点数目标+扣费净利出场(非仿真 15U 固定);保险模式仍不接仿真净盈亏离场 |
**已知局限:**
- 链上 `moneyness` 依赖刷新时指数;剧烈跳动后需「刷新链」再选。
- `MIN_OPTION_HOURS` 需前端/调用方传入 `hours_to_expiry` 才校验(当前链行未必带该字段)。
- 期期「推荐跨式」优先 ATM,若无 ATM 会回退到最近允许档(含 OTM)。
## 5. 安全性审计
| 风险 | 控制 |
|------|------|
| 客户端改包选虚值永期保险 | `validate_start_body` + preview 服务端拒绝 |
| 客户端选实值期期腿 | 同上 |
| 过深实值权利金过贵 / 杠杆过低 | `ITM_MAX_DIST` + 可选 `MIN_OPTION_LEVERAGE` |
| 误开实盘 | 既有 `HEDGE_PLAN_LIVE_ORDER``LIVE_TRADING_ENABLED` ∩ 全仓(永期)门禁不变 |
| 保险模式平仓 | 不变:交易所 TP/SL |
| 以期权为主平仓 | 独立监控分支;不改保险模式路径 |
## 6. 测试
```bash
python -m unittest tests.test_hedge_plan_moneyness tests.test_hedge_plan_orders -v
```
覆盖:虚实值几何、永期拒 OTM、期期拒 ITM、`validate_start_body` 集成。
## 7. 与开发方案对齐
更新 `docs/对冲计划开发方案.md` §3.2 / §4.1 选约约束,与本文件一致。
+7 -4
View File
@@ -12,8 +12,8 @@
| 产品名 | 英文键 | 含义 | | 产品名 | 英文键 | 含义 |
|--------|--------|------| |--------|--------|------|
| **永期对冲** | `perp_options` | 永续(子账户) + 买方期权(主账户) | | **永期对冲** | `perp_options` | 同账户永续 + 买方期权 |
| **期期对冲** | `options_options` | 账户内两条买方期权腿 | | **期期对冲** | `options_options` | 账户内两条买方期权腿 |
页面/导航展示用中文名;API/DB 用英文键. 页面/导航展示用中文名;API/DB 用英文键.
@@ -75,6 +75,8 @@
- 行情自动拉 OKX 期权链(复用 `build_option_chain`). - 行情自动拉 OKX 期权链(复用 `build_option_chain`).
- **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**. - **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**.
- **虚实值(冻结):**仅允许 **实值或平值**,**禁止虚值**(保险腿须有内在价值或贴近平值).详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md).
- 页面默认筛「实值/平值」,提供「推荐」取距指数最近档;服务端 `validate_start_body` / preview 二次校验.
- 权利金默认按 **卖一 ask** 估算;开仓限价买入. - 权利金默认按 **卖一 ask** 估算;开仓限价买入.
### 3.3 左右布局 ### 3.3 左右布局
@@ -93,7 +95,8 @@
- **T 型报价链**(复用期权页 T 型样式/数据结构). - **T 型报价链**(复用期权页 T 型样式/数据结构).
- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). - 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部).
- 预算:`B = min(交易户 USDC × OKX_OPTIONS_BUDGET_BUFFER, OKX_OPTIONS_TRADE_BUDGET_USDC)`(默认 buffer=0.95). - **虚实值(冻结):**两腿仅允许 **平值或虚值**,**禁止实值**;推荐模板:平值跨式 / 双虚值.详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md).
- 预算:`B = min(交易户 USDC × 对冲缓冲 HEDGE_PLAN_BUDGET_BUFFER, 单笔预算)`(默认 buffer=0.95;与期权页 buffer 独立).
- 自动张数(选齐两腿后写入,可手改): - 自动张数(选齐两腿后写入,可手改):
- **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n` - **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n`
- **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改) - **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改)
@@ -217,7 +220,7 @@
| 侧 | 来源 | | 侧 | 来源 |
|----|------| |----|------|
| 永续行情/规格 | OKX 账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 | | 永续行情/规格 | OKX 账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 |
| 期权链 | `build_option_chain` / `/api/options/chain`(本实例直连,无需中控代理) | | 期权链 | `build_option_chain` / `/api/options/chain`(本实例直连,无需中控代理) |
| 指数价 | 期权 `index_px`,左右对齐 | | 指数价 | 期权 `index_px`,左右对齐 |
+2 -2
View File
@@ -9,8 +9,8 @@
| 类型 | 账户 | 作用 | | 类型 | 账户 | 作用 |
|------|------|------| |------|------|------|
| **永期对冲** | 永续子账户 + 期权主账户买方 | 全仓做方向,期权买保险 | | **永期对冲** | 同账户永续 + 期权买方 | 全仓做方向,期权买保险 |
| **期期对冲** | 仅期权主账户双买方 | 目标价兑现盈利腿,亏损腿到期 | | **期期对冲** | 账户双买方期权 | 目标价兑现盈利腿,亏损腿到期 |
### 永期结束与统计 ### 永期结束与统计
+2 -1
View File
@@ -6,7 +6,8 @@
| 标签 | 指向提交 | 说明 | | 标签 | 指向提交 | 说明 |
|------|----------|------| |------|----------|------|
| `snapshot/20260728-2` | `722c511` | 2026-07-28 午后:振幅统计改为波动点数→振幅占比、两日振幅(例25日16:00→27日16:00);去掉买跨/永期对照 | | `snapshot/20260820` | `2028251` | 2026-08-20:币本位期权开发前快照;含盘口深度预览方案、OKX单笔期权币本位+USDT桥+复利开发方案;对冲暂不接币本位 |
| `snapshot/20260728-2` | `05864d7` | 2026-07-28 午后:振幅统计改为波动点数→振幅占比、两日振幅(例25日16:00→27日16:00);去掉买跨/永期对照 |
| `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 | | `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 |
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 | | `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
| `snapshot/20260726-2` | `4a79e01` | 2026-07-26 午:执行手册脑图(业务主题)、`.xmind` 按二进制入库、去掉缩略图避免 Gitea raw 换行损坏 | | `snapshot/20260726-2` | `4a79e01` | 2026-07-26 午:执行手册脑图(业务主题)、`.xmind` 按二进制入库、去掉缩略图避免 Gitea raw 换行损坏 |
+30
View File
@@ -4,6 +4,36 @@
--- ---
## 2026-08-20 · OKX 单笔期权币本位 + USDT 桥 + 复利
### 修改原因
币本位期权流动性往往好于 USDC;操作者仍用 USDT 思考本金。需 env 切换本位、自动 USDT↔币桥、交易户 USDT×0.95 复利;对冲仍仅 USDC;中控只读识别本位;不改 Gate。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `lib/options/options_margin_mode_lib.py` | 本位/合约族/USDT 预算/按币算张数 |
| `lib/options/options_spot_bridge_lib.py` | 买币/卖回/桥状态表/回滚 |
| `lib/options/options_coin_open_lib.py` | 买满→开满编排;平后卖回 |
| `options_register` / `okx_options_lib` / close_exec | 链族切换、开平接入、retry-sell |
| `options_hub_lib` + 中控 `app.js` / AI context | 只读字段识别币本位 |
| `hedge_plan_register` | 币本位禁止开对冲 |
| `env_*` / `.env.example` | 新 env;MARGIN_MODE 需重启;有仓拒切 |
| `docs/OKX单笔期权-币本位与USDT桥-开发方案.md` | 方案(已有) |
### 交付之后的验收
1. `OKX_OPTIONS_MARGIN_MODE=usdc` 行为与现网一致.
2. `=coin` 时链为 `ETH-USD`(非 `_UM`);开仓走买币再开期权;失败回滚卖币.
3. 平仓清空后卖回本桥币量;失败可 `POST /api/options/spot-bridge/retry-sell`.
4. 预算默认交易户 USDT×0.95;上限开关默认关.
5. 中控期权卡显示本位标签;无下单.
6. Gate 无改动.
---
## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复 ## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复
### 修改原因 ### 修改原因
+4 -4
View File
@@ -1,15 +1,15 @@
# 期权对冲方案分析 # 期权对冲方案分析
> 适用范围:OKX **永续子账户**(USDT 本位) + **期权主账户**(USDⓈ 本位买方). > 适用范围:OKX **同一账户**(`OKX_API_*`):USDT 永续 + USDⓈ 期权买方.
> 本文档为 **策略与操盘说明**,非系统自动下单功能;组合须 **人工** 在永续页与期权页分别执行. > 本文档为 **策略与操盘说明**,非系统自动下单功能;组合须 **人工** 在永续页与期权页分别执行.
--- ---
## 1. 前提与账户分工 ## 1. 前提与账户分工
| 维度 | 永续合约(子账户) | 期权(主账户) | | 维度 | 永续合约 | 期权 |
|------|------------------|--------------| |------|----------|------|
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` | | API | `OKX_API_*`(swap 客户端) | `OKX_API_*`(option 客户端) |
| 系统页面 | 实盘下单 / 关键位 / 策略 | 期权 | | 系统页面 | 实盘下单 / 关键位 / 策略 | 期权 |
| 保证金 | USDT | USDC / USDG | | 保证金 | USDT | USDC / USDG |
| 本系统能力 | 开平仓、止损、关键位 | **仅买方** 开平仓,无组合单 | | 本系统能力 | 开平仓、止损、关键位 | **仅买方** 开平仓,无组合单 |
+16 -7
View File
@@ -47,6 +47,13 @@
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时. - 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂. - 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
### 2.4 翻倍出场(可选)
- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
- 可随时关闭;与目标位监控并行,谁先达标谁平.
- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
--- ---
## 3. 监控逻辑 ## 3. 监控逻辑
@@ -58,19 +65,21 @@
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 | | 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) | | 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | | 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 | | 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 |
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | | 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
--- ---
## 4. 平仓校验(门控) ## 4. 平仓校验(门控)
| 门控 | 手动买一平 | 目标自动平 | 说明 | | 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 |
|------|------------|------------|------| |------|------------|------------|----------|------|
| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 | | 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 | | 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 |
| 锁定买一价 | | ✅ | 下单价 = 通过校验时的买一 | | 回收 ≥ 权利金×(1+倍数) | ❌ | | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 |
| 市价兜底 | | | 永不市价 | | 锁定买一价 | | | ✅ | 下单价 = 通过校验时的买一 |
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
--- ---
+26 -99
View File
@@ -1,15 +1,15 @@
# OKX 期权模块 — 技术方案 # OKX 期权模块 — 技术方案
> 适用范围:`crypto_monitor_okx` 实例;永续子账户并行,不新增 PM2 进程. > 适用范围:`crypto_monitor_okx` 实例;永续与期权共用同一套 `OKX_API_*`,不新增 PM2 进程.
## 1. 目标 ## 1. 目标
在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力: 在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力:
- 永续/关键位:继续走 **子账户 API-A**(现有 `OKX_API_*`) - 永续/关键位与期权:**同一账户 API**(`OKX_API_*`)
- 期权:走 **主账户 API-B**(`OKX_OPTIONS_API_*`) - 两个 ccxt 客户端:`exchange`(defaultType=swap)与 `exchange_options`(defaultType=option),身份相同
- 资金展示对齐 OKX:**资金账户 / 交易账户**,分币种显示 USDT,USDC,USDG - 顶栏资金:**资金账户/交易账户=USDT**;**期权资金/期权交易=USDC**
- 支持 **手动 USDT→USDC 兑换****USDC 账户划转** - 支持 **手动 USDT→USDC 兑换****账户划转**(无主↔子划转)
- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC) - **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC)
## 2. 交易规则(硬约束) ## 2. 交易规则(硬约束)
@@ -32,8 +32,8 @@
``` ```
crypto_okx(单 PM2) crypto_okx(单 PM2)
├── exchange (swap) ← OKX_API_* 子账户 ├── exchange (swap) ← OKX_API_*
└── exchange_options ← OKX_OPTIONS_API_* 主账户 └── exchange_options (option)← 同一套 OKX_API_*
lib/options/ lib/options/
├── okx_options_lib.py # 封装于 lib/exchange/ ├── okx_options_lib.py # 封装于 lib/exchange/
@@ -43,109 +43,36 @@ lib/options/
└── options_register.py # 路由 + 监控线程 └── options_register.py # 路由 + 监控线程
``` ```
**隔离:** 期权模块只调用 `exchange_options`;永续逻辑只调用 `exchange`. **说明:** `defaultType` 分离避免错路由;密钥身份唯一.旧 `OKX_OPTIONS_API_*` 已废弃.
## 4. 资金与兑换 ## 4. 资金与兑换
### 4.1 展示(期权页顶栏) ### 4.1 展示(实例顶栏)
| 账户 | 币种 | - **资金账户 / 交易账户**:USDT(永续侧)
|------|------| - **期权资金账户 / 期权交易账户**:USDC
| 资金账户 | USDT,USDC(若有) | - 总资金:USDT + USDC(1:1),同账户 USDT 不重复累加期权侧 USDT
| 交易账户 | USDT,USDC,USDG(若有) |
不展示「练手池」等抽象记账名称. ### 4.2 兑换与划转
### 4.2 推荐操作流程 - 系统设置「币种兑换」:资金账户内 USDT ↔ USDC
- 「期权划转」:同账户 funding ↔ trading(USDC/USDT)
- **已移除**主↔子账户划转
``` ## 5. 环境变量(要点)
资金账户 USDT
→ [手动兑换 USDT→USDC](OKX Convert API,资金账户内)
→ [划转到交易账户](USDC)
→ 交易账户 USDC
→ [限价买入期权]
```
### 4.3 API
| 接口 | OKX |
|------|-----|
| 余额 | `fetch_balance`(funding / trading)+ `GET /api/v5/asset/balances` |
| 询价兑换 | `POST /api/v5/asset/convert/estimate-quote` |
| 确认兑换 | `POST /api/v5/asset/convert/trade` |
| 划转 | `exchange.transfer(ccy, amt, from, to)` |
## 5. 配置项(`.env`)
```bash ```bash
OKX_OPTIONS_ENABLED=false OKX_API_KEY=
OKX_OPTIONS_API_KEY= OKX_API_SECRET=
OKX_OPTIONS_API_SECRET= OKX_API_PASSPHRASE=
OKX_OPTIONS_API_PASSPHRASE= OKX_OPTIONS_ENABLED=true
OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权
OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
OKX_OPTIONS_MAX_DTE_DAYS=2
OKX_OPTIONS_ITM_MAX_DIST_USD=30
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=cross
# 市价平仓已在代码中硬关闭,此变量无效,可删
# OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
# 平仓限价挂单超时自动撤(秒),默认 600=10 分钟;联调可临时改 60
OKX_OPTIONS_PENDING_TTL_SECONDS=600
``` ```
平仓执行:**只锁买一限价**,说明见 [期权开平仓与监控说明.md](./期权开平仓与监控说明.md);线上 `/options/guide`. 详见 [env配置说明.md](./env配置说明.md) 与 `.env.example`.
修改 `.env` 后须 `pm2 restart crypto_okx`. ## 6. 相关文档
## 6. 数据库 - [期权用法.md](./期权用法.md)
- [对冲计划开发方案.md](./对冲计划开发方案.md)
### `options_trades`
记录本地开仓/平仓,权利金,翻倍提醒状态.
### `options_convert_log` / `options_transfer_log`
可选记录兑换与划转操作.
## 7. HTTP 路由
| 方法 | 路径 |
|------|------|
| GET | `/options` |
| GET | `/options/guide` | 开平仓与监控说明(独立页) |
| GET | `/api/options/balances` |
| GET | `/api/options/chain` |
| GET | `/api/options/quote` |
| POST | `/api/options/open` |
| POST | `/api/options/close` |
| POST | `/api/options/convert/quote` |
| POST | `/api/options/convert/execute` |
| POST | `/api/options/transfer` |
| GET | `/api/options/positions` |
## 8. 分阶段交付
1. **基础设施**:双 API,余额,文档,设置页说明
2. **兑换 + 划转**:资金账户 USDT→USDC,划转到交易户
3. **交易**:链,报价,开平仓,持仓
4. **监控**:翻倍微信提醒
## 9. 不在一期范围
- 卖方,组合单,RFQ
- 自动 USDT↔USDC
- `manual-agent-okx` / 中控聚合
- 币本位期权
## 10. 安全
- 期权 API:**交易 + 读**,禁止提币
- 日志不输出 Secret
- 下单前校验 `client is exchange_options`
+23 -82
View File
@@ -2,37 +2,37 @@
## 1. 前置条件 ## 1. 前置条件
1. OKX **主账户**已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`. 1. OKX 账户已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`.
2.`crypto_monitor_okx/.env` 配置 **期权专用 API**(永续子账户分开): 2.`crypto_monitor_okx/.env` 配置 **唯一账户 API**(永续与期权共用):
```bash ```bash
OKX_API_KEY=你的账户Key
OKX_API_SECRET=...
OKX_API_PASSPHRASE=...
OKX_OPTIONS_ENABLED=true OKX_OPTIONS_ENABLED=true
OKX_OPTIONS_API_KEY=你的主账户Key
OKX_OPTIONS_API_SECRET=...
OKX_OPTIONS_API_PASSPHRASE=...
``` ```
3. 重启实例:`pm2 restart crypto_okx` 3. 重启实例:`pm2 restart crypto_okx --update-env`
> 永续仍用原有 `OKX_API_*`(子账户);期权只用 `OKX_OPTIONS_API_*`(主账户). > 旧 `OKX_OPTIONS_API_*` 已废弃.若仅残留 OPTIONS 键而 `OKX_API_*` 为空,启动会自动回填.
## 2. 资金准备 ## 2. 资金准备
期权权利金使用 **USDC 或 USDG**,不能直接用 USDT 买入. 期权权利金使用 **USDC**,不能直接用 USDT 买入.
### 推荐步骤 ### 推荐步骤
1. 打开 **期权** 页,查看顶栏: 1. 查看顶栏:
- **资金账户**:USDT 余额 - **资金账户 / 交易账户**:USDT
- **交易账户**:USDC 余额(买期权从这里扣) - **期权资金账户 / 期权交易账户**:USDC
2. **币种兑换**(资金账户内) 2. **币种兑换**(资金账户内)
- 从 USDT 兑换为 USDC - 从 USDT 兑换为 USDC
- 先点 **询价**,确认预估获得量后点 **确认兑换** - 先点 **询价**,确认预估获得量后点 **确认兑换**
3. **账户划转** 3. **账户划转**
- 从:资金账户 → 到:交易账户 - 从:资金账户 → 到:交易账户
- 币种:USDC - 币种:USDC
- 将兑换得到的 USDC 划到交易账户 - 将兑换得到的 USDC 划到交易账户
4. 确认 **交易账户 USDC** 足够支付本笔权利金 4. 确认 **期权交易账户 USDC** 足够支付本笔权利金
系统 **不会** 自动兑换或划转,避免误动资金. 系统 **不会** 自动兑换或划转,避免误动资金.
@@ -85,82 +85,23 @@ OKX_OPTIONS_API_PASSPHRASE=...
## 6. 与永续 / 对冲计划的关系 ## 6. 与永续 / 对冲计划的关系
| | 永续(子账户) | 期权(主账户) | | | 永续 | 期权 |
|--|----------------|----------------| |--|------|------|
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` | | API | `OKX_API_*`(同一套) | `OKX_API_*`(同一套) |
| 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 | | 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 |
| 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 | | 顶栏 | USDT 资金户+交易户 | USDC 期权资金+期权交易 |
两套资金 **不合并** 显示.
**期期对冲张数**(对冲计划页,与单独开期权共用预算算法): **期期对冲张数**(对冲计划页,与单独开期权共用预算算法):
| 模式 | 说明 | | 模式 | 说明 |
|------|------| |------|------|
| 同张数(默认) | 两腿同 `n`,总权利金 ≤ 预算 | | 同张数(默认) | 两腿同 `n`,总权利金 ≤ 预算 |
| 做多 | Call:Put 按主腿占比(默认 7:3) | | 按比例 | 主腿/次腿按 `HEDGE_PLAN_OO_BIAS_*` |
| 做空 | Put:Call 按主腿占比(默认 7:3) |
拆分口径与比例见 env:`HEDGE_PLAN_OO_BIAS_SPLIT_BY`(`budget` 默认 / `sheets`=先算同张数总张数 `2n` 再拆)、`HEDGE_PLAN_OO_BIAS_RATIO`(默认 `0.7`)。细则见 [对冲计划开发方案.md](./对冲计划开发方案.md) §4.1、[系统说明.md](./系统说明.md)。 ## 7. 常见问题
## 7. 配置说明 **Q:以前的期权专用密钥还要配吗?**
- 不需要.统一写到 `OKX_API_*`.
| 变量 | 默认 | 含义 | **Q:还能主↔子划转吗?**
|------|------|------| - 已移除.只保留同账户内划转与币种兑换.
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | 10 | 单笔权利金上限 |
| `OKX_OPTIONS_BUDGET_BUFFER` | 0.95 | 算张数时预留 5% 缓冲 |
| `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 |
| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD |
| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 |
| `HEDGE_PLAN_OO_BIAS_SPLIT_BY` | budget | 期期做多/做空:按预算或按张数拆 |
| `HEDGE_PLAN_OO_BIAS_RATIO` | 0.7 | 期期做多/做空主腿占比 |
## 8. 期权复盘(含对冲)
**OKX** 实例提供独立页 **期权复盘**(`/options/review`),与合约「交易记录与复盘」完全隔离.
### 数据来源
| 类型 | source_type | 来源 | 粒度 |
|------|-------------|------|------|
| 纯期权 | `option_spot` | 本地 `options_trades` 已平仓 | 一仓一条 |
| 永期对冲 | `perp_options` | 本地 `hedge_plans``status=closed` | **一计划一条** |
| 期期对冲 | `options_options` | 同上 | **一计划一条** |
- 打开复盘页即自动读取本地记录,**不访问交易所**.
- 对冲盈亏主口径:`realized_pnl_total`;详情另显永续/期权分项.
- 若某纯期权 `inst_id` 已出现在对冲腿中,默认标记排除,避免总盈亏双计.
- 人工复盘字段存在 `options_review_entries`,刷新本地源**不会覆盖**.
### 图片
- 目录:`static/images/options_journal/`
- 文件名:`options_journal_{draftId}_{5m|15m|1h|4h}.ext`(与合约复盘同周期槽位)
- 备份时与 `crypto.db` 一并打包即可;勿与合约 `journal_*` 截图混用.
### 页面
顶部三个 Tab:**期权交易记录** / **期期对冲记录** / **永期对冲记录**.点击列表行后在下方打开「复盘记录上传」,支持四周期即时截图与情绪标签.
### 统计
同页 KPI + 分组:类型、标的、策略标签、对冲结束原因、持有周期、Call/Put.策略维度仅统计已填策略标签的记录.
## 9. 常见问题
**Q:为什么买不了?**
- 交易账户 USDC 不足 → 先兑换再划转
- 卖一价过高,10U 预算买不到 1 张 → 选更便宜合约或提高 `OKX_OPTIONS_TRADE_BUDGET_USDC`
- 期权 API 未配置或 `OKX_OPTIONS_ENABLED=false`
**Q:报价 15 是每张 15U 吗?**
- 不是.15 是 **每 1 ETH** 的报价;每张(0.01 ETH)约 0.15 USDC.
**Q:子账户能开期权吗?**
- 本系统期权走主账户 API;子账户永续不受影响.
## 10. 风险说明
- 买方最大亏损为 **权利金**;近期实值仍会时间衰减
- 限价单可能因无流动性未成交
- 请先在小额下验证兑换,划转,开平仓全流程
+38 -12
View File
@@ -65,6 +65,19 @@ API`POST /api/calculator/perp-options`
组合净利 = 期权净利 + 永续亏损 组合净利 = 期权净利 + 永续亏损
``` ```
**C · 横盘(最大亏损)**
波动≈0、期权到期无内在价值:
```text
永续盈亏 ≈ 0
永续开平手续费 = 2 × 现价 × 1 × 0.05% (同价开平)
最大亏损 = 权利金总额 + 永续开平手续费
组合净利 = −最大亏损
```
忽略资金费 / Theta 过程中的中间态;口径与「权利金按全亏」一致。
### 手测示例 ### 手测示例
现价 1800、波动 50 点、目标盈利 15、期权杠杆 100、永续杠杆 10: 现价 1800、波动 50 点、目标盈利 15、期权杠杆 100、永续杠杆 10:
@@ -80,16 +93,19 @@ API`POST /api/calculator/perp-options`
--- ---
## 模式二:由比例推波动点数(`calc_mode=points` ## 模式二:由币数推波动点数(`calc_mode=points`
已知永续:期权比例(如 **1:2**)、目标盈利、期权杠杆 → 反推两套情景要涨/跌多少点才能达到目标。 已知永续币数 / 期权币数(如 **1:2****2:4**)、目标盈利、期权杠杆 → 反推两套情景要涨/跌多少点才能达到目标。
**按绝对币数**,不再把输入归一到「永续 1 币」。填 2 与 4 → 永续 2 币 + 期权 4 币(权利金、保证金、手续费均按 2 倍于 1:2 放大;达同一目标盈利所需点数会变小)。
### 仓位 ### 仓位
```text ```text
永续币数 = 1 永续币数 = 输入的永续币数
期权币数 = 1 × (期权比例 / 永续比例) # 1:2 → 2 币 期权币数 = 输入的期权币数
权利金总额 = 期权币数 × (现价 / 期权杠杆) 权利金总额 = 期权币数 × (现价 / 期权杠杆)
永续保证金 = 现价 × 永续币数 / 永续杠杆
``` ```
### 情景 A · 永续方向对 ### 情景 A · 永续方向对
@@ -97,10 +113,10 @@ API`POST /api/calculator/perp-options`
净利 = 目标盈利: 净利 = 目标盈利:
```text ```text
move 权利金 fee(move) = 目标 qty×move 权利金 fee(move,qty) = 目标
fee(move) = (2×现价 + move) × 0.05% fee = (2×现价 + move) × qty × 0.05%
move = (目标 + 权利金 + 2×现价×0.05%) / (1 0.05%) move = (目标 + 权利金 + 2×现价×qty×0.05%) / (qty × (1 0.05%))
``` ```
### 情景 B · 期权方向对(以组合净利为准) ### 情景 B · 期权方向对(以组合净利为准)
@@ -108,24 +124,34 @@ move = (目标 + 权利金 + 2×现价×0.05%) / (1 0.05%)
组合净利 = 目标盈利: 组合净利 = 目标盈利:
```text ```text
组合 = 期权币数×move − 权利金 − 1×move 组合 = 期权币数×move − 权利金 − 永续币数×move
= move×(期权币数 − 1) 权利金 = move×(期权币数 − 永续币数) 权利金
move = (目标 + 权利金) / (期权币数 − 1) move = (目标 + 权利金) / (期权币数 − 永续币数)
``` ```
要求期权币数 > 永续币数(比例须使期权侧更重,如 1:2);若为 1:1,组合恒为 −权利金,无法解出正目标。 要求期权币数 > 永续币数;若相等,组合恒为 −权利金,无法解出正目标。
结果区展示:所需波动点数(及折合%)、组合净利、其中期权净利、其中永续盈亏。 结果区展示:所需波动点数(及折合%)、组合净利、其中期权净利、其中永续盈亏。
### 手测示例 ### 手测示例
现价 1800、目标 15、期权杠杆 100、比例 1:2 → 权利金总额 36U 现价 1800、目标 15、期权杠杆 100、币数 1:2 → 权利金总额 36U
| 情景 | 所需点数(约) | | 情景 | 所需点数(约) |
|------|----------------| |------|----------------|
| A 永续方向对(净利=15 | ≈52.83 | | A 永续方向对(净利=15 | ≈52.83 |
| B 组合净利=15 | 51.00 | | B 组合净利=15 | 51.00 |
| C 横盘最大亏损 | 37.80(权利金 36 + 同价开平费 1.8) |
币数 **2:4**(权利金 72U、保证金 360U):
| 情景 | 约值 |
|------|------|
| 仓位 | 永续 2 币 / 期权 4 币(400 张) |
| A 所需点数 | ≈45.32 |
| B 组合达目标 | 43.50 |
| C 横盘最大亏损 | 75.60 |
--- ---
+2 -2
View File
@@ -66,7 +66,7 @@
| 网页登录密码 | ✅ | 本区块 | | 网页登录密码 | ✅ | 本区块 |
| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | | 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 |
| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | | 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 |
| 交易所 API | ❌ | **env 配置** 页(各所自配) | | 交易所 API | ❌ | **仅服务器** 各所 `.env`(`*_API_KEY` 等;前端 env 页已移除) |
| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) | | AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) |
### 操作流程 ### 操作流程
@@ -84,7 +84,7 @@
### 用途 ### 用途
**子账户永续** 场景下,于 **资金账户(funding)****交易账户(swap)** 之间手动划转 USDT. **OKX 账户** 场景下,于 **资金账户(funding)****交易账户(swap)** 之间手动划转 USDT.
### 与 env 配置的关系 ### 与 env 配置的关系
+71
View File
@@ -0,0 +1,71 @@
# 账户流水(三所统一)
从**交易所 API**拉取资金账户与交易账户账单,在实例内展示。
不使用程序本地 `transfer_logs` 作为主数据源。
## 能力概览
| 项 | 说明 |
|----|------|
| 导航 | 「账户流水」Tab;默认关闭,在 **系统设置 → 导航显示** 打开 |
| Tab | **资金账户** / **交易账户** |
| 分页 | 每页 10 条,时间倒序 |
| 时间窗 | 跟随顶栏 UTC **预设**(与列表 `list_window` 一致) |
| 同步 | 后台约 **120s** 拉一次交易所;完成后 **SSE** 推版本,前端自动刷新 |
| 币种 | USDTOKX 另含 **USDC** |
| 三所 | Binance / OKX / Gate 同一套 UI 与路由 |
## 使用
1. 系统设置 → 导航显示 → 勾选「账户流水」→ 保存
2. 顶栏选预设时间并点「应用」
3. 打开「账户流水」,切换资金/交易 Tab;可点「立即同步」
## API
| 路由 | 说明 |
|------|------|
| `GET /api/account_ledger?account=funding\|trading&page=1` | 按当前 session 时间窗分页查询缓存 |
| `GET /api/account_ledger/stream` | SSE`event: ledger`,载荷含 `ledger_version` |
| `POST /api/account_ledger/refresh` | 手动触发同步(有冷却,默认 30s) |
均需登录(与实例其他 API 相同)。
## 交易所数据源
| 所 | 资金账户 | 交易账户 |
|----|----------|----------|
| Gate | spot `account_book`USDT | USDT 永续 `account_book` |
| OKX | `asset/bills`USDT+USDC | `account/bills` + `bills-archive`USDT+USDC |
| Binance | 充提 + `fetch_transfers`USDT | U 本位 `fapi` incomeUSDT |
后台默认回看 **90 天**`ACCOUNT_LEDGER_LOOKBACK_DAYS`),写入本地 SQLite 缓存后再按顶栏时间窗过滤展示。
「全部 / 近 6 月」等超出回看窗口的部分,仅能看到缓存内数据。
## 环境变量(可选)
| 变量 | 默认 | 说明 |
|------|------|------|
| `ACCOUNT_LEDGER_POLL_SEC` | `120` | 后台轮询秒数 |
| `ACCOUNT_LEDGER_LOOKBACK_DAYS` | `90` | 拉取与手动同步上限天数 |
| `ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC` | `30` | 手动同步冷却 |
| `ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC` | `25` | SSE 心跳 |
## 代码位置
```
lib/account_ledger/ # DB / 同步 / SSE / 注册 / 面板
lib/exchange/*_ledger_lib.py # 三所拉取适配
lib/common/static/account_ledger.js
```
三所 `app.py` 调用:`install_account_ledger(..., exchange_key=...)`
## 审计摘要(2026-08-10
- 路由均 `@login_required`;SSE 仅推版本号,不含账单正文
- SQL 参数化;`account` 白名单;币种服务端固定
- 前端表格字段 `escapeHtml`
- **已修复**:手动同步强制套用 lookback 上限 + 冷却,避免滥用刷交易所 API
详见同目录旁注或 PR 说明;安全复查子代理结论:修复后无未关闭的中高危项。
+1
View File
@@ -0,0 +1 @@
"""实例账户流水(交易所资金/交易账户账单)."""
+186
View File
@@ -0,0 +1,186 @@
"""账户流水 SQLite 缓存."""
from __future__ import annotations
import time
from typing import Any, Optional
from lib.account_ledger.account_ledger_normalize import PAGE_SIZE, VALID_ACCOUNTS
def ensure_account_ledger_tables(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS account_ledger_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account TEXT NOT NULL,
ccy TEXT NOT NULL,
amount REAL NOT NULL,
balance_after REAL,
kind TEXT,
raw_type TEXT,
symbol TEXT,
ref_id TEXT NOT NULL,
ts_ms INTEGER NOT NULL,
note TEXT,
synced_at REAL,
UNIQUE(account, ref_id, ccy, ts_ms)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_account_ledger_acc_ts "
"ON account_ledger_entries(account, ts_ms DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS account_ledger_meta (
key TEXT PRIMARY KEY,
value TEXT
)
"""
)
conn.commit()
def meta_get(conn, key: str, default: str = "") -> str:
row = conn.execute(
"SELECT value FROM account_ledger_meta WHERE key=?", (key,)
).fetchone()
if not row:
return default
try:
return str(row[0] if not hasattr(row, "keys") else row["value"])
except Exception:
return default
def meta_set(conn, key: str, value: str) -> None:
conn.execute(
"INSERT INTO account_ledger_meta(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, str(value)),
)
def upsert_entries(conn, rows: list[dict[str, Any]]) -> int:
if not rows:
return 0
now = time.time()
n = 0
for r in rows:
try:
conn.execute(
"""
INSERT INTO account_ledger_entries(
account, ccy, amount, balance_after, kind, raw_type,
symbol, ref_id, ts_ms, note, synced_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(account, ref_id, ccy, ts_ms) DO UPDATE SET
amount=excluded.amount,
balance_after=excluded.balance_after,
kind=excluded.kind,
raw_type=excluded.raw_type,
symbol=excluded.symbol,
note=excluded.note,
synced_at=excluded.synced_at
""",
(
r["account"],
r["ccy"],
float(r["amount"]),
r.get("balance_after"),
r.get("kind") or "other",
r.get("raw_type") or "",
r.get("symbol") or "",
r["ref_id"],
int(r["ts_ms"]),
r.get("note") or "",
now,
),
)
n += 1
except Exception:
continue
conn.commit()
return n
def query_entries(
conn,
*,
account: str,
start_ms: int,
end_ms: int,
page: int = 1,
page_size: int = PAGE_SIZE,
currencies: Optional[list[str]] = None,
) -> dict[str, Any]:
acc = (account or "").strip().lower()
if acc not in VALID_ACCOUNTS:
return {"items": [], "total": 0, "page": 1, "page_size": page_size, "pages": 0}
page = max(1, int(page or 1))
page_size = max(1, min(50, int(page_size or PAGE_SIZE)))
start_ms = int(start_ms)
end_ms = int(end_ms)
params: list[Any] = [acc, start_ms, end_ms]
ccy_sql = ""
if currencies:
ccy_list = [c.strip().upper() for c in currencies if c and str(c).strip()]
if ccy_list:
placeholders = ",".join("?" for _ in ccy_list)
ccy_sql = f" AND ccy IN ({placeholders})"
params.extend(ccy_list)
total = conn.execute(
f"SELECT COUNT(*) FROM account_ledger_entries "
f"WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}",
params,
).fetchone()[0]
total = int(total or 0)
pages = (total + page_size - 1) // page_size if total else 0
if pages and page > pages:
page = pages
offset = (page - 1) * page_size
rows = conn.execute(
f"""
SELECT account, ccy, amount, balance_after, kind, raw_type, symbol,
ref_id, ts_ms, note
FROM account_ledger_entries
WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}
ORDER BY ts_ms DESC, id DESC
LIMIT ? OFFSET ?
""",
params + [page_size, offset],
).fetchall()
items = []
for r in rows:
if hasattr(r, "keys"):
d = {k: r[k] for k in r.keys()}
else:
d = {
"account": r[0],
"ccy": r[1],
"amount": r[2],
"balance_after": r[3],
"kind": r[4],
"raw_type": r[5],
"symbol": r[6],
"ref_id": r[7],
"ts_ms": r[8],
"note": r[9],
}
from lib.account_ledger.account_ledger_normalize import kind_label_zh
d["kind_label"] = kind_label_zh(d.get("kind") or "")
items.append(d)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
def prune_older_than(conn, min_ts_ms: int) -> None:
conn.execute("DELETE FROM account_ledger_entries WHERE ts_ms < ?", (int(min_ts_ms),))
conn.commit()
@@ -0,0 +1,192 @@
"""账户流水:交易所原始记录 → 统一行模型."""
from __future__ import annotations
from typing import Any, Optional
ACCOUNT_FUNDING = "funding"
ACCOUNT_TRADING = "trading"
VALID_ACCOUNTS = frozenset({ACCOUNT_FUNDING, ACCOUNT_TRADING})
PAGE_SIZE = 10
def _safe_float(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _safe_int(v: Any) -> Optional[int]:
if v is None or v == "":
return None
try:
n = float(v)
if n > 1e12:
return int(n)
if n > 1e9:
return int(n)
return int(n)
except (TypeError, ValueError):
return None
def _ts_ms(v: Any) -> Optional[int]:
if v is None or v == "":
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
if n > 1e12:
return int(n)
if n > 1e10:
return int(n)
return int(n * 1000.0)
def kind_from_raw(raw_type: str, amount: Optional[float] = None) -> str:
t = (raw_type or "").strip().lower()
if not t:
return "other"
if "deposit" in t or t in ("1", "funding_deposit"):
return "deposit"
if "withdraw" in t or "withdrawal" in t:
return "withdraw"
if "transfer" in t or "dnw" in t or t in ("2", "18", "19"):
if amount is not None and amount < 0:
return "transfer_out"
if amount is not None and amount > 0:
return "transfer_in"
return "transfer"
if "funding" in t and "fee" in t:
return "funding_fee"
if t in ("funding_fee", "fundingfee", "8"):
return "funding_fee"
if "commission" in t or "fee" in t or t in ("commission", "5", "fee"):
return "commission"
if "realiz" in t or "pnl" in t or t in ("realized_pnl", "realizedpnl", "3"):
return "realized_pnl"
if "liqui" in t:
return "liquidate"
return "other"
def kind_label_zh(kind: str) -> str:
return {
"deposit": "充值",
"withdraw": "提现",
"transfer": "划转",
"transfer_in": "划入",
"transfer_out": "划出",
"realized_pnl": "已实现盈亏",
"funding_fee": "资金费",
"commission": "手续费",
"liquidate": "强平",
"other": "其他",
}.get((kind or "").strip().lower(), "其他")
def make_ref_id(*parts: Any) -> str:
bits = []
for p in parts:
if p is None:
continue
s = str(p).strip()
if s:
bits.append(s)
return "|".join(bits) if bits else ""
def normalize_row(
*,
account: str,
ccy: str,
amount: Any,
ts_ms: Any,
ref_id: str,
raw_type: str = "",
balance_after: Any = None,
symbol: str = "",
note: str = "",
kind: str = "",
) -> Optional[dict[str, Any]]:
acc = (account or "").strip().lower()
if acc not in VALID_ACCOUNTS:
return None
ccy_u = (ccy or "").strip().upper()
if not ccy_u:
return None
amt = _safe_float(amount)
if amt is None:
return None
ts = _ts_ms(ts_ms)
if ts is None or ts <= 0:
return None
rid = (ref_id or "").strip() or make_ref_id(acc, ccy_u, ts, amt, raw_type)
k = (kind or "").strip().lower() or kind_from_raw(raw_type, amt)
bal = _safe_float(balance_after)
return {
"account": acc,
"ccy": ccy_u,
"amount": amt,
"balance_after": bal,
"kind": k,
"kind_label": kind_label_zh(k),
"raw_type": (raw_type or "").strip()[:120],
"symbol": (symbol or "").strip()[:80],
"ref_id": rid[:200],
"ts_ms": int(ts),
"note": (note or "").strip()[:240],
}
def from_ccxt_ledger_entry(entry: dict[str, Any], *, account: str) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
if amount is None:
amount = entry.get("change")
if amount is None:
amount = info.get("balChg") or info.get("change") or info.get("income") or info.get("amount")
ts = entry.get("timestamp") or entry.get("datetime")
if ts is None:
ts = info.get("time") or info.get("uTime") or info.get("ts") or info.get("create_time") or info.get("createDate")
ccy = entry.get("currency") or info.get("ccy") or info.get("asset") or info.get("currency") or "USDT"
raw_type = (
entry.get("type")
or entry.get("status")
or info.get("type")
or info.get("incomeType")
or info.get("change_type")
or info.get("subType")
or ""
)
if isinstance(raw_type, (int, float)):
raw_type = str(raw_type)
balance_after = entry.get("balance") or info.get("bal") or info.get("balance")
symbol = entry.get("symbol") or info.get("instId") or info.get("symbol") or info.get("contract") or ""
ref = (
entry.get("id")
or info.get("billId")
or info.get("tranId")
or info.get("id")
or info.get("trade_id")
or ""
)
note = entry.get("description") or info.get("info") or info.get("text") or ""
return normalize_row(
account=account,
ccy=str(ccy),
amount=amount,
ts_ms=ts,
ref_id=str(ref) if ref != "" else make_ref_id(account, ccy, ts, amount, raw_type),
raw_type=str(raw_type),
balance_after=balance_after,
symbol=str(symbol or ""),
note=str(note or ""),
)
@@ -0,0 +1,208 @@
"""三所统一:账户流水路由 + 后台同步安装."""
from __future__ import annotations
import os
from typing import Any, Callable
from flask import Flask, Response, jsonify, request, session, stream_with_context
from jinja2 import ChoiceLoader, FileSystemLoader
from lib.account_ledger.account_ledger_db import ensure_account_ledger_tables, query_entries
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
PAGE_SIZE,
VALID_ACCOUNTS,
)
from lib.account_ledger.account_ledger_sync import account_ledger_store
from lib.common.history_window_lib import resolve_list_window
def attach_account_ledger_templates(app: Flask, repo_root: str) -> None:
tpl_dir = os.path.join(repo_root, "lib", "account_ledger", "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 _build_fetch_fn(exchange_key: str, app_module: Any) -> Callable:
ex_key = (exchange_key or "").strip().lower()
exchange = getattr(app_module, "exchange", None)
ensure_markets = getattr(app_module, "ensure_markets_loaded", None)
def _fetch(*, start_ms: int, end_ms: int):
if exchange is None:
return [], ["exchange missing"]
if ex_key == "okx":
from lib.exchange.okx_ledger_lib import fetch_okx_account_ledger
return fetch_okx_account_ledger(
exchange,
start_ms=start_ms,
end_ms=end_ms,
ensure_markets=ensure_markets,
)
if ex_key == "binance":
from lib.exchange.binance_ledger_lib import fetch_binance_account_ledger
return fetch_binance_account_ledger(
exchange,
start_ms=start_ms,
end_ms=end_ms,
ensure_markets=ensure_markets,
)
from lib.exchange.gate_ledger_lib import fetch_gate_account_ledger
return fetch_gate_account_ledger(
exchange,
start_ms=start_ms,
end_ms=end_ms,
ensure_markets=ensure_markets,
)
return _fetch
def _currencies_for_exchange(exchange_key: str) -> list[str]:
if (exchange_key or "").strip().lower() == "okx":
return ["USDT", "USDC"]
return ["USDT"]
def install_account_ledger(
app: Flask,
repo_root: str,
app_module: Any,
*,
exchange_key: str = "",
) -> None:
ex = (exchange_key or "").strip().lower()
if not ex:
mod_name = getattr(app_module, "__name__", "") or ""
if "okx" in mod_name.lower():
ex = "okx"
elif "binance" in mod_name.lower():
ex = "binance"
else:
ex = "gate"
exchange_key = ex
attach_account_ledger_templates(app, repo_root)
get_db = app_module.get_db
login_required = app_module.login_required
# 初始化表
try:
conn = get_db()
try:
ensure_account_ledger_tables(conn)
finally:
conn.close()
except Exception:
pass
account_ledger_store.configure(
get_db=get_db,
fetch_fn=_build_fetch_fn(exchange_key, app_module),
exchange_key=str(exchange_key),
)
account_ledger_store.start()
app.extensions["account_ledger_exchange"] = str(exchange_key).lower()
def _list_window():
resolve = getattr(app_module, "_list_window_from_request", None)
if callable(resolve):
return resolve()
return resolve_list_window(request.args, session)
@app.route("/api/account_ledger")
@login_required
def api_account_ledger():
account = (request.args.get("account") or ACCOUNT_FUNDING).strip().lower()
if account not in VALID_ACCOUNTS:
account = ACCOUNT_FUNDING
try:
page = int(request.args.get("page") or 1)
except Exception:
page = 1
win = _list_window()
start_ms = int(win.get("start_ms") or 0)
end_ms = int(win.get("end_ms") or 0)
ccys = _currencies_for_exchange(app.extensions.get("account_ledger_exchange") or "")
conn = get_db()
try:
ensure_account_ledger_tables(conn)
data = query_entries(
conn,
account=account,
start_ms=start_ms,
end_ms=end_ms,
page=page,
page_size=PAGE_SIZE,
currencies=ccys,
)
finally:
conn.close()
st = account_ledger_store.status_dict()
return jsonify(
{
"ok": True,
"account": account,
"window": {
"preset": win.get("preset"),
"label": win.get("label"),
"start_ms": start_ms,
"end_ms": end_ms,
},
"currencies": ccys,
**data,
**st,
}
)
@app.route("/api/account_ledger/stream")
@login_required
def api_account_ledger_stream():
return Response(
stream_with_context(account_ledger_store.iter_sse()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/account_ledger/refresh", methods=["POST"])
@login_required
def api_account_ledger_refresh():
win = _list_window()
body = request.get_json(silent=True) or {}
start_ms = body.get("start_ms", win.get("start_ms"))
end_ms = body.get("end_ms", win.get("end_ms"))
try:
start_i = int(start_ms) if start_ms is not None else None
end_i = int(end_ms) if end_ms is not None else None
except Exception:
start_i, end_i = None, None
result = account_ledger_store.sync_once(
reason="manual", start_ms=start_i, end_ms=end_i
)
return jsonify(result)
@app.route("/account_ledger")
@login_required
def account_ledger_page():
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
redir = redirect_to_embed_shell_if_enabled("account_ledger")
if redir is not None:
return redir
return app_module.render_main_page("account_ledger")
+252
View File
@@ -0,0 +1,252 @@
"""账户流水:后台定时拉取交易所 + SSE 版本推送."""
from __future__ import annotations
import json
import os
import queue
import threading
import time
from collections.abc import Callable, Iterator
from datetime import datetime, timezone
from typing import Any, Optional
from lib.account_ledger.account_ledger_db import (
ensure_account_ledger_tables,
meta_get,
meta_set,
prune_older_than,
upsert_entries,
)
ACCOUNT_LEDGER_POLL_SEC = float(os.getenv("ACCOUNT_LEDGER_POLL_SEC", "120"))
ACCOUNT_LEDGER_LOOKBACK_DAYS = int(os.getenv("ACCOUNT_LEDGER_LOOKBACK_DAYS", "90"))
ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC = float(os.getenv("ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC", "25"))
class AccountLedgerStore:
def __init__(self) -> None:
self._lock = threading.Lock()
self.version = 0
self._subscribers: list[queue.Queue[str | None]] = []
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._syncing = False
self._get_db: Optional[Callable] = None
self._fetch_fn: Optional[Callable[..., tuple[list[dict[str, Any]], list[str]]]] = None
self._exchange_key = ""
self.last_sync_at: Optional[float] = None
self.last_error: str = ""
self.last_upserted: int = 0
self._last_manual_at: float = 0.0
self._manual_cooldown_sec = float(os.getenv("ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC", "30"))
def configure(
self,
*,
get_db: Callable,
fetch_fn: Callable[..., tuple[list[dict[str, Any]], list[str]]],
exchange_key: str,
) -> None:
self._get_db = get_db
self._fetch_fn = fetch_fn
self._exchange_key = (exchange_key or "").strip().lower()
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
if not self._get_db or not self._fetch_fn:
return
self._stop.clear()
self._thread = threading.Thread(
target=self._loop, daemon=True, name=f"account-ledger-{self._exchange_key or 'x'}"
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._broadcast(close=True)
def lookback_bounds_ms(self, start_ms: Optional[int] = None, end_ms: Optional[int] = None) -> tuple[int, int]:
now = datetime.now(timezone.utc)
end = int(end_ms) if end_ms is not None else int(now.timestamp() * 1000)
floor = int(end - ACCOUNT_LEDGER_LOOKBACK_DAYS * 86400 * 1000)
if start_ms is not None:
start = max(int(start_ms), floor)
else:
start = floor
if start > end:
start, end = end, start
return start, end
def sync_once(
self,
*,
reason: str = "poll",
start_ms: Optional[int] = None,
end_ms: Optional[int] = None,
) -> dict[str, Any]:
if not self._get_db or not self._fetch_fn:
return {"ok": False, "msg": "未配置"}
with self._lock:
if self._syncing:
return {"ok": True, "busy": True, "ledger_version": self.version}
if reason == "manual":
gap = time.time() - self._last_manual_at
if gap < self._manual_cooldown_sec:
wait = int(self._manual_cooldown_sec - gap) + 1
return {
"ok": False,
"msg": f"同步过于频繁,请 {wait}s 后再试",
"ledger_version": self.version,
}
self._syncing = True
try:
start, end = self.lookback_bounds_ms(start_ms, end_ms)
rows, errors = self._fetch_fn(start_ms=start, end_ms=end)
conn = self._get_db()
try:
ensure_account_ledger_tables(conn)
n = upsert_entries(conn, rows or [])
# 保留略宽于 lookback 的缓存
prune_ms = int(
(datetime.now(timezone.utc).timestamp() - (ACCOUNT_LEDGER_LOOKBACK_DAYS + 7) * 86400)
* 1000
)
prune_older_than(conn, prune_ms)
self.last_sync_at = time.time()
self.last_upserted = n
self.last_error = "; ".join(errors[:3]) if errors else ""
meta_set(conn, "last_sync_at", str(self.last_sync_at))
meta_set(conn, "last_error", self.last_error)
meta_set(conn, "last_upserted", str(n))
conn.commit()
finally:
try:
conn.close()
except Exception:
pass
if reason == "manual":
self._last_manual_at = time.time()
ver = self.bump(reason)
return {
"ok": True,
"ledger_version": ver,
"upserted": n,
"errors": errors,
"start_ms": start,
"end_ms": end,
}
except Exception as e:
self.last_error = str(e)
try:
conn = self._get_db()
try:
ensure_account_ledger_tables(conn)
meta_set(conn, "last_error", self.last_error)
conn.commit()
finally:
conn.close()
except Exception:
pass
return {"ok": False, "msg": str(e), "ledger_version": self.version}
finally:
with self._lock:
self._syncing = False
def bump(self, reason: str = "poll") -> int:
with self._lock:
self.version += 1
ver = self.version
payload = json.dumps(
{"ledger_version": ver, "reason": reason, "exchange": self._exchange_key},
ensure_ascii=False,
)
self._broadcast(payload)
return ver
def status_dict(self) -> dict[str, Any]:
last_at = self.last_sync_at
if last_at is None and self._get_db:
try:
conn = self._get_db()
try:
ensure_account_ledger_tables(conn)
raw = meta_get(conn, "last_sync_at", "")
if raw:
last_at = float(raw)
self.last_error = meta_get(conn, "last_error", self.last_error)
finally:
conn.close()
except Exception:
pass
return {
"ledger_version": self.version,
"poll_sec": ACCOUNT_LEDGER_POLL_SEC,
"lookback_days": ACCOUNT_LEDGER_LOOKBACK_DAYS,
"last_sync_at": last_at,
"last_error": self.last_error,
"last_upserted": self.last_upserted,
"exchange": self._exchange_key,
}
def _loop(self) -> None:
# 启动后稍等再拉,避免和启动高峰撞车
if self._stop.wait(3):
return
while not self._stop.is_set():
try:
self.sync_once(reason="poll")
except Exception:
pass
if self._stop.wait(ACCOUNT_LEDGER_POLL_SEC):
break
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
with self._lock:
subs = list(self._subscribers)
dead: list[queue.Queue[str | None]] = []
for q in subs:
try:
q.put_nowait(None if close else event)
except Exception:
dead.append(q)
if dead:
with self._lock:
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
def _subscribe(self) -> queue.Queue[str | None]:
q: queue.Queue[str | None] = queue.Queue(maxsize=16)
with self._lock:
self._subscribers.append(q)
return q
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def iter_sse(self) -> Iterator[str]:
q = self._subscribe()
try:
yield f"event: ledger\ndata: {json.dumps({'ledger_version': self.version, 'reason': 'hello'}, ensure_ascii=False)}\n\n"
last_hb = time.time()
while not self._stop.is_set():
try:
item = q.get(timeout=1.0)
except queue.Empty:
item = "timeout"
if item is None:
break
if item != "timeout":
yield f"event: ledger\ndata: {item}\n\n"
last_hb = time.time()
elif time.time() - last_hb >= ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC:
yield ": heartbeat\n\n"
last_hb = time.time()
finally:
self._unsubscribe(q)
account_ledger_store = AccountLedgerStore()
@@ -0,0 +1,72 @@
{# 账户流水:资金/交易 Tab · 交易所账单 · SSE #}
<div class="card full account-ledger-card" id="account-ledger-root" data-account-ledger="1">
<div class="account-ledger-head">
<div>
<h2 style="margin-bottom:4px">账户流水</h2>
<p class="muted account-ledger-desc">拉取交易所资金账户与交易账户账单 · 时间跟随顶栏 UTC 预设 · 约 2 分钟自动同步</p>
</div>
<div class="account-ledger-head-actions">
<span class="muted" id="account-ledger-sync"></span>
<button type="button" class="btn-sm" id="account-ledger-refresh">立即同步</button>
</div>
</div>
<div class="account-ledger-tabs" role="tablist">
<button type="button" class="account-ledger-tab active" data-ledger-account="funding" role="tab" aria-selected="true">资金账户</button>
<button type="button" class="account-ledger-tab" data-ledger-account="trading" role="tab" aria-selected="false">交易账户</button>
</div>
<p class="muted account-ledger-status" id="account-ledger-status"></p>
<div class="account-ledger-table-wrap panel-scroll">
<table class="account-ledger-table" id="account-ledger-table">
<thead>
<tr>
<th>时间(北京)</th>
<th>币种</th>
<th>类型</th>
<th>变动</th>
<th>余额</th>
<th>合约/备注</th>
</tr>
</thead>
<tbody id="account-ledger-tbody">
<tr><td colspan="6" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
<div class="account-ledger-pager" id="account-ledger-pager">
<button type="button" class="btn-sm" id="account-ledger-prev" disabled>上一页</button>
<span class="muted" id="account-ledger-page-info"></span>
<button type="button" class="btn-sm" id="account-ledger-next" disabled>下一页</button>
</div>
</div>
<style>
.account-ledger-card { grid-column: 1 / -1; }
.account-ledger-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
}
.account-ledger-head-actions { display: flex; align-items: center; gap: 10px; }
.account-ledger-tabs {
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
}
.account-ledger-tab {
border: 1px solid rgba(140,160,200,.35);
background: transparent; color: #c5cbe0;
border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: .9rem;
}
.account-ledger-tab.active {
background: #1f3a5a; border-color: #3d6f9c; color: #e8f1ff;
}
.account-ledger-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
.account-ledger-table th, .account-ledger-table td {
padding: 8px 10px; border-bottom: 1px solid rgba(120,130,160,.2); text-align: left;
}
.account-ledger-table th { color: #9aa3bd; font-weight: 600; }
.account-ledger-amt-pos { color: #3ecf8e; }
.account-ledger-amt-neg { color: #f07178; }
.account-ledger-pager {
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
margin-top: 12px;
}
.account-ledger-status { min-height: 1.2em; margin: 0 0 8px; }
.account-ledger-table-wrap { max-height: min(60vh, 560px); overflow: auto; }
</style>
+337
View File
@@ -0,0 +1,337 @@
/**
* 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设.
*/
(function (global) {
const PAGE_SIZE = 10;
let account = "funding";
let page = 1;
let pages = 0;
let localVersion = 0;
let es = null;
let reconnectTimer = null;
let loading = false;
let booted = false;
function root() {
const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]');
if (active) return active;
return document.getElementById("account-ledger-root");
}
function $(id) {
const r = root();
return (r && r.querySelector("#" + id)) || document.getElementById(id);
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function listWindowQs() {
if (typeof global.listWindowQueryString === "function") {
const q = global.listWindowQueryString();
return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : "";
}
try {
return new URLSearchParams(location.search).toString();
} catch (_) {
return "";
}
}
function fmtBj(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return "—";
try {
const d = new Date(n);
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (t) => (parts.find((p) => p.type === t) || {}).value || "";
return (
get("year") +
"-" +
get("month") +
"-" +
get("day") +
" " +
get("hour") +
":" +
get("minute") +
":" +
get("second")
);
} catch (_) {
return "—";
}
}
function fmtAmt(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : "";
const sign = n > 0 ? "+" : "";
return '<span class="' + cls + '">' + sign + n.toFixed(6).replace(/\.?0+$/, "") + "</span>";
}
function fmtBal(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(6).replace(/\.?0+$/, "");
}
function setStatus(msg, isErr) {
const el = $("account-ledger-status");
if (!el) return;
el.textContent = msg || "";
el.style.color = isErr ? "#f07178" : "";
}
function setSyncLabel(data) {
const el = $("account-ledger-sync");
if (!el) return;
const ts = data && data.last_sync_at;
if (!ts) {
el.textContent = "尚未同步";
return;
}
el.textContent = "同步 " + fmtBj(Number(ts) * 1000);
}
function renderRows(items) {
const tbody = $("account-ledger-tbody");
if (!tbody) return;
if (!items || !items.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">当前时间窗暂无流水</td></tr>';
return;
}
tbody.innerHTML = items
.map(function (it) {
const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · ");
return (
"<tr>" +
"<td>" +
escapeHtml(fmtBj(it.ts_ms)) +
"</td>" +
"<td>" +
escapeHtml(it.ccy || "") +
"</td>" +
"<td>" +
escapeHtml(it.kind_label || it.kind || "") +
"</td>" +
"<td>" +
fmtAmt(it.amount) +
"</td>" +
"<td>" +
escapeHtml(fmtBal(it.balance_after)) +
"</td>" +
"<td>" +
escapeHtml(note || "—") +
"</td>" +
"</tr>"
);
})
.join("");
}
function renderPager(data) {
pages = Number(data.pages || 0);
page = Number(data.page || 1);
const info = $("account-ledger-page-info");
const prev = $("account-ledger-prev");
const next = $("account-ledger-next");
if (info) {
info.textContent =
"第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE;
}
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = !pages || page >= pages;
}
async function loadList(opts) {
const r = root();
if (!r) return;
if (loading) return;
loading = true;
const force = opts && opts.force;
try {
if (!force) setStatus("加载中…");
const qs = new URLSearchParams(listWindowQs());
qs.set("account", account);
qs.set("page", String(page));
const res = await fetch("/api/account_ledger?" + qs.toString(), {
credentials: "same-origin",
});
const data = await res.json().catch(function () {
return {};
});
if (!res.ok || data.ok === false) {
throw new Error(data.msg || res.statusText || "加载失败");
}
if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion;
renderRows(data.items || []);
renderPager(data);
setSyncLabel(data);
const winLabel = (data.window && data.window.label) || "";
const err = data.last_error ? " · 同步提示: " + data.last_error : "";
setStatus(
(winLabel ? "时间窗 " + winLabel + " · " : "") +
(account === "trading" ? "交易账户" : "资金账户") +
err,
!!data.last_error
);
} catch (e) {
setStatus(e.message || String(e), true);
} finally {
loading = false;
}
}
async function refreshNow() {
setStatus("正在从交易所同步…");
try {
const res = await fetch("/api/account_ledger/refresh", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const data = await res.json().catch(function () {
return {};
});
if (!res.ok || data.ok === false) {
throw new Error(data.msg || "同步失败");
}
await loadList({ force: true });
} catch (e) {
setStatus(e.message || String(e), true);
}
}
function bindUi() {
const r = root();
if (!r || r.getAttribute("data-ledger-bound") === "1") return;
r.setAttribute("data-ledger-bound", "1");
r.querySelectorAll(".account-ledger-tab").forEach(function (btn) {
btn.addEventListener("click", function () {
const acc = btn.getAttribute("data-ledger-account") || "funding";
if (acc === account) return;
account = acc;
page = 1;
r.querySelectorAll(".account-ledger-tab").forEach(function (b) {
const on = b.getAttribute("data-ledger-account") === account;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
loadList();
});
});
const prev = $("account-ledger-prev");
const next = $("account-ledger-next");
const ref = $("account-ledger-refresh");
if (prev)
prev.addEventListener("click", function () {
if (page > 1) {
page -= 1;
loadList();
}
});
if (next)
next.addEventListener("click", function () {
if (!pages || page < pages) {
page += 1;
loadList();
}
});
if (ref) ref.addEventListener("click", refreshNow);
}
function connectSse() {
if (es) {
try {
es.close();
} catch (_) {}
es = null;
}
if (typeof EventSource === "undefined") return;
try {
es = new EventSource("/api/account_ledger/stream");
es.addEventListener("ledger", function (ev) {
let data = {};
try {
data = JSON.parse(ev.data || "{}");
} catch (_) {}
const ver = Number(data.ledger_version || 0);
if (ver && ver !== localVersion) {
localVersion = ver;
loadList({ force: true });
}
});
es.onerror = function () {
try {
es.close();
} catch (_) {}
es = null;
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connectSse, 5000);
};
} catch (_) {}
}
function boot() {
const r = root();
if (!r) return;
bindUi();
if (!booted) {
booted = true;
connectSse();
}
loadList();
}
function onTabActivated(tab) {
if (tab !== "account_ledger") return;
boot();
}
global.AccountLedgerPage = {
boot: boot,
onTabActivated: onTabActivated,
reload: function () {
page = 1;
loadList();
},
};
document.addEventListener("DOMContentLoaded", function () {
const page =
(document.body && document.body.getAttribute("data-page")) ||
(document.body && document.body.getAttribute("data-initial-tab")) ||
"";
if (page === "account_ledger" || root()) {
// embed 延后到 tab 激活;独立页直接 boot
if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") {
boot();
} else if (page === "account_ledger") {
boot();
}
}
});
document.addEventListener("instance-embed-tab-activated", function (ev) {
const tab = ev && ev.detail && ev.detail.tab;
onTabActivated(tab);
});
})(window);
+2 -1
View File
@@ -35,7 +35,8 @@
delete form.dataset.submitGuard; delete form.dataset.submitGuard;
form.classList.remove("is-form-submitting"); form.classList.remove("is-form-submitting");
submitButtons(form).forEach(function (btn) { submitButtons(form).forEach(function (btn) {
btn.disabled = false; // 风控灰显(开仓门禁)保持禁用
btn.disabled = btn.classList.contains("is-blocked");
var orig = btn.dataset.submitGuardOrig; var orig = btn.dataset.submitGuardOrig;
if (orig !== undefined) { if (orig !== undefined) {
if (btn.tagName === "BUTTON") btn.textContent = orig; if (btn.tagName === "BUTTON") btn.textContent = orig;
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -5,6 +5,7 @@
(function (global) { (function (global) {
const TAB_PATH = { const TAB_PATH = {
dashboard: "/dashboard", dashboard: "/dashboard",
account_ledger: "/account_ledger",
key_monitor: "/key_monitor", key_monitor: "/key_monitor",
trade: "/trade", trade: "/trade",
strategy: "/strategy", strategy: "/strategy",
@@ -114,6 +115,9 @@
if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") { if (tab === "dashboard" && global.InstanceDashboard && typeof global.InstanceDashboard.init === "function") {
global.InstanceDashboard.init(!!revisit); global.InstanceDashboard.init(!!revisit);
} }
if (tab === "account_ledger" && global.AccountLedgerPage && typeof global.AccountLedgerPage.boot === "function") {
global.AccountLedgerPage.boot();
}
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") { if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
global.initStrategyRollForm(); global.initStrategyRollForm();
} }
@@ -284,7 +288,7 @@
setSettingsSubTabInUrl("transfer"); setSettingsSubTabInUrl("transfer");
return "transfer"; return "transfer";
} }
if (path.indexOf("/api/options/transfer") >= 0 || path.indexOf("/api/options/cross-transfer") >= 0) { if (path.indexOf("/api/options/transfer") >= 0) {
setSettingsSubTabInUrl("options_transfer"); setSettingsSubTabInUrl("options_transfer");
return "options_transfer"; return "options_transfer";
} }
@@ -294,6 +298,7 @@
async function fetchTabHtml(tab) { async function fetchTabHtml(tab) {
const r = await fetch(embedPageUrl(tab), { const r = await fetch(embedPageUrl(tab), {
credentials: "same-origin", credentials: "same-origin",
cache: "no-store",
headers: { "X-Instance-Soft-Nav": "1" }, headers: { "X-Instance-Soft-Nav": "1" },
}); });
const ct = (r.headers.get("content-type") || "").toLowerCase(); const ct = (r.headers.get("content-type") || "").toLowerCase();
+37 -1
View File
@@ -22,8 +22,37 @@
.card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff} .card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center} .form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center}
.form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem} .form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto} /* 实盘下单监控:分层布局 */
.order-monitor-form{display:flex;flex-direction:column;gap:10px;margin-bottom:4px}
.order-monitor-form .om-row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px}
.order-monitor-form .om-row-policy > input:not([type=checkbox]):not([type=radio]),
.order-monitor-form .om-row-policy > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem}
.order-monitor-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-monitor-form .om-field{display:flex;flex-direction:column;gap:4px;min-width:7.5rem}
.order-monitor-form .om-field-lab{font-size:.72rem;color:#9aa3c7;line-height:1;letter-spacing:.02em}
.order-monitor-form .om-field input{width:9.5rem;max-width:160px;box-sizing:border-box}
.order-monitor-form .om-live-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-bottom:2px;margin-left:auto}
.order-monitor-form .om-row-opts{align-items:center;gap:12px;padding-top:2px}
.order-monitor-form .om-check{display:inline-flex;align-items:center;gap:5px;font-size:.82rem;color:#cfd3ef;cursor:pointer;user-select:none}
.order-monitor-form .om-time-close{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef}
.order-monitor-form .om-time-close select{width:auto;min-width:4.2rem;max-width:5.5rem;padding:6px 8px}
.order-monitor-form .om-row-action{padding-top:2px;display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px}
.order-monitor-form .om-submit{min-width:11rem;padding:10px 18px;font-weight:600}
.order-monitor-form .om-submit.is-blocked,
.order-monitor-form .om-submit:disabled{
opacity:.45;
cursor:not-allowed;
filter:grayscale(.35);
pointer-events:none;
}
.order-monitor-form .om-open-block-note{
color:var(--danger,#ff7b7b);
font-size:13px;
line-height:1.4;
max-width:min(28rem,100%);
}
.order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem} .order-plan-preview{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:4px 0 10px;padding:10px 12px;background:#151a28;border:1px solid #2a3150;border-radius:8px;font-size:.85rem}
#add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto}
.order-preview-risk{color:#ff6b6b} .order-preview-risk{color:#ff6b6b}
.order-preview-risk strong{color:#ff8f8f;font-weight:600} .order-preview-risk strong{color:#ff8f8f;font-weight:600}
.order-preview-profit{color:#4cd97f} .order-preview-profit{color:#4cd97f}
@@ -196,6 +225,13 @@
.inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside} .inst-stats-details>summary{cursor:pointer;font-size:.84rem;color:#9aa3bf;padding:8px 0;user-select:none;list-style-position:inside}
.inst-stats-details>summary::-webkit-details-marker{color:#6d7689} .inst-stats-details>summary::-webkit-details-marker{color:#6d7689}
.inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef} .inst-stats-details[open]>summary{margin-bottom:6px;color:#cfd3ef}
.inst-stats-month-table-wrap{overflow:auto;-webkit-overflow-scrolling:touch}
.inst-stats-month-table{width:100%;border-collapse:collapse;font-size:.8rem;font-variant-numeric:tabular-nums}
.inst-stats-month-table th,.inst-stats-month-table td{padding:8px 10px;text-align:right;border-bottom:1px solid #2a3348;white-space:nowrap}
.inst-stats-month-table th:first-child,.inst-stats-month-table td:first-child{text-align:left}
.inst-stats-month-table th{color:#8892b0;font-weight:600;font-size:.72rem}
.inst-stats-month-table td{color:#e8ecf4}
.inst-stats-month-table tbody tr:last-child td{border-bottom:none}
@media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}} @media (max-width:640px){.inst-stats-kpis{grid-template-columns:1fr}.inst-stats-risk-grid{grid-template-columns:1fr}}
.key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150} .key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150}
.key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px}
+113 -3
View File
@@ -20,7 +20,11 @@
} }
/** 默认关闭的导航开关:缺失时按 false,不能用 !== false */ /** 默认关闭的导航开关:缺失时按 false,不能用 !== false */
const NAV_DEFAULT_OFF = { show_nav_dashboard: true, show_nav_system_guide: true }; const NAV_DEFAULT_OFF = {
show_nav_dashboard: true,
show_nav_account_ledger: true,
show_nav_system_guide: true,
};
function navPrefShow(display, key) { function navPrefShow(display, key) {
if (!key) return true; if (!key) return true;
@@ -31,6 +35,7 @@
function applyDisplayToNav(display) { function applyDisplayToNav(display) {
const map = { const map = {
dashboard: "show_nav_dashboard", dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
key_monitor: "show_nav_key_monitor", key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade", trade: "show_nav_trade",
strategy: "show_nav_strategy", strategy: "show_nav_strategy",
@@ -66,6 +71,7 @@
const d = DISPLAY(); const d = DISPLAY();
const map = { const map = {
dashboard: "show_nav_dashboard", dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
key_monitor: "show_nav_key_monitor", key_monitor: "show_nav_key_monitor",
trade: "show_nav_trade", trade: "show_nav_trade",
strategy: "show_nav_strategy", strategy: "show_nav_strategy",
@@ -196,6 +202,7 @@
function renderEnvFieldRow(field) { function renderEnvFieldRow(field) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : ""); row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
row.dataset.envKey = field.key;
const label = document.createElement("label"); const label = document.createElement("label");
label.className = "env-field-label"; label.className = "env-field-label";
label.htmlFor = "env-f-" + field.key; label.htmlFor = "env-f-" + field.key;
@@ -288,6 +295,10 @@
input.dataset.envKey = field.key; input.dataset.envKey = field.key;
input.className = "env-field-input"; input.className = "env-field-input";
row.appendChild(input); row.appendChild(input);
if (field.hidden) {
row.hidden = true;
row.style.display = "none";
}
return row; return row;
} }
@@ -311,7 +322,9 @@
const panelsWrap = document.createElement("div"); const panelsWrap = document.createElement("div");
panelsWrap.className = "env-config-panels"; panelsWrap.className = "env-config-panels";
panelsWrap.id = "env-config-grid"; panelsWrap.id = "env-config-grid";
let modeSectionIdx = 0;
groups.forEach((group, idx) => { groups.forEach((group, idx) => {
if ((group.title || "").indexOf("期权/对冲模式") >= 0) modeSectionIdx = idx;
const label = document.createElement("label"); const label = document.createElement("label");
label.className = "env-tab-btn"; label.className = "env-tab-btn";
label.htmlFor = "env-sec-" + idx; label.htmlFor = "env-sec-" + idx;
@@ -335,9 +348,100 @@
}); });
body.appendChild(tabBar); body.appendChild(tabBar);
body.appendChild(panelsWrap); body.appendChild(panelsWrap);
body.dataset.envModeSectionIdx = String(modeSectionIdx);
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
bindMarginModeGateVisibility(body);
return body; return body;
} }
function envFieldRowByKey(body, key) {
if (!body || !key) return null;
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
if (byRow) return byRow;
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
return input ? input.closest(".env-field-row") : null;
}
function setEnvRowHidden(row, hidden) {
if (!row) return;
row.hidden = !!hidden;
row.style.display = hidden ? "none" : "";
}
function syncCompoundBudgetVisibility(body) {
if (!body) return;
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
if (!budgetRow) return;
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
setEnvRowHidden(budgetRow, compoundOn);
}
function syncMarginModeGateVisibility(body) {
if (!body) return;
const modeSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
);
const mode = String((modeSel && modeSel.value) || "coin").toLowerCase();
const coinMode = mode !== "usdc";
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC"), coinMode);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), !coinMode);
}
function bindCompoundBudgetVisibility(body) {
if (!body) return;
syncCompoundBudgetVisibility(body);
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
compoundSel.dataset.compoundBudgetBound = "1";
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
}
function bindMarginModeGateVisibility(body) {
if (!body) return;
syncMarginModeGateVisibility(body);
const modeSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
);
if (!modeSel || modeSel.dataset.marginGateBound === "1") return;
modeSel.dataset.marginGateBound = "1";
modeSel.addEventListener("change", () => syncMarginModeGateVisibility(body));
}
function bindTradeModeAutoRefresh(body) {
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
modeSel.dataset.modeRefreshBound = "1";
modeSel.addEventListener("change", async () => {
const status = document.getElementById("env-config-status");
const nextMode = modeSel.value;
setStatus(status, "切换交易模式并刷新配置…");
try {
await fetchJson("/api/settings/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values: { OKX_TRADE_MODE: nextMode } }),
});
await loadEnvConfig(true);
const page = envConfigRoot() || document.querySelector(".env-config-page");
const newBody = page && page.querySelector("#env-config-body");
const idx = newBody && newBody.dataset.envModeSectionIdx;
if (idx != null) {
const radio = document.getElementById("env-sec-" + idx);
if (radio) radio.checked = true;
}
setStatus(status, "交易模式已切换为当前选项,配置区已刷新");
} catch (e) {
setStatus(status, e.message || "切换失败", true);
}
});
}
async function loadEnvConfig(force) { async function loadEnvConfig(force) {
const root = envConfigRoot(); const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body"); const body = root && root.querySelector("#env-config-body");
@@ -395,10 +499,10 @@
setStatus(status, "已保存,正在重启实例…"); setStatus(status, "已保存,正在重启实例…");
await restartInstance(); await restartInstance();
setStatus(status, "保存并重启完成"); setStatus(status, "保存并重启完成");
await loadEnvConfig(); await loadEnvConfig(true);
} else { } else {
setStatus(status, "已保存(即时生效项已应用)"); setStatus(status, "已保存(即时生效项已应用)");
await loadEnvConfig(); await loadEnvConfig(true);
} }
} catch (e) { } catch (e) {
setStatus(status, e.message || "保存失败", true); setStatus(status, e.message || "保存失败", true);
@@ -501,6 +605,12 @@
bindEvents(); bindEvents();
loadDisplayPrefsForm(false); loadDisplayPrefsForm(false);
loadEnvConfig(false); loadEnvConfig(false);
const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body");
if (body) {
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
}
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__); if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
} }
+1 -1
View File
@@ -1,7 +1,7 @@
(function (global) { (function (global) {
"use strict"; "use strict";
var PERIODS = ["day", "week", "month"]; var PERIODS = ["day", "week", "month", "all"];
function statsSegmentSelect() { function statsSegmentSelect() {
return document.getElementById("stats-segment-select"); return document.getElementById("stats-segment-select");
+417 -18
View File
@@ -690,6 +690,14 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
white-space: nowrap; white-space: nowrap;
} }
#current-capital,
.stat-strip-item [data-funds-field="current-capital"],
.inst-phone-chip [data-funds-field="current-capital"] {
white-space: pre-line;
line-height: 1.2;
font-size: 0.82rem;
}
.stat-strip-item--primary .label { .stat-strip-item--primary .label {
font-size: 0.76rem; font-size: 0.76rem;
} }
@@ -859,6 +867,13 @@ html[data-theme="light"] .inst-stats-details > summary {
html[data-theme="light"] .inst-stats-details[open] > summary { html[data-theme="light"] .inst-stats-details[open] > summary {
color: #0d4a7a !important; color: #0d4a7a !important;
} }
html[data-theme="light"] .inst-stats-month-table th {
color: #4a6078 !important;
}
html[data-theme="light"] .inst-stats-month-table td {
color: #142232 !important;
border-bottom-color: #d0dae4 !important;
}
html[data-theme="light"] .key-history { html[data-theme="light"] .key-history {
border-top-color: #d0dae4 !important; border-top-color: #d0dae4 !important;
@@ -1959,6 +1974,15 @@ html[data-theme="light"] .order-plan-preview {
border-color: #b8c8d8 !important; border-color: #b8c8d8 !important;
} }
html[data-theme="light"] .order-monitor-form .om-field-lab {
color: #5a6a82;
}
html[data-theme="light"] .order-monitor-form .om-check,
html[data-theme="light"] .order-monitor-form .om-time-close {
color: #3a4a62;
}
html[data-theme="light"] .order-preview-rr { html[data-theme="light"] .order-preview-rr {
color: #4a6078 !important; color: #4a6078 !important;
} }
@@ -2478,6 +2502,11 @@ html[data-theme="light"] .journal-detail-img-thumb {
min-width: 0; min-width: 0;
} }
/* display:flex 会盖掉 UA [hidden];全仓复利开时隐藏单笔预算等依赖此规则 */
.env-field-row[hidden] {
display: none !important;
}
.env-field-row--restart .env-field-label { .env-field-row--restart .env-field-label {
color: #d4c4a0; color: #d4c4a0;
} }
@@ -3122,6 +3151,15 @@ html[data-theme="light"] .settings-side-export-label {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
.opt-chain-lev {
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 600;
color: #b8c8ff;
}
html[data-theme="light"] .opt-chain-lev {
color: #1a4a8a;
}
.opt-be-dist-up { .opt-be-dist-up {
color: #5ee89a; color: #5ee89a;
} }
@@ -3445,6 +3483,34 @@ html[data-theme="light"] .opt-be-dist-down {
font-size: 0.74rem; font-size: 0.74rem;
line-height: 1.4; line-height: 1.4;
} }
.hedge-plan-page-wrap .hp-po-mode-badge {
display: inline-block;
margin-right: 6px;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #c4b5fd;
background: rgba(139, 92, 246, 0.22);
border: 1px solid rgba(167, 139, 250, 0.35);
vertical-align: middle;
}
.hedge-plan-page-wrap .hp-po-mode-badge.is-insurance {
color: #93c5fd;
background: rgba(59, 130, 246, 0.18);
border-color: rgba(96, 165, 250, 0.35);
}
.hedge-plan-page-wrap .hp-po-section {
margin: 8px 0 10px;
}
.hedge-plan-page-wrap .hp-po-section-title {
margin: 0 0 6px;
font-size: 0.8rem;
font-weight: 650;
color: #c5cdd9;
letter-spacing: 0.02em;
}
.hedge-plan-page-wrap .hp-po-fields { .hedge-plan-page-wrap .hp-po-fields {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -3455,6 +3521,121 @@ html[data-theme="light"] .opt-be-dist-down {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
} }
.hedge-plan-page-wrap .hp-po-fields--section {
margin: 0;
}
.hedge-plan-page-wrap .hp-po-fields--capital {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.hedge-plan-page-wrap .hp-po-fields--select {
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: end;
}
.hedge-plan-page-wrap .hp-po-field--type {
min-width: 0;
}
.hedge-plan-page-wrap .hp-po-field--type select {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.hedge-plan-page-wrap .hp-plan-watching {
color: #fbbf24;
font-weight: 650;
}
@media (max-width: 720px) {
.hedge-plan-page-wrap .hp-po-fields--capital,
.hedge-plan-page-wrap .hp-po-fields--select {
grid-template-columns: 1fr 1fr;
}
.hedge-plan-page-wrap .hp-po-field--type {
grid-column: 1 / -1;
}
.hedge-plan-page-wrap .hp-po-type-seg {
flex-wrap: wrap;
}
}
.hedge-plan-page-wrap .hp-po-right-card {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100%;
}
.hedge-plan-page-wrap .hp-po-right-stack {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
}
.hedge-plan-page-wrap .hp-po-inner-card {
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
min-width: 0;
min-height: 0;
}
.hedge-plan-page-wrap .hp-po-inner-card > h2 {
margin: 0 0 8px;
font-size: 0.95rem;
}
.hedge-plan-page-wrap .hp-po-action-row {
margin-top: auto;
padding-top: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.hedge-plan-page-wrap .hp-po-strategy-status {
font-size: 0.86rem;
font-weight: 700;
letter-spacing: 0.02em;
min-height: 1.2em;
}
.hedge-plan-page-wrap .hp-po-strategy-status.is-watching {
color: #fbbf24;
}
.hedge-plan-page-wrap .hp-po-strategy-status.is-holding {
color: #3dd68c;
}
.hedge-plan-page-wrap .hp-po-strategy-status.is-idle {
color: #6b7388;
font-weight: 500;
}
.hedge-plan-page-wrap .hp-po-quote-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.hedge-plan-page-wrap .hp-po-ins-money {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.hedge-plan-page-wrap .hp-po-ins-money[hidden],
.hedge-plan-page-wrap .hp-po-ins-money.hidden {
display: none !important;
}
/* display:grid 会盖掉 [hidden];模式切换必须显式 none */
.hedge-plan-page-wrap .hp-po-fields.hidden,
.hedge-plan-page-wrap .hp-po-fields[hidden],
.hedge-plan-page-wrap #hp-po-fields-option-primary.hidden,
.hedge-plan-page-wrap #hp-po-fields-option-primary[hidden] {
display: none !important;
}
.hedge-plan-page-wrap .hp-strike-table-wrap--6 {
max-height: 280px;
min-height: 200px;
overflow-y: auto;
margin-top: 4px;
flex: 1 1 auto;
}
.hedge-plan-page-wrap .hp-po-field { .hedge-plan-page-wrap .hp-po-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3618,7 +3799,10 @@ html[data-theme="light"] .opt-be-dist-down {
} }
.hedge-plan-page-wrap .hp-uly-btn, .hedge-plan-page-wrap .hp-uly-btn,
.hedge-plan-page-wrap .hp-uly-btn-oo, .hedge-plan-page-wrap .hp-uly-btn-oo,
.hedge-plan-page-wrap .hp-money-btn { .hedge-plan-page-wrap .hp-money-btn,
.hedge-plan-page-wrap .hp-oo-money-btn,
.hedge-plan-page-wrap .hp-oo-recommend-btn,
.hedge-plan-page-wrap .hp-oo-expand-btn {
min-width: 52px; min-width: 52px;
font-weight: 600; font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.14);
@@ -3627,11 +3811,34 @@ html[data-theme="light"] .opt-be-dist-down {
} }
.hedge-plan-page-wrap .hp-uly-btn.active, .hedge-plan-page-wrap .hp-uly-btn.active,
.hedge-plan-page-wrap .hp-uly-btn-oo.active, .hedge-plan-page-wrap .hp-uly-btn-oo.active,
.hedge-plan-page-wrap .hp-money-btn.active { .hedge-plan-page-wrap .hp-money-btn.active,
color: #0b1220; .hedge-plan-page-wrap .hp-oo-money-btn.is-selected,
background: linear-gradient(180deg, #ffffff 0%, #9ec0ff 100%); .hedge-plan-page-wrap .hp-oo-money-btn.active,
border-color: #fff; .hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected,
box-shadow: 0 0 0 2px rgba(100, 160, 255, 0.5); .hedge-plan-page-wrap .hp-oo-recommend-btn.active,
.hedge-plan-page-wrap .hp-oo-expand-btn.is-selected,
.hedge-plan-page-wrap .hp-oo-expand-btn.active {
border-color: var(--accent, #00d4ff);
color: var(--text, #fff);
background: rgba(0, 212, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.35);
font-weight: 700;
}
.hedge-plan-page-wrap .hp-oo-money-btn .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-recommend-btn .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-expand-btn .hp-oo-check {
display: none;
margin-right: 4px;
font-weight: 700;
}
.hedge-plan-page-wrap .hp-oo-money-btn.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-money-btn.active .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-recommend-btn.active .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-expand-btn.is-selected .hp-oo-check,
.hedge-plan-page-wrap .hp-oo-expand-btn.active .hp-oo-check {
display: inline;
color: var(--accent, #00d4ff);
} }
.hedge-plan-page-wrap .hp-money-hint { .hedge-plan-page-wrap .hp-money-hint {
font-size: 0.68rem; font-size: 0.68rem;
@@ -3651,20 +3858,36 @@ html[data-theme="light"] .opt-be-dist-down {
min-height: 26px; min-height: 26px;
} }
.hedge-plan-page-wrap .hp-opt-toolbar .btn-secondary, .hedge-plan-page-wrap .hp-opt-toolbar .btn-secondary,
.hedge-plan-page-wrap .hp-opt-toolbar .hp-money-btn { .hedge-plan-page-wrap .hp-opt-toolbar .hp-money-btn,
.hedge-plan-page-wrap .hp-oo-money-btn,
.hedge-plan-page-wrap .hp-oo-recommend-btn,
.hedge-plan-page-wrap .hp-oo-expand-btn {
padding: 3px 8px; padding: 3px 8px;
min-height: 26px; min-height: 26px;
font-size: 0.72rem; font-size: 0.72rem;
} }
/* 视口约 5 行数据 + 表头超出表内滚动 */ /* 永期期权列表:视口约 5 行 + 表头,超出滚动 */
.hedge-plan-page-wrap .hp-strike-table-wrap--5, .hedge-plan-page-wrap .hp-strike-table-wrap--5 {
.hedge-plan-page-wrap .options-strike-table-wrap--t {
max-height: 248px; max-height: 248px;
min-height: 248px; min-height: 248px;
overflow-y: auto; overflow-y: auto;
margin-top: 4px; margin-top: 4px;
flex: 1 1 auto; flex: 1 1 auto;
} }
/* 期期 T 型:默认 3+3 一页展示,无下滑框 */
.hedge-plan-page-wrap .hp-oo-table-wrap {
max-height: none;
min-height: 0;
overflow: visible;
margin-top: 4px;
flex: 0 0 auto;
}
.hedge-plan-page-wrap .opt-chain-lev,
.hedge-plan-page-wrap .hp-oo-table-wrap .opt-chain-lev {
text-align: center;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.hedge-plan-page-wrap .hp-opt-bal-line { .hedge-plan-page-wrap .hp-opt-bal-line {
margin-top: 4px; margin-top: 4px;
} }
@@ -3682,6 +3905,25 @@ html[data-theme="light"] .opt-be-dist-down {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.hedge-plan-page-wrap #hp-po-layout {
align-items: stretch;
}
.hedge-plan-page-wrap #hp-po-layout > .card {
align-self: stretch;
}
.hedge-plan-page-wrap .hp-po-right-stack > .hp-po-perp-quote-card {
height: auto;
flex: 0 0 auto;
}
.hedge-plan-page-wrap .hp-po-right-stack > .hp-opt-card {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.hedge-plan-page-wrap .hp-po-right-stack > .hp-opt-card .hp-strike-table-wrap--6 {
flex: 1 1 auto;
}
.hedge-plan-page-wrap .hp-action-row { .hedge-plan-page-wrap .hp-action-row {
margin-top: 10px; margin-top: 10px;
gap: 8px; gap: 8px;
@@ -3704,6 +3946,20 @@ html[data-theme="light"] .opt-be-dist-down {
.hedge-plan-page-wrap .opt-row-selected td { .hedge-plan-page-wrap .opt-row-selected td {
background: rgba(90, 140, 255, 0.18); background: rgba(90, 140, 255, 0.18);
} }
.hedge-plan-page-wrap .hp-oo-pick.is-selected,
.hedge-plan-page-wrap .hp-oo-pick.active {
border-color: var(--accent, #00d4ff);
color: var(--text, #fff);
background: rgba(0, 212, 255, 0.22);
box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.45), 0 0 0 2px rgba(0, 212, 255, 0.2);
font-weight: 700;
}
.hedge-plan-page-wrap .hp-oo-side-selected {
background: rgba(0, 212, 255, 0.12);
}
.hedge-plan-page-wrap tr.hp-oo-row-selected td.opt-t-strike {
color: var(--accent, #00d4ff);
}
.hedge-plan-page-wrap .hp-tab-panel.hidden, .hedge-plan-page-wrap .hp-tab-panel.hidden,
.hedge-plan-page-wrap .hp-tab-panel[hidden] { .hedge-plan-page-wrap .hp-tab-panel[hidden] {
display: none !important; display: none !important;
@@ -3868,10 +4124,17 @@ html[data-theme="light"] .hedge-plan-page-wrap .hp-tab.active {
} }
html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn.active, html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn.active,
html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn-oo.active, html[data-theme="light"] .hedge-plan-page-wrap .hp-uly-btn-oo.active,
html[data-theme="light"] .hedge-plan-page-wrap .hp-money-btn.active { html[data-theme="light"] .hedge-plan-page-wrap .hp-money-btn.active,
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-money-btn.is-selected,
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-money-btn.active,
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-recommend-btn.is-selected,
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-recommend-btn.active,
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-expand-btn.is-selected,
html[data-theme="light"] .hedge-plan-page-wrap .hp-oo-expand-btn.active {
color: #0b1220; color: #0b1220;
background: linear-gradient(180deg, #ffffff 0%, #b9d2ff 100%); background: linear-gradient(180deg, #ffffff 0%, #b9d2ff 100%);
border-color: #2f6fed; border-color: #2f6fed;
box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.25);
} }
html[data-theme="light"] .hedge-plan-page-wrap .hp-placeholder { html[data-theme="light"] .hedge-plan-page-wrap .hp-placeholder {
border-color: rgba(15, 23, 42, 0.18); border-color: rgba(15, 23, 42, 0.18);
@@ -4169,6 +4432,9 @@ html[data-theme="light"] .opt-pending-item {
.opt-size-mode-chip { .opt-size-mode-chip {
position: relative; position: relative;
} }
.opt-size-mode-chip[hidden] {
display: none !important;
}
.opt-size-mode-chip input[type="radio"] { .opt-size-mode-chip input[type="radio"] {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
@@ -4192,6 +4458,22 @@ html[data-theme="light"] .opt-pending-item {
min-height: 32px; min-height: 32px;
box-sizing: border-box; box-sizing: border-box;
} }
.options-estimate-row .opt-profit-exit-mult,
.options-page-wrap .opt-pos-profit-exit-mult {
width: 4.5rem;
min-width: 0;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-page-wrap .opt-profit-exit-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
white-space: nowrap;
}
.options-estimate-row .k { .options-estimate-row .k {
color: #8892b0; color: #8892b0;
} }
@@ -4915,36 +5197,43 @@ html[data-theme="light"] .opt-source-badge--oo {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
font-size: 0.72rem;
} }
.opt-history-table th:nth-child(1), .opt-history-table th:nth-child(1),
.opt-history-table td:nth-child(1) { .opt-history-table td:nth-child(1) {
width: 34%; width: 22%;
} }
.opt-history-table th:nth-child(2), .opt-history-table th:nth-child(2),
.opt-history-table td:nth-child(2) { .opt-history-table td:nth-child(2) {
width: 7%; width: 6%;
} }
.opt-history-table th:nth-child(3), .opt-history-table th:nth-child(3),
.opt-history-table td:nth-child(3) { .opt-history-table td:nth-child(3) {
width: 11%; width: 14%;
white-space: nowrap;
} }
.opt-history-table th:nth-child(4), .opt-history-table th:nth-child(4),
.opt-history-table td:nth-child(4) { .opt-history-table td:nth-child(4) {
width: 9%; width: 8%;
} }
.opt-history-table th:nth-child(5), .opt-history-table th:nth-child(5),
.opt-history-table td:nth-child(5) { .opt-history-table td:nth-child(5) {
width: 11%; width: 16%;
white-space: nowrap;
} }
.opt-history-table th:nth-child(6), .opt-history-table th:nth-child(6),
.opt-history-table td:nth-child(6) { .opt-history-table td:nth-child(6) {
width: 20%; width: 22%;
} }
.opt-history-table th:nth-child(7), .opt-history-table th:nth-child(7),
.opt-history-table td:nth-child(7) { .opt-history-table td:nth-child(7) {
width: 8%; width: 12%;
text-align: center; text-align: center;
} }
.opt-history-table .opt-hist-pnl {
white-space: nowrap;
}
.opt-hist-time { .opt-hist-time {
font-size: 0.64rem; font-size: 0.64rem;
white-space: nowrap; white-space: nowrap;
@@ -4990,6 +5279,86 @@ html[data-theme="light"] .opt-source-badge--oo {
.options-pos-head h2 { .options-pos-head h2 {
margin: 0; margin: 0;
} }
.options-pos-head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
}
.opt-bridge-sell-hint {
font-size: 0.78rem;
line-height: 1.35;
max-width: 280px;
text-align: right;
}
.opt-retry-sell-coin-btn--pending {
border-color: #ffb347;
color: #ffb347;
}
.options-page-wrap .opt-pos-transfer {
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.options-page-wrap .opt-pos-transfer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
list-style: none;
font-weight: 600;
font-size: 0.88rem;
margin: 0 0 8px;
}
.options-page-wrap .opt-pos-transfer-head::-webkit-details-marker {
display: none;
}
.options-page-wrap .opt-pos-transfer[open] .opt-pos-transfer-closed-hint {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-open-hint {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-body {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-head {
margin-bottom: 0;
}
.options-page-wrap .opt-pos-transfer-body .options-settings-subtitle {
margin-bottom: 6px;
}
.options-page-wrap .opt-pos-transfer-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0;
}
.options-page-wrap .opt-pos-transfer-form select,
.options-page-wrap .opt-pos-transfer-form input[type="number"] {
font-size: 0.74rem;
}
.options-page-wrap .opt-pos-transfer-form input[type="number"] {
width: 96px;
max-width: 30vw;
}
.options-page-wrap .opt-pos-xfer-msg {
margin-top: 6px;
font-size: 0.76rem;
min-height: 1.1em;
}
.options-page-wrap .opt-pos-xfer-msg.opt-error {
color: #ff7b72;
}
.options-page-wrap .opt-pos-xfer-msg.opt-success {
color: #3dd68c;
}
.opt-pos-card { .opt-pos-card {
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -5680,6 +6049,36 @@ html[data-theme="light"] .options-review-wrap .or-reviewed-table tbody tr:hover
max-width: 100%; max-width: 100%;
} }
body.inst-phone .order-monitor-form .om-row {
align-items: stretch;
}
body.inst-phone .order-monitor-form .om-row-policy > input:not([type="checkbox"]):not([type="radio"]),
body.inst-phone .order-monitor-form .om-row-policy > select,
body.inst-phone .order-monitor-form #sltp-mode,
body.inst-phone .order-monitor-form .om-field,
body.inst-phone .order-monitor-form .om-live-meta,
body.inst-phone .order-monitor-form .om-check,
body.inst-phone .order-monitor-form .om-time-close,
body.inst-phone .order-monitor-form .order-entry-model-row,
body.inst-phone .order-monitor-form .om-submit {
flex: 1 1 100% !important;
width: 100% !important;
max-width: 100% !important;
min-width: 0;
box-sizing: border-box;
margin-left: 0;
}
body.inst-phone .order-monitor-form .om-field input {
width: 100% !important;
max-width: 100% !important;
}
body.inst-phone .order-monitor-form .om-live-meta {
padding-bottom: 0;
}
body.inst-phone .order-plan-preview { body.inst-phone .order-plan-preview {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
+55
View File
@@ -0,0 +1,55 @@
/**
* 实盘下单监控:开仓按钮灰显 + 旁注(强制清仓/冷静期/日冻结等).
*/
(function (global) {
function apply(data) {
const d = data || {};
const btn =
document.getElementById("om-submit-btn") ||
document.querySelector("#add-order-form button.om-submit");
const noteEl = document.getElementById("om-open-block-note");
if (!btn && !noteEl) return;
const canTrade = d.can_trade !== false;
let note = (d.open_block_note || "").trim();
const fc = d.force_close || {};
const rs = d.risk_status || {};
if (!note && fc.enabled && fc.executing) {
const grace = fc.grace_minutes != null ? fc.grace_minutes : 5;
note =
"强制清仓窗口内(北京时间 " +
(fc.hour_label || "--:--") +
" 起 " +
grace +
" 分钟),暂不可开仓";
}
if (!note && rs.can_trade === false && rs.reason) {
note = String(rs.reason);
}
if (!note && !canTrade) {
note = "当前不可开仓";
}
if (btn) {
btn.disabled = !canTrade;
btn.classList.toggle("is-blocked", !canTrade);
btn.setAttribute("aria-disabled", canTrade ? "false" : "true");
if (!canTrade) {
btn.title = note || "当前不可开仓";
} else {
btn.removeAttribute("title");
}
}
if (noteEl) {
if (!canTrade && note) {
noteEl.hidden = false;
noteEl.textContent = note;
} else {
noteEl.hidden = true;
noteEl.textContent = "";
}
}
}
global.OpenSubmitGate = { apply: apply };
})(window);
File diff suppressed because it is too large Load Diff
+94 -14
View File
@@ -34,6 +34,61 @@
return Number(v).toFixed(2); return Number(v).toFixed(2);
} }
function posPremiumCcy(p) {
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
if (ccy) return ccy;
const mode = String((p && p.margin_mode) || "").toLowerCase();
const inst = String((p && p.inst_id) || "");
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
}
return "USDC";
}
function fmtPremiumAmt(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
if (unit === "ETH" || unit === "BTC") {
let s = n.toFixed(8).replace(/\.?0+$/, "");
return s || "0";
}
return fmtUsdc(n);
}
function spotPxOf(p) {
const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px));
return Number.isFinite(n) && n > 0 ? n : null;
}
function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) {
if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—";
const n = Number(coinAmt);
const unit = String(ccy || "ETH").toUpperCase();
if (unit !== "ETH" && unit !== "BTC") {
const sign = signed && n > 0 ? "+" : "";
return sign + fmtUsdc(n) + "U";
}
const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : "";
const coinTxt = coinSign + absCoin + " " + unit;
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
const u = n * px;
const absU = Math.abs(u).toFixed(2);
const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : "";
return coinTxt + " / " + uSign + absU + "U";
}
function fmtNetPnlDual(net, p) {
const ccy = posPremiumCcy(p);
if (ccy === "USDC") {
if (net == null || Number.isNaN(Number(net))) return "—";
return fmtUsdc(Number(net)) + "U";
}
return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true);
}
function optTypeLabel(t) { function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
} }
@@ -96,22 +151,34 @@
} }
const gate = preview.close_gate || {}; const gate = preview.close_gate || {};
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); return "目标门控: " + (preview.close_gate_msg || gate.msg || "门控未过(见 env 目标平仓门控)");
} }
return ""; return "";
} }
function netPnlFromPos(p) { function netPnlFromPos(p) {
const preview = (p && p.close_preview) || {}; const preview = (p && p.close_preview) || {};
if (preview.bid_invalid) {
const upl = p && p.upl != null ? Number(p.upl) : NaN;
return Number.isFinite(upl) ? upl : null;
}
if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) {
return Number(preview.estimated_pnl); return Number(preview.estimated_pnl);
} }
const covered = Number(preview.covered_sheets);
const recv = Number(preview.total_received); const recv = Number(preview.total_received);
const prem = Number(p && p.premium_paid); const prem = Number(p && p.premium_paid);
if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { if (
preview.total_received != null &&
Number.isFinite(covered) &&
covered > 0 &&
!Number.isNaN(recv) &&
!Number.isNaN(prem)
) {
return recv - prem; return recv - prem;
} }
return null; const upl = p && p.upl != null ? Number(p.upl) : NaN;
return Number.isFinite(upl) ? upl : null;
} }
function netRoiFromPos(p, net) { function netRoiFromPos(p, net) {
@@ -124,9 +191,10 @@
return (net / prem) * 100; return (net / prem) * 100;
} }
function fmtClosePreview(preview, premiumPaid, hub) { function fmtClosePreview(preview, premiumPaid, hub, p) {
if (!preview || preview.total_received == null) return "—"; if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received); const ccy = posPremiumCcy(p);
const recvTxt = fmtPremiumAmt(preview.total_received, ccy);
let cls = ""; let cls = "";
const prem = Number(premiumPaid); const prem = Number(premiumPaid);
const recv = Number(preview.total_received); const recv = Number(preview.total_received);
@@ -134,7 +202,7 @@
if (recv > prem) cls = " " + pnlCls(1, hub); if (recv > prem) cls = " " + pnlCls(1, hub);
else if (recv < prem) cls = " " + pnlCls(-1, hub); else if (recv < prem) cls = " " + pnlCls(-1, hub);
} }
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>"; return '<span class="opt-close-value' + cls + '">' + recvTxt + " " + ccy + "</span>";
} }
function expiryCdHtml(expMs) { function expiryCdHtml(expMs) {
@@ -156,7 +224,11 @@
const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
const closePreview = p.close_preview || {}; const closePreview = p.close_preview || {};
const tickSz = p.tick_sz; const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); const premCcy = posPremiumCcy(p);
const premTxt = fmtDisplay(
p.premium_paid_fmt,
p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : null
);
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
let headActions = ""; let headActions = "";
@@ -170,9 +242,9 @@
const pnlCells = hidePnl const pnlCells = hidePnl
? "" ? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' + : '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" + (net == null ? "—" : fmtNetPnlDual(net, p)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' + '<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>"; (roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return ( return (
'<div class="pos-card-head">' + '<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" + '<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + "</strong>" +
@@ -190,7 +262,7 @@
: "") + : "") +
"</div>" + "</div>" +
'<div class="pos-grid">' + '<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" + '<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" + '<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
@@ -201,7 +273,7 @@
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' + '<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid (closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>' ? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" + : fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub, p)) + "</span></div>" +
"</div>" + "</div>" +
(function () { (function () {
const hint = closeGateHint(closePreview); const hint = closeGateHint(closePreview);
@@ -214,6 +286,7 @@
const strike = Number(p.strike); const strike = Number(p.strike);
const tgt = Number(p.target_index); const tgt = Number(p.target_index);
const prem = Number(p.premium_paid); const prem = Number(p.premium_paid);
const idx = Number(p.idx_px);
let profit = null; let profit = null;
let value = null; let value = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
@@ -221,10 +294,17 @@
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) { if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100; value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100; if (!hidePnl && Number.isFinite(prem)) {
let premUsd = prem;
if (premCcy !== "USDC" && Number.isFinite(idx) && idx > 0) premUsd = prem * idx;
profit = Math.round((value - premUsd) * 100) / 100;
}
} }
} }
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); const valueUnit = premCcy !== "USDC" ? " U(估)" : " USDC";
const profitTxt = profit == null
? "—"
: ((profit > 0 ? "+" : "") + fmtUsdc(profit) + (premCcy !== "USDC" ? " U(估)" : " USDC"));
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null; const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
@@ -235,7 +315,7 @@
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' + '<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" + '<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" + '<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" + '<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</span>" +
profitSpan + profitSpan +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + (managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
+20 -5
View File
@@ -49,7 +49,7 @@
if (v == null || v === "") return "—"; if (v == null || v === "") return "—";
var n = Number(v); var n = Number(v);
if (Number.isNaN(n)) return "—"; if (Number.isNaN(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2); return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
} }
function fmtHold(sec) { function fmtHold(sec) {
@@ -76,8 +76,9 @@
target_win_leg: "期期平盈利腿", target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿", target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中", profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
oo_rest_closed: "期期全平·两腿已平", oo_rest_closing: "期期残值平·清亏损腿中",
oo_rest_closed: "期期残值平·两腿已平",
orphaned_after_tp: "止盈后持有至到期", orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期", orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期", hold_to_expiry: "持有至到期",
@@ -248,8 +249,22 @@
return "持平"; return "持平";
} }
function tradeModeFromDom() {
var tabs = document.querySelector(".or-tabs");
return (tabs && tabs.getAttribute("data-okx-trade-mode")) || "options";
}
function defaultSourceForMode(mode) {
if (mode === "options_options") return "options_options";
if (mode === "perp_options") return "perp_options";
return "option_spot";
}
function setActiveTab(source) { function setActiveTab(source) {
activeSource = source || "option_spot"; var mode = tradeModeFromDom();
var allowed = defaultSourceForMode(mode);
activeSource = source || allowed;
if (activeSource !== allowed) activeSource = allowed;
tradesPage = 0; tradesPage = 0;
reviewedPage = 0; reviewedPage = 0;
document.querySelectorAll(".or-tab").forEach(function (btn) { document.querySelectorAll(".or-tab").forEach(function (btn) {
@@ -1312,7 +1327,7 @@
hideJournalForm(); hideJournalForm();
hideDetail(); hideDetail();
hardenSearchAutofill(); hardenSearchAutofill();
setActiveTab("option_spot"); setActiveTab(defaultSourceForMode(tradeModeFromDom()));
} }
function hardenSearchAutofill() { function hardenSearchAutofill() {
+2 -78
View File
@@ -6,7 +6,6 @@
const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"]; const SWAP_BTNS = ["opt-set-swap-btn", "opt-set-swap-all-btn"];
const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"]; const INT_BTNS = ["opt-set-int-btn", "opt-set-int-all-btn"];
const CROSS_BTNS = ["opt-set-cross-btn", "opt-set-cross-all-btn"];
async function apiJson(url, opts) { async function apiJson(url, opts) {
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
@@ -269,80 +268,6 @@
}); });
} }
async function submitCrossTransfer(amount) {
setButtonsBusy(CROSS_BTNS, true, "划转中…");
setMsg("opt-set-cross-msg", "划转中…", false);
try {
const d = await apiJson("/api/options/cross-transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ccy: document.getElementById("opt-set-cross-ccy").value,
amount: amount,
from_account: document.getElementById("opt-set-cross-from").value,
to_account: document.getElementById("opt-set-cross-to").value,
direction: document.getElementById("opt-set-cross-dir").value,
}),
});
if (d.ok) {
setMsg("opt-set-cross-msg", "划转成功", false);
refreshFundsAfterMutation();
} else {
setMsg("opt-set-cross-msg", "划转失败:" + (d.msg || "未知错误"), true);
}
return d;
} catch (e) {
setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "网络错误"), true);
return { ok: false };
} finally {
setButtonsBusy(CROSS_BTNS, false);
}
}
const crossBtn = document.getElementById("opt-set-cross-btn");
if (crossBtn) {
crossBtn.addEventListener("click", async function () {
const amount = parseFloat(document.getElementById("opt-set-cross-amount").value);
if (!amount || amount <= 0) {
setMsg("opt-set-cross-msg", "请输入有效数量", true);
return;
}
await submitCrossTransfer(amount);
});
}
const crossAllBtn = document.getElementById("opt-set-cross-all-btn");
if (crossAllBtn) {
crossAllBtn.addEventListener("click", async function () {
try {
const ccy = document.getElementById("opt-set-cross-ccy").value;
const from = document.getElementById("opt-set-cross-from").value;
const to = document.getElementById("opt-set-cross-to").value;
const direction = document.getElementById("opt-set-cross-dir").value;
const scope = direction === "sub_to_main" ? "sub" : "main";
const sideLabel = direction === "sub_to_main" ? "子账户" : "主账户";
const amount = await resolveMaxAmount(from, ccy, scope);
if (!amount) {
setMsg("opt-set-cross-msg", sideLabel + "划出账户可用余额不足", true);
return;
}
const msg =
"确认全部划转?\n\n" +
"方向:" + (direction === "main_to_sub" ? "主 → 子" : "子 → 主") + "\n" +
"币种:" + ccy + "\n" +
"划出:" + sideLabel + " · " + accountLabel(from) + "\n" +
"划入:" + (direction === "main_to_sub" ? "子账户" : "主账户") + " · " + accountLabel(to) + "\n" +
"金额:" + fmtAmt(amount, ccy) + "\n\n" +
"将划转该账户全部可用余额。";
if (!confirmOk(msg)) return;
document.getElementById("opt-set-cross-amount").value = String(amount);
await submitCrossTransfer(amount);
} catch (e) {
setMsg("opt-set-cross-msg", "划转失败:" + (e.message || "余额拉取失败"), true);
}
});
}
function hardenAmountAutofill(ids) { function hardenAmountAutofill(ids) {
ids.forEach(function (id) { ids.forEach(function (id) {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -366,7 +291,7 @@
} }
// 全部划转/兑换前去掉 readonly,避免写不进数量 // 全部划转/兑换前去掉 readonly,避免写不进数量
["opt-set-swap-all-btn", "opt-set-int-all-btn", "opt-set-cross-all-btn"].forEach(function (btnId) { ["opt-set-swap-all-btn", "opt-set-int-all-btn"].forEach(function (btnId) {
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
if (!btn) return; if (!btn) return;
btn.addEventListener( btn.addEventListener(
@@ -375,7 +300,6 @@
const map = { const map = {
"opt-set-swap-all-btn": "opt-set-swap-amount", "opt-set-swap-all-btn": "opt-set-swap-amount",
"opt-set-int-all-btn": "opt-set-int-amount", "opt-set-int-all-btn": "opt-set-int-amount",
"opt-set-cross-all-btn": "opt-set-cross-amount",
}; };
const input = document.getElementById(map[btnId]); const input = document.getElementById(map[btnId]);
if (input) input.removeAttribute("readonly"); if (input) input.removeAttribute("readonly");
@@ -384,5 +308,5 @@
); );
}); });
hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount", "opt-set-cross-amount"]); hardenAmountAutofill(["opt-set-swap-amount", "opt-set-int-amount"]);
})(); })();
+40 -1
View File
@@ -76,6 +76,7 @@ HOT_RELOAD_EXACT = frozenset({
"TRANSFER_CCY", "TRANSFER_CCY",
"FORCE_CLOSE_ENABLED", "FORCE_CLOSE_ENABLED",
"FORCE_CLOSE_BJ_HOUR", "FORCE_CLOSE_BJ_HOUR",
"FORCE_CLOSE_GRACE_MINUTES",
"BTC_LEVERAGE", "BTC_LEVERAGE",
"ALT_LEVERAGE", "ALT_LEVERAGE",
"DAILY_START_CAPITAL", "DAILY_START_CAPITAL",
@@ -89,8 +90,25 @@ HOT_RELOAD_EXACT = frozenset({
"HEDGE_PLAN_ENABLED", "HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS", "HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_SHOW_PERP_FUNDS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER",
"OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPTION_PRIMARY",
"HEDGE_PLAN_OPEN_ORDER", "HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
@@ -144,6 +162,23 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("budget", "预算金额"), ("budget", "预算金额"),
("sheets", "张数"), ("sheets", "张数"),
), ),
"OKX_TRADE_MODE": (
("options", "单独期权"),
("perp_options", "永期对冲"),
("options_options", "期期对冲"),
),
"OKX_OPTIONS_MARGIN_MODE": (
("coin", "币本位(USDT买币桥)"),
("usdc", "USDC(USDⓈ权利金)"),
),
"OKX_OPTIONS_CLOSE_GATE_MODE": (
("premium", "权利金×倍数"),
("net_pnl", "净盈亏(U)阈值"),
),
"HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
("false", "保险模式"),
),
} }
_SELECT_ALIASES: dict[str, dict[str, str]] = { _SELECT_ALIASES: dict[str, dict[str, str]] = {
@@ -206,7 +241,11 @@ def _field_type(key: str, value: str) -> str:
low = (value or "").strip().lower() low = (value or "").strip().lower()
if low in ("true", "false"): if low in ("true", "false"):
return "bool" return "bool"
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_"): if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
"OKX_SHOW_PERP_FUNDS",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
):
return "bool" return "bool"
try: try:
if "." in low: if "." in low:
+310 -67
View File
@@ -19,27 +19,25 @@ from lib.env.env_schema import (
# 各所「交易所与实盘」字段(顺序即页面顺序) # 各所「交易所与实盘」字段(顺序即页面顺序)
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [ "okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("OKX_API_KEY", "API Key", "永续子账户"),
("OKX_API_SECRET", "API Secret", "永续子账户"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_TD_MODE", "保证金模式", ""), ("OKX_TD_MODE", "保证金模式", ""),
("OKX_POS_MODE", "持仓模式", ""), ("OKX_POS_MODE", "持仓模式", ""),
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
(
"OKX_SHOW_PERP_FUNDS",
"显示永续资金",
"默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧",
),
], ],
"binance": [ "binance": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("BINANCE_API_KEY", "API Key", "永续子账户"),
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
("BINANCE_MARGIN_MODE", "保证金模式", ""), ("BINANCE_MARGIN_MODE", "保证金模式", ""),
("BINANCE_POSITION_MODE", "持仓模式", ""), ("BINANCE_POSITION_MODE", "持仓模式", ""),
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
], ],
"gate": [ "gate": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("GATE_API_KEY", "API Key", "永续子账户"),
("GATE_API_SECRET", "API Secret", "永续子账户"),
("GATE_TD_MODE", "保证金模式", ""), ("GATE_TD_MODE", "保证金模式", ""),
("GATE_POS_MODE", "持仓模式", ""), ("GATE_POS_MODE", "持仓模式", ""),
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
@@ -78,6 +76,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("KEY_AUTO_MIN_PLANNED_RR", "关键位最低盈亏比", "自动单计划 RR 须严格大于该值,默认 1.5"), ("KEY_AUTO_MIN_PLANNED_RR", "关键位最低盈亏比", "自动单计划 RR 须严格大于该值,默认 1.5"),
("FORCE_CLOSE_ENABLED", "强制清仓开关", ""), ("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""), ("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"),
], ],
}, },
{ {
@@ -119,75 +118,212 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
}, },
] ]
_MODE_SECTION: dict[str, Any] = {
"title": "期权/对冲模式",
"exchanges": frozenset({"okx"}),
"fields": [
(
"OKX_TRADE_MODE",
"交易模式",
"三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权",
),
],
}
_OPTIONS_SECTION: dict[str, Any] = { _OPTIONS_SECTION: dict[str, Any] = {
"title": "期权账户", "title": "期权账户",
"exchanges": frozenset({"okx"}), "exchanges": frozenset({"okx"}),
"fields": [ "fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", ""), ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_API_KEY", "期权 API Key", "主账户,与永续子账户分离"),
("OKX_OPTIONS_API_SECRET", "期权 API Secret", ""),
("OKX_OPTIONS_API_PASSPHRASE", "期权 API Passphrase", ""),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), (
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), "OKX_OPTIONS_MARGIN_MODE",
"单笔期权本位",
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
(
"OKX_OPTIONS_COIN_COMPOUND",
"币本位按交易户USDT复利",
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
),
(
"OKX_OPTIONS_COIN_BUDGET_USDT",
"币本位固定预算(USDT)",
"仅币本位且复利关闭时生效",
),
(
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"币本位单笔上限开关",
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
),
(
"OKX_OPTIONS_COIN_MAX_USDT",
"币本位单笔上限(USDT)",
"仅上限开关开启时生效",
),
(
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"币本位现货买入缓冲",
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
),
(
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)",
"仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"), ("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
(
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"期权链展示天数",
"默认 14;下拉到期日只出现该天数内的合约(含明天)",
),
(
"OKX_OPTIONS_MAX_DTE_DAYS",
"开仓最大剩余天数",
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
),
( (
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"链上仅显示有卖一", "链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
), ),
(
"OKX_OPTIONS_CLOSE_GATE_MODE",
"目标平仓门控模式",
"premium=可回收(U)≥权利金(U)×倍数;net_pnl=净盈亏(U)大于阈值。币本位按指数换算为U",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"门控权利金倍数(全局)",
"可选;填写则覆盖下方分本位默认值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"门控权利金倍数(币本位)",
"默认 1.05;premium 模式下 recyclable(U)≥premium(U)×本值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC",
"门控权利金倍数(USDC)",
"默认 2;premium 模式下 recyclable(U)≥premium(U)×本值",
),
(
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U",
"门控净盈亏下限(U)",
"net_pnl 模式;净盈亏(估)须大于本值,如 0 或 1",
),
(
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"门控持续秒数",
"达标后须持续本秒数才通过,默认 120",
),
], ],
} }
# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
(
"MAX_ACTIVE_HEDGE_PLANS",
"对冲组数上限",
"默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
(
"HEDGE_PLAN_BUDGET_BUFFER",
"对冲预算缓冲比例",
"默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
),
(
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"半腿失败时自动平期权",
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
),
]
_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
(
"HEDGE_PLAN_OPTION_PRIMARY",
"永期模式(以期权为主/保险)",
"默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
),
("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_ITM_MAX_DIST_USD",
"永期实值最大深度(U)",
"默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
),
(
"HEDGE_PLAN_MIN_OPTION_HOURS",
"对冲期权最低剩余小时",
"默认 8;测算/启动时若传 hours_to_expiry 则校验",
),
(
"HEDGE_PLAN_MIN_OPTION_LEVERAGE",
"对冲期权最低杠杆(S/ask)",
"默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
),
]
_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
(
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
),
(
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"期期做多做空拆分口径",
"默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
),
(
"HEDGE_PLAN_OO_BIAS_RATIO",
"期期做多做空主腿占比",
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
),
]
# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
_HEDGE_PLAN_SECTION: dict[str, Any] = { _HEDGE_PLAN_SECTION: dict[str, Any] = {
"title": "对冲计划", "title": "对冲计划",
"exchanges": frozenset({"okx"}), "exchanges": frozenset({"okx"}),
"fields": [ "fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "关闭则隐藏导航且不可开仓"), ("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "默认 true;关闭后隐藏永期 Tab,不可测算/开仓"), ("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "默认 true;关闭后隐藏期期 Tab,不可测算/开仓"), ("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动永期"), ("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"), *_HEDGE_COMMON_FIELDS,
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"), *_HEDGE_PO_FIELDS,
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"), *_HEDGE_OO_FIELDS,
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
(
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」(盈利腿平后另一腿);关闭则固定到期平",
),
(
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"期期做多做空拆分口径",
"默认预算金额;budget=按权利金预算按比例分两腿;sheets=先算同张数总张数(2n)再按比例拆",
),
(
"HEDGE_PLAN_OO_BIAS_RATIO",
"期期做多做空主腿占比",
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
),
(
"HEDGE_PLAN_BUDGET_BUFFER",
"对冲预算缓冲比例",
"默认 0.95;仅对冲计划(期期可用预算=交易户×本比例);与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
),
(
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"对冲与期权互斥门控",
"默认 true;开启时:有对冲计划则不可单独开期权,有单独期权则不可启动对冲;关闭后两边可同时开",
),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开永续/腿B;并强制关闭下方自动平",
),
("MAX_ACTIVE_HEDGE_PLANS", "最大同时活跃计划数", "建议 1"),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
(
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"半腿失败时自动平期权",
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效(不会自动平)",
),
], ],
} }
@@ -205,17 +341,42 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"TRANSFER_CCY": "USDT", "TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true", "HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true", "HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"OKX_SHOW_PERP_FUNDS": "true",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
"OKX_OPTIONS_MAX_DTE_DAYS": "2",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
"OKX_TRADE_MODE": "options",
"MAX_ACTIVE_HEDGE_PLANS": "1",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true", "HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget", "HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7", "HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
"HEDGE_PLAN_BUDGET_BUFFER": "0.95", "HEDGE_PLAN_BUDGET_BUFFER": "0.95",
"HEDGE_PLAN_OPTION_PRIMARY": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true", "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC": "2",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U": "0",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
} }
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str: def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key == "OKX_TRADE_MODE":
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
if file_val:
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
return normalize_okx_trade_mode(file_val) or file_val
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
pass
if key in file_values: if key in file_values:
file_val = str(file_values.get(key) or "").strip() file_val = str(file_values.get(key) or "").strip()
if file_val: if file_val:
@@ -287,24 +448,69 @@ def _build_field(
return out return out
def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]: def _okx_mode_for_env_ui() -> str:
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
return "options"
def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
fields = list(_OPTIONS_SECTION["fields"])
if mode != "options":
fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
return fields
def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
if mode == "perp_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
if mode == "options_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
return []
def ui_sections_for_exchange(
exchange_key: str,
*,
mode: str | None = None,
) -> list[dict[str, Any]]:
ex = (exchange_key or "").strip().lower() ex = (exchange_key or "").strip().lower()
sections: list[dict[str, Any]] = [] sections: list[dict[str, Any]] = []
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"]) live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
sections.append({"title": "交易所与实盘", "fields": live_fields}) sections.append({"title": "交易所与实盘", "fields": live_fields})
sections.extend(_SHARED_SECTIONS) sections.extend(_SHARED_SECTIONS)
if ex in _OPTIONS_SECTION.get("exchanges", frozenset()): if ex in _MODE_SECTION.get("exchanges", frozenset()):
sections.append(_OPTIONS_SECTION) from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
if ex in _HEDGE_PLAN_SECTION.get("exchanges", frozenset()):
sections.append(_HEDGE_PLAN_SECTION) m = normalize_okx_trade_mode(mode) if mode else ""
if not m:
m = _okx_mode_for_env_ui()
sections.append(_MODE_SECTION)
sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
hedge_fields = _hedge_fields_for_mode(m)
if hedge_fields:
title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期"
sections.append({"title": title, "fields": hedge_fields})
return sections return sections
def ui_allowed_keys(exchange_key: str) -> frozenset[str]: def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
"""可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
keys: set[str] = set() keys: set[str] = set()
for sec in ui_sections_for_exchange(exchange_key): for sec in ui_sections_for_exchange(exchange_key):
for item in sec["fields"]: for item in sec["fields"]:
keys.add(item[0]) keys.add(item[0])
ex = (exchange_key or "").strip().lower()
if ex == "okx":
keys.add("OKX_TRADE_MODE")
# 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
for item in _HEDGE_PLAN_SECTION["fields"]:
keys.add(item[0])
for item in _OPTIONS_SECTION["fields"]:
keys.add(item[0])
return frozenset(keys) return frozenset(keys)
@@ -317,11 +523,14 @@ def build_env_ui_payload(
env_lines = read_env_lines(env_path) env_lines = read_env_lines(env_path)
values = env_get_all(env_lines) values = env_get_all(env_lines)
groups: list[dict[str, Any]] = [] groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange(exchange_key): for sec in ui_sections_for_exchange(
exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
):
fields = [ fields = [
_build_field(key, label, note, schema, values) _build_field(key, label, note, schema, values)
for key, label, note in sec["fields"] for key, label, note in sec["fields"]
] ]
fields = _mark_options_env_field_visibility(fields)
groups.append({ groups.append({
"title": sec["title"], "title": sec["title"],
"fields": fields, "fields": fields,
@@ -330,6 +539,40 @@ def build_env_ui_payload(
return groups return groups
def _mark_options_env_field_visibility(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按本位/全仓复利隐藏无关项(供 SSR/前端;切换开关仍可再显示)."""
compound_on = True
margin_mode = "coin"
for f in fields:
key = f.get("key")
cur = str(f.get("current") or f.get("default") or "").strip()
if key == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
compound_on = _env_truthy(cur or "true")
elif key == "OKX_OPTIONS_MARGIN_MODE":
margin_mode = (cur or "coin").lower()
if margin_mode not in ("coin", "usdc"):
margin_mode = "coin"
out: list[dict[str, Any]] = []
for f in fields:
item = dict(f)
key = item.get("key")
hide = False
if key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
hide = True
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC" and margin_mode == "coin":
hide = True
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and margin_mode == "usdc":
hide = True
if hide:
item["hidden"] = True
out.append(item)
return out
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""兼容旧调用名;实际走 _mark_options_env_field_visibility."""
return _mark_options_env_field_visibility(fields)
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]: def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
allowed = ui_allowed_keys(exchange_key) allowed = ui_allowed_keys(exchange_key)
return {k: v for k, v in (updates or {}).items() if k in allowed} return {k: v for k, v in (updates or {}).items() if k in allowed}
+94
View File
@@ -0,0 +1,94 @@
"""交易所 API 凭证规范化.
新机 .env 密钥应为空;示例占位符不得注入 ccxt,否则鉴权失败且
(尤其 Gate)反复签名请求易触发 IP 封禁.
"""
from __future__ import annotations
from typing import Any, Optional
_PLACEHOLDER_EXACT = frozenset(
{
"你的密钥",
"your-api-key",
"your_api_key",
"your-api-secret",
"your_api_secret",
"todo",
"xxx",
"changeme",
}
)
def normalize_api_credential(value: Optional[str]) -> str:
"""去空白;空/占位符一律视为未配置."""
s = (value or "").strip().strip('"').strip("'")
if not s:
return ""
upper = s.upper()
if upper.startswith("REPLACE_WITH"):
return ""
if upper.startswith("CHANGE_TO"):
return ""
if s.lower() in _PLACEHOLDER_EXACT:
return ""
return s
def credentials_configured(*parts: Optional[str]) -> bool:
return all(bool(normalize_api_credential(p)) for p in parts)
def is_exchange_auth_error(exc: BaseException) -> bool:
"""鉴权/无效 Key 类错误(用于停掉后续签名请求,避免 Gate 封 IP)."""
name = type(exc).__name__
if name in ("AuthenticationError", "PermissionDenied", "InvalidNonce"):
return True
msg = str(exc)
markers = (
"Invalid Api-Key",
"Invalid API-key",
"Invalid API Key",
"INVALID_KEY",
"Invalid key",
"API key is invalid",
"api key not found",
"Signature",
"INVALID_SIGNATURE",
"401",
"-2008",
"-2014",
"-2015",
"10003", # Gate: invalid key often
"INVALID_KEY",
)
low = msg.lower()
if "api" in low and ("key" in low or "sign" in low) and (
"invalid" in low or "incorrect" in low or "not found" in low
):
return True
return any(m in msg for m in markers)
def strip_ccxt_credentials(exchange: Any) -> None:
"""内存中清空密钥,后续只走公开接口,避免继续带坏钥签名."""
try:
exchange.apiKey = ""
except Exception:
pass
try:
exchange.secret = ""
except Exception:
pass
try:
exchange.password = ""
except Exception:
pass
def load_markets_public_fallback(exchange: Any, *, reload: bool = False) -> None:
"""鉴权失败后去掉密钥再拉公开 markets(最多再请求一次)."""
strip_ccxt_credentials(exchange)
exchange.load_markets(reload=reload)
+187
View File
@@ -0,0 +1,187 @@
"""Binance:交易账户 futures income;资金账户 deposits/withdrawals/transfers.USDT."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
kind_from_raw,
make_ref_id,
normalize_row,
)
def _paginate_income(exchange, *, start_ms: int, end_ms: int, max_pages: int = 15) -> list[dict]:
out: list[dict] = []
cursor = int(start_ms)
end = int(end_ms)
for _ in range(max_pages):
try:
if hasattr(exchange, "fapiPrivateGetIncome"):
batch = exchange.fapiPrivateGetIncome(
{"startTime": cursor, "endTime": end, "limit": 1000}
)
else:
batch = exchange.fetch_ledger(
"USDT", cursor, 1000, {"type": "swap", "until": end}
)
# already unified
return batch or []
except Exception:
break
if not batch:
break
out.extend(batch)
if len(batch) < 1000:
break
last_t = batch[-1].get("time") or batch[-1].get("timestamp")
try:
last_i = int(float(last_t))
except Exception:
break
if last_i >= end:
break
cursor = last_i + 1
return out
def _income_to_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
# raw fapi income
if "income" in raw or "incomeType" in raw:
amt = raw.get("income")
ts = raw.get("time")
ccy = raw.get("asset") or "USDT"
raw_type = str(raw.get("incomeType") or "")
ref = str(raw.get("tranId") or raw.get("tradeId") or "")
return normalize_row(
account=ACCOUNT_TRADING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("trading", ccy, ts, amt, raw_type),
raw_type=raw_type,
symbol=str(raw.get("symbol") or ""),
note=str(raw.get("info") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
return from_ccxt_ledger_entry(raw, account=ACCOUNT_TRADING)
def _dep_wd_to_row(entry: dict, *, kind: str) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
ts = entry.get("timestamp") or info.get("insertTime") or info.get("applyTime")
ccy = entry.get("currency") or info.get("coin") or "USDT"
status = entry.get("status") or info.get("status") or ""
ref = str(entry.get("id") or info.get("txId") or info.get("id") or "")
amt = amount
try:
af = float(amount)
if kind == "withdraw" and af > 0:
af = -af
amt = af
except Exception:
pass
return normalize_row(
account=ACCOUNT_FUNDING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", kind, ccy, ts, amount),
raw_type=kind,
note=str(status),
kind=kind,
)
def _transfer_to_row(entry: dict) -> Optional[dict[str, Any]]:
if not isinstance(entry, dict):
return None
info = entry.get("info") if isinstance(entry.get("info"), dict) else {}
amount = entry.get("amount")
ts = entry.get("timestamp") or info.get("timestamp")
ccy = entry.get("currency") or info.get("asset") or "USDT"
ref = str(entry.get("id") or info.get("tranId") or info.get("id") or "")
frm = str(entry.get("fromAccount") or info.get("from") or "")
to = str(entry.get("toAccount") or info.get("to") or "")
try:
amt = float(amount)
except Exception:
return None
# 资金侧视角:从资金转出为负,转入为正(粗分)
note = f"{frm}->{to}".strip("->")
raw_type = "transfer"
return normalize_row(
account=ACCOUNT_FUNDING,
ccy=str(ccy),
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", "transfer", ccy, ts, amt),
raw_type=raw_type,
note=note,
kind=kind_from_raw("transfer", amt),
)
def fetch_binance_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
# 交易账户
try:
raw = _paginate_income(exchange, start_ms=start_ms, end_ms=end_ms)
for e in raw:
n = _income_to_row(e)
if n and n["ccy"] == "USDT":
rows.append(n)
except Exception as e:
errors.append(f"trading:{e}")
# 资金账户:充提 + 划转
for label, fn, kind in (
("deposits", "fetch_deposits", "deposit"),
("withdrawals", "fetch_withdrawals", "withdraw"),
):
try:
meth = getattr(exchange, fn, None)
if not callable(meth):
continue
batch = meth("USDT", int(start_ms), 1000, {"until": int(end_ms)}) or []
for e in batch:
n = _dep_wd_to_row(e, kind=kind)
if n:
rows.append(n)
except Exception as e:
errors.append(f"{label}:{e}")
try:
if hasattr(exchange, "fetch_transfers"):
batch = (
exchange.fetch_transfers("USDT", int(start_ms), 1000, {"until": int(end_ms)})
or []
)
for e in batch:
n = _transfer_to_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"transfers:{e}")
return rows, errors
+212
View File
@@ -0,0 +1,212 @@
"""Gate:资金账户(spot account_book) + 交易账户(futures account_book),USDT."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
kind_from_raw,
make_ref_id,
normalize_row,
)
def _sec(ms: int) -> int:
return max(0, int(int(ms) // 1000))
def _paginate_spot_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
out: list[dict] = []
# Gate spot account_book: from/to 为秒
cursor = _sec(start_ms)
end = _sec(end_ms)
for _ in range(max_pages):
try:
batch = exchange.privateSpotGetAccountBook(
{
"currency": "USDT",
"from": cursor,
"to": end,
"limit": 100,
}
)
except Exception:
break
if not batch:
break
if isinstance(batch, dict):
batch = batch.get("data") or batch.get("result") or []
if not isinstance(batch, list) or not batch:
break
out.extend(batch)
if len(batch) < 100:
break
last_t = batch[-1].get("time") or batch[-1].get("create_time")
try:
last_i = int(float(last_t))
except Exception:
break
# spot 返回秒
if last_i > 1e12:
last_i = last_i // 1000
if last_i >= end:
break
cursor = last_i + 1
return out
def _paginate_swap_book(exchange, *, start_ms: int, end_ms: int, max_pages: int = 10) -> list[dict]:
out: list[dict] = []
cursor = _sec(start_ms)
end = _sec(end_ms)
for _ in range(max_pages):
try:
batch = exchange.privateFuturesGetSettleAccountBook(
{
"settle": "usdt",
"from": cursor,
"to": end,
"limit": 100,
}
)
except Exception:
break
if not batch:
break
if isinstance(batch, dict):
batch = batch.get("data") or batch.get("result") or []
if not isinstance(batch, list) or not batch:
break
out.extend(batch)
if len(batch) < 100:
break
last_t = batch[-1].get("time")
try:
last_i = int(float(last_t))
except Exception:
break
if last_i > 1e12:
last_i = last_i // 1000
if last_i >= end:
break
cursor = last_i + 1
return out
def _spot_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
amt = raw.get("change")
ts = raw.get("time") or raw.get("create_time")
# 秒 → 毫秒
try:
t = float(ts)
if t < 1e12:
t = t * 1000.0
ts = t
except Exception:
pass
raw_type = str(raw.get("type") or raw.get("change_type") or "")
bal = raw.get("balance")
ref = str(raw.get("id") or raw.get("txid") or "")
return normalize_row(
account=ACCOUNT_FUNDING,
ccy="USDT",
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("funding", raw_type, ts, amt),
raw_type=raw_type,
balance_after=bal,
note=str(raw.get("text") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
def _swap_row(raw: dict) -> Optional[dict[str, Any]]:
if not isinstance(raw, dict):
return None
# futures account_book: change, balance, type, text, time, contract...
amt = raw.get("change")
ts = raw.get("time")
try:
t = float(ts)
if t < 1e12:
t = t * 1000.0
ts = t
except Exception:
pass
raw_type = str(raw.get("type") or "")
bal = raw.get("balance")
ref = str(raw.get("id") or "")
return normalize_row(
account=ACCOUNT_TRADING,
ccy="USDT",
amount=amt,
ts_ms=ts,
ref_id=ref or make_ref_id("trading", raw_type, ts, amt, raw.get("contract")),
raw_type=raw_type,
balance_after=bal,
symbol=str(raw.get("contract") or ""),
note=str(raw.get("text") or ""),
kind=kind_from_raw(raw_type, float(amt) if amt is not None else None),
)
def fetch_gate_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
try:
for e in _paginate_spot_book(exchange, start_ms=start_ms, end_ms=end_ms):
n = _spot_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"funding:{e}")
# 回退 ccxt fetch_ledger
try:
batch = exchange.fetch_ledger(
"USDT", int(start_ms), 100, {"type": "spot", "until": int(end_ms)}
) or []
for e in batch:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
if n:
rows.append(n)
except Exception as e2:
errors.append(f"funding_fallback:{e2}")
try:
for e in _paginate_swap_book(exchange, start_ms=start_ms, end_ms=end_ms):
n = _swap_row(e)
if n:
rows.append(n)
except Exception as e:
errors.append(f"trading:{e}")
try:
batch = exchange.fetch_ledger(
"USDT",
int(start_ms),
100,
{"type": "swap", "settle": "usdt", "until": int(end_ms)},
) or []
for e in batch:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
if n:
rows.append(n)
except Exception as e2:
errors.append(f"trading_fallback:{e2}")
return rows, errors
+99
View File
@@ -0,0 +1,99 @@
"""OKX:资金账户 asset bills + 交易账户 account bills;USDT + USDC."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.account_ledger.account_ledger_normalize import (
ACCOUNT_FUNDING,
ACCOUNT_TRADING,
from_ccxt_ledger_entry,
)
OKX_LEDGER_CCYS = ("USDT", "USDC")
def _fetch_one(
exchange,
*,
code: str,
since: int,
until: int,
method: str,
max_pages: int = 10,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
after = None
for _ in range(max_pages):
params: dict[str, Any] = {"method": method, "until": int(until)}
if after:
params["after"] = after
try:
batch = exchange.fetch_ledger(code, int(since), 100, params) or []
except Exception:
# archive / bills 窗口差异:失败则停
break
if not batch:
break
out.extend(batch)
if len(batch) < 100:
break
# OKX 翻页用 billId
last = batch[-1]
info = last.get("info") if isinstance(last.get("info"), dict) else {}
bid = last.get("id") or info.get("billId")
if not bid:
break
after = str(bid)
return out
def fetch_okx_account_ledger(
exchange,
*,
start_ms: int,
end_ms: int,
ensure_markets: Optional[Callable[[], None]] = None,
) -> tuple[list[dict[str, Any]], list[str]]:
errors: list[str] = []
rows: list[dict[str, Any]] = []
if ensure_markets:
try:
ensure_markets()
except Exception as e:
errors.append(f"markets:{e}")
for ccy in OKX_LEDGER_CCYS:
# 资金账户
try:
raw = _fetch_one(
exchange,
code=ccy,
since=start_ms,
until=end_ms,
method="privateGetAssetBills",
)
for e in raw:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_FUNDING)
if n:
rows.append(n)
except Exception as e:
errors.append(f"funding:{ccy}:{e}")
# 交易账户:近 3 月 archive + 近 7 日 bills(去重靠 upsert)
for method in ("privateGetAccountBillsArchive", "privateGetAccountBills"):
try:
raw = _fetch_one(
exchange,
code=ccy,
since=start_ms,
until=end_ms,
method=method,
)
for e in raw:
n = from_ccxt_ledger_entry(e, account=ACCOUNT_TRADING)
if n:
rows.append(n)
except Exception as e:
errors.append(f"trading:{ccy}:{method}:{e}")
return rows, errors
+321 -121
View File
@@ -13,24 +13,19 @@ import ccxt
from lib.options.options_pricing_lib import ( from lib.options.options_pricing_lib import (
expiry_breakeven_from_ask, expiry_breakeven_from_ask,
idx_distance_to_be, idx_distance_to_be,
intrinsic_px_per_unit,
is_shallow_itm, is_shallow_itm,
option_moneyness, option_moneyness,
option_moneyness_label, option_moneyness_label,
strike_distance_to_be,
) )
_OKX_OPTION_ERR_ZH: dict[str, str] = { _OKX_OPTION_ERR_ZH: dict[str, str] = {
"51008": "资金账户 USDT 可用余额不足", "51008": "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够;USDC 模式请确认 USDC 足够)",
"51018": "期权账户不能持有净空头头寸", "51018": "期权账户不能持有净空头头寸",
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
} }
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
row: dict[str, Any] | None = None row: dict[str, Any] | None = None
@@ -51,10 +46,25 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
pass pass
if row: if row:
code = str(row.get("sCode") or "") code = str(row.get("sCode") or "")
msg = str(row.get("sMsg") or "").strip()
low = msg.lower()
if code == "51008":
# 勿写死「资金账户 USDT」:USDC 模式常因交易户 USDC 不足;币本位则是标的币不足
if "usdc" in low:
return "交易账户 USDC 可用余额不足"
if "usdt" in low:
return "USDT 可用余额不足"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
if is_coin_margin_mode():
return "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够,或减少张数)"
except Exception:
pass
return _OKX_OPTION_ERR_ZH["51008"]
zh = _OKX_OPTION_ERR_ZH.get(code) zh = _OKX_OPTION_ERR_ZH.get(code)
if zh: if zh:
return zh return zh
msg = str(row.get("sMsg") or "").strip()
if msg: if msg:
return msg return msg
if exc is not None: if exc is not None:
@@ -65,6 +75,28 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
return "下单失败" return "下单失败"
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活
_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock()
_OPTION_INSTRUMENTS_CACHE_TTL = 90.0
_OPTION_INSTRUMENTS_STALE_MAX = 600.0
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
with _OPTION_INSTRUMENTS_CACHE_LOCK:
if inst_family:
_OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None)
else:
_OPTION_INSTRUMENTS_CACHE.clear()
def td_mode_for_option_buy(configured: str | None = None) -> str: def td_mode_for_option_buy(configured: str | None = None) -> str:
"""OKX 买入期权(多头)必须使用逐仓.""" """OKX 买入期权(多头)必须使用逐仓."""
mode = (configured or "isolated").strip().lower() mode = (configured or "isolated").strip().lower()
@@ -72,16 +104,22 @@ def td_mode_for_option_buy(configured: str | None = None) -> str:
def create_options_exchange( def create_options_exchange(
api_key: str, api_key: str = "",
api_secret: str, api_secret: str = "",
passphrase: str, passphrase: str = "",
proxies: dict[str, str] | None = None, proxies: dict[str, str] | None = None,
) -> ccxt.okx: ) -> ccxt.okx:
"""创建 option 客户端.未传密钥时读 OKX_API_*(与永续同源)."""
import os
key = (api_key or os.getenv("OKX_API_KEY") or "").strip()
secret = (api_secret or os.getenv("OKX_API_SECRET") or "").strip()
password = (passphrase or os.getenv("OKX_API_PASSPHRASE") or "").strip()
ex = ccxt.okx( ex = ccxt.okx(
{ {
"apiKey": api_key, "apiKey": key,
"secret": api_secret, "secret": secret,
"password": passphrase, "password": password,
"enableRateLimit": True, "enableRateLimit": True,
"options": {"defaultType": "option"}, "options": {"defaultType": "option"},
} }
@@ -135,6 +173,21 @@ def format_usdc_amount(v: float | None) -> str | None:
return f"{float(v):.2f}" return f"{float(v):.2f}"
def format_premium_amount(v: float | None, *, ccy: str | None = "USDC") -> str | None:
"""权利金/回收金额文案:USDC 2 位;币本位 ETH/BTC 最多 8 位去尾零."""
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
unit = (ccy or "USDC").strip().upper() or "USDC"
if unit in ("ETH", "BTC"):
txt = f"{n:.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{n:.2f}"
def is_option_full_close_history(raw: dict[str, Any]) -> bool: def is_option_full_close_history(raw: dict[str, Any]) -> bool:
"""仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓.""" """仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓."""
close_type = str(raw.get("type") or "").strip() close_type = str(raw.get("type") or "").strip()
@@ -152,7 +205,10 @@ def option_history_row_key(
pos_id = (pos_id or "").strip() pos_id = (pos_id or "").strip()
if source == "live": if source == "live":
return f"live:{inst_id}:{pos_id or close_ms or '0'}" return f"live:{inst_id}:{pos_id or close_ms or '0'}"
# OKX 可能对同合约多次开平复用 posId,必须带上平仓时间区分
if pos_id: if pos_id:
if close_ms:
return f"ex:{pos_id}:{int(close_ms)}"
return f"ex:{pos_id}" return f"ex:{pos_id}"
return f"ex:{inst_id}:{close_ms or 0}" return f"ex:{inst_id}:{close_ms or 0}"
@@ -197,15 +253,6 @@ def tick_sz_and_ct_mult(
return tick_sz, ct_mult or 0.01 return tick_sz, ct_mult or 0.01
def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None:
o = (opt_type or "").upper()
if o == "C" and index_px > strike:
return float(index_px) - float(strike)
if o == "P" and index_px < strike:
return float(strike) - float(index_px)
return None
def _resolve_chain_quote( def _resolve_chain_quote(
*, *,
ticker: dict[str, Any], ticker: dict[str, Any],
@@ -213,6 +260,7 @@ def _resolve_chain_quote(
opt_type: str, opt_type: str,
strike: float, strike: float,
index_px: float, index_px: float,
inst_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一).""" """链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一)."""
tick_sz = meta.get("tickSz") tick_sz = meta.get("tickSz")
@@ -222,12 +270,13 @@ def _resolve_chain_quote(
ask_sz = _safe_float(ticker.get("askSz")) ask_sz = _safe_float(ticker.get("askSz"))
bid_sz = _safe_float(ticker.get("bidSz")) bid_sz = _safe_float(ticker.get("bidSz"))
ask_estimated = False ask_estimated = False
iid = (inst_id or str(meta.get("instId") or "")).strip()
if ask is None and mark is not None and mark > 0: if ask is None and mark is not None and mark > 0:
ask = round_option_px(mark, tick_sz, "buy") ask = round_option_px(mark, tick_sz, "buy")
ask_estimated = True ask_estimated = True
if ask is None: if ask is None:
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
if intrinsic is not None and intrinsic > 0: if intrinsic is not None and intrinsic > 0:
ask = round_option_px(intrinsic, tick_sz, "buy") ask = round_option_px(intrinsic, tick_sz, "buy")
ask_estimated = True ask_estimated = True
@@ -235,7 +284,7 @@ def _resolve_chain_quote(
if bid is None and mark is not None and mark > 0: if bid is None and mark is not None and mark > 0:
bid = round_option_px(mark, tick_sz, "sell") bid = round_option_px(mark, tick_sz, "sell")
if bid is None: if bid is None:
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
if intrinsic is not None and intrinsic > 0: if intrinsic is not None and intrinsic > 0:
bid = round_option_px(intrinsic, tick_sz, "sell") bid = round_option_px(intrinsic, tick_sz, "sell")
@@ -398,25 +447,31 @@ def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] |
family = inst_family_from_inst_id(inst_id) family = inst_family_from_inst_id(inst_id)
if not family: if not family:
return None return None
# 优先从全族缓存取,避免每选一腿再打 instruments
try:
cached_rows = fetch_option_instruments(ex, family, allow_stale=True)
for r in cached_rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r
except Exception:
pass
last_err: BaseException | None = None last_err: BaseException | None = None
for attempt in range(3): for attempt in range(2):
try: try:
rows = ex.public_get_public_instruments( rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family, "instId": inst_id} {"instType": "OPTION", "instFamily": family, "instId": inst_id}
).get("data") or [] ).get("data") or []
if rows and isinstance(rows[0], dict): if rows and isinstance(rows[0], dict):
return rows[0] return rows[0]
rows = ex.public_get_public_instruments( rows = fetch_option_instruments(ex, family, allow_stale=True)
{"instType": "OPTION", "instFamily": family}
).get("data") or []
for r in rows: for r in rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id: if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r return r
return None return None
except Exception as e: except Exception as e:
last_err = e last_err = e
if _is_okx_rate_limit(e) and attempt < 2: if _is_okx_rate_limit(e) and attempt < 1:
time.sleep(0.45 * (attempt + 1)) time.sleep(1.2)
continue continue
break break
if last_err is not None and _is_okx_rate_limit(last_err): if last_err is not None and _is_okx_rate_limit(last_err):
@@ -471,8 +526,8 @@ def fetch_account_balances_by_type(
ex: ccxt.okx, ex: ccxt.okx,
account_type: str, account_type: str,
) -> tuple[dict[str, float | None], dict[str, float | None]]: ) -> tuple[dict[str, float | None], dict[str, float | None]]:
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try: try:
bal = ex.fetch_balance(params={"type": account_type}) bal = ex.fetch_balance(params={"type": account_type})
for c in out: for c in out:
@@ -487,8 +542,8 @@ def fetch_funding_balances_via_asset_api(
ex: ccxt.okx, ex: ccxt.okx,
) -> tuple[dict[str, float | None], dict[str, float | None]]: ) -> tuple[dict[str, float | None], dict[str, float | None]]:
"""OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确.""" """OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确."""
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try: try:
resp = ex.private_get_asset_balances({}) resp = ex.private_get_asset_balances({})
for row in (resp or {}).get("data") or []: for row in (resp or {}).get("data") or []:
@@ -571,24 +626,34 @@ def fetch_options_balances(
funding = _merge_balance_maps(funding, asset_funding) funding = _merge_balance_maps(funding, asset_funding)
funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail) funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail)
trading, trading_avail = fetch_account_balances_by_type(ex, "trading") trading, trading_avail = fetch_account_balances_by_type(ex, "trading")
if trading.get("USDC") is None: # OKX 统一账户:option 客户端拉 type=trading 常缺 USDT/币;用 swap 补齐缺失项
if any(trading.get(c) is None for c in ("USDT", "USDC", "ETH", "BTC")):
swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap") swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap")
if swap_bal.get("USDC") is not None: for ccy in ("USDT", "USDC", "USDG", "ETH", "BTC"):
trading["USDC"] = swap_bal["USDC"] if trading.get(ccy) is None and swap_bal.get(ccy) is not None:
if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None: trading[ccy] = swap_bal[ccy]
trading_avail["USDC"] = swap_avail["USDC"] if trading_avail.get(ccy) is None and swap_avail.get(ccy) is not None:
trading_avail[ccy] = swap_avail[ccy]
result = { result = {
"scope": "main", "scope": "main",
"funding_usdt": funding.get("USDT"), "funding_usdt": funding.get("USDT"),
"funding_usdc": funding.get("USDC"), "funding_usdc": funding.get("USDC"),
"funding_usdg": funding.get("USDG"), "funding_usdg": funding.get("USDG"),
"funding_eth": funding.get("ETH"),
"funding_btc": funding.get("BTC"),
"funding_usdt_avail": funding_avail.get("USDT"), "funding_usdt_avail": funding_avail.get("USDT"),
"funding_usdc_avail": funding_avail.get("USDC"), "funding_usdc_avail": funding_avail.get("USDC"),
"funding_eth_avail": funding_avail.get("ETH"),
"funding_btc_avail": funding_avail.get("BTC"),
"trading_usdt": trading.get("USDT"), "trading_usdt": trading.get("USDT"),
"trading_usdc": trading.get("USDC"), "trading_usdc": trading.get("USDC"),
"trading_usdg": trading.get("USDG"), "trading_usdg": trading.get("USDG"),
"trading_eth": trading.get("ETH"),
"trading_btc": trading.get("BTC"),
"trading_usdt_avail": trading_avail.get("USDT"), "trading_usdt_avail": trading_avail.get("USDT"),
"trading_usdc_avail": trading_avail.get("USDC"), "trading_usdc_avail": trading_avail.get("USDC"),
"trading_eth_avail": trading_avail.get("ETH"),
"trading_btc_avail": trading_avail.get("BTC"),
} }
_OPTIONS_BALANCE_CACHE["updated_at"] = now _OPTIONS_BALANCE_CACHE["updated_at"] = now
_OPTIONS_BALANCE_CACHE["data"] = result _OPTIONS_BALANCE_CACHE["data"] = result
@@ -600,23 +665,67 @@ def options_header_balances(
*, *,
force: bool = False, force: bool = False,
) -> tuple[float | None, float | None, float | None, float | None]: ) -> tuple[float | None, float | None, float | None, float | None]:
"""顶栏四格:交易 USDC/USDT,资金 USDC/USDT(单次拉取 + 缓存).""" """顶栏期权两格用 USDC;顺带返回同账户 USDT(调用方勿再计入总资金,避免与永续栏重复).
返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt)
"""
pack = options_header_balance_pack(ex, force=force)
return (
pack.get("trading_usdc"),
pack.get("funding_usdc"),
pack.get("funding_usdt"),
pack.get("trading_usdt"),
)
def options_header_balance_pack(
ex: ccxt.okx,
*,
force: bool = False,
) -> dict[str, Any]:
"""顶栏/快照用期权资金包(含币本位 ETH/BTC)."""
import os
bal = fetch_options_balances(ex, force=force) bal = fetch_options_balances(ex, force=force)
def _round(v: Any) -> float | None: def _round(v: Any, nd: int = 2) -> float | None:
if v is None: if v is None:
return None return None
try: try:
return round(float(v), 2) return round(float(v), nd)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return ( def _round_coin(v: Any) -> float | None:
_round(bal.get("trading_usdc")), if v is None:
_round(bal.get("funding_usdc")), return None
_round(bal.get("funding_usdt")), try:
_round(bal.get("trading_usdt")), return round(float(v), 8)
) except (TypeError, ValueError):
return None
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
except Exception:
margin_mode = "usdc"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
coin_key = "btc" if underly == "BTC" else "eth"
return {
"trading_usdc": _round(bal.get("trading_usdc")),
"funding_usdc": _round(bal.get("funding_usdc")),
"funding_usdt": _round(bal.get("funding_usdt")),
"trading_usdt": _round(bal.get("trading_usdt")),
"funding_eth": _round_coin(bal.get("funding_eth")),
"trading_eth": _round_coin(bal.get("trading_eth")),
"funding_btc": _round_coin(bal.get("funding_btc")),
"trading_btc": _round_coin(bal.get("trading_btc")),
"options_margin_mode": margin_mode,
"options_underly": underly,
"funding_coin": _round_coin(bal.get(f"funding_{coin_key}")),
"trading_coin": _round_coin(bal.get(f"trading_{coin_key}")),
}
def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
@@ -633,11 +742,42 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
def fetch_option_instruments( def fetch_option_instruments(
ex: ccxt.okx, ex: ccxt.okx,
inst_family: str, inst_family: str,
*,
force: bool = False,
allow_stale: bool = True,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
rows = ex.public_get_public_instruments( """拉取 OPTION instruments;进程内缓存,50011 时回退旧列表."""
{"instType": "OPTION", "instFamily": inst_family} family = str(inst_family or "").strip()
).get("data") or [] if not family:
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] return []
now = time.time()
with _OPTION_INSTRUMENTS_CACHE_LOCK:
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
if (
not force
and entry is not None
and entry.get("rows") is not None
and now - float(entry.get("updated_at") or 0) < _OPTION_INSTRUMENTS_CACHE_TTL
):
return list(entry["rows"])
try:
rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family}
).get("data") or []
live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
with _OPTION_INSTRUMENTS_CACHE_LOCK:
_OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live}
return list(live)
except Exception as e:
if allow_stale:
with _OPTION_INSTRUMENTS_CACHE_LOCK:
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
if entry is not None and entry.get("rows") is not None:
age = now - float(entry.get("updated_at") or 0)
if age <= _OPTION_INSTRUMENTS_STALE_MAX:
return list(entry["rows"])
raise
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
@@ -662,31 +802,46 @@ def build_option_chain(
itm_only: bool = True, itm_only: bool = True,
itm_max_dist_usd: float = 30.0, itm_max_dist_usd: float = 30.0,
index_px: float | None = None, index_px: float | None = None,
margin_mode: str | None = None,
inst_family: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
u = (underlying or "ETH").upper() u = (underlying or "ETH").upper()
family = f"{u}-USD_UM" if inst_family:
family = str(inst_family).strip()
else:
try:
from lib.options.options_margin_mode_lib import inst_family_for_underlying
family = inst_family_for_underlying(u, margin_mode=margin_mode)
except Exception:
family = f"{u}-USD_UM"
uly = f"{u}-USD" uly = f"{u}-USD"
idx = index_px if index_px is not None else fetch_index_price(ex, uly) idx = index_px if index_px is not None else fetch_index_price(ex, uly)
chain_margin = "usdc" if "_UM" in family.upper() else "coin"
now_ms = time.time() * 1000 now_ms = time.time() * 1000
max_ms = now_ms + max_dte_days * 86400 * 1000 max_ms = now_ms + max_dte_days * 86400 * 1000
instruments_err = "" instruments_err = ""
instruments: list[dict[str, Any]] = [] instruments: list[dict[str, Any]] = []
for attempt in range(2): try:
try: instruments = fetch_option_instruments(ex, family)
instruments = fetch_option_instruments(ex, family) if not instruments:
instruments_err = "" # 空列表可能是瞬时空;短退避后强制再拉一次(非 50011)
if instruments: time.sleep(0.5)
break instruments = fetch_option_instruments(ex, family, force=True)
if not instruments:
instruments_err = "期权合约列表为空" instruments_err = "期权合约列表为空"
except Exception as e: except Exception as e:
instruments = [] instruments = []
instruments_err = str(e) or e.__class__.__name__ instruments_err = str(e) or e.__class__.__name__
if attempt == 0: # 限频:再等一下用 stale/缓存,不要连打
time.sleep(0.35) if _is_okx_rate_limit(e):
continue time.sleep(1.5)
break try:
if attempt == 0 and not instruments: instruments = fetch_option_instruments(ex, family, allow_stale=True)
time.sleep(0.35) if instruments:
instruments_err = ""
except Exception as e2:
instruments_err = str(e2) or e2.__class__.__name__
tickers = fetch_option_tickers(ex, family) tickers = fetch_option_tickers(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {} expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0 skipped_no_index = 0
@@ -719,6 +874,7 @@ def build_option_chain(
opt_type=opt_type, opt_type=opt_type,
strike=strike, strike=strike,
index_px=idx, index_px=idx,
inst_id=inst_id,
) )
ask = q["ask"] ask = q["ask"]
bid = q["bid"] bid = q["bid"]
@@ -730,6 +886,8 @@ def build_option_chain(
strike=strike, strike=strike,
ask_px=ask, ask_px=ask,
mark_px=mark, mark_px=mark,
inst_id=inst_id,
margin_mode=chain_margin,
) )
mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx) mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx)
exp_key = str(exp_ms) exp_key = str(exp_ms)
@@ -746,7 +904,7 @@ def build_option_chain(
"mark_px": mark, "mark_px": mark,
"ask_estimated": q["ask_estimated"], "ask_estimated": q["ask_estimated"],
"expiry_be_px": expiry_be, "expiry_be_px": expiry_be,
"dist_expiry_be": idx_distance_to_be(idx, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=opt_type),
"moneyness": mny, "moneyness": mny,
"moneyness_label": option_moneyness_label(mny), "moneyness_label": option_moneyness_label(mny),
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
@@ -762,6 +920,8 @@ def build_option_chain(
"underlying": u, "underlying": u,
"index_px": idx, "index_px": idx,
"inst_family": family, "inst_family": family,
"margin_mode": "usdc" if "_UM" in family.upper() else "coin",
"premium_ccy": "USDC" if "_UM" in family.upper() else u,
"expiries": exp_list, "expiries": exp_list,
"instruments_count": len(instruments), "instruments_count": len(instruments),
} }
@@ -872,6 +1032,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
strike=strike, strike=strike,
ask_px=book_ask if can_open else None, ask_px=book_ask if can_open else None,
mark_px=mark, mark_px=mark,
inst_id=inst_id,
) )
return { return {
"ok": True, "ok": True,
@@ -890,7 +1051,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
"open_block_msg": "" if can_open else open_block_msg, "open_block_msg": "" if can_open else open_block_msg,
"index_px": idx, "index_px": idx,
"expiry_be_px": expiry_be, "expiry_be_px": expiry_be,
"dist_expiry_be": idx_distance_to_be(idx, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
"min_sz": int(_safe_float(meta.get("minSz")) or 1), "min_sz": int(_safe_float(meta.get("minSz")) or 1),
"tick_sz": tick_sz, "tick_sz": tick_sz,
@@ -1300,6 +1461,15 @@ def format_option_history_row(
ctime = _safe_float(raw.get("cTime")) ctime = _safe_float(raw.get("cTime"))
opt_type, strike = option_fields_from_inst_id(inst_id) opt_type, strike = option_fields_from_inst_id(inst_id)
uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "") uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "")
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
premium_ccy = premium_ccy_for_mode(row_mode, uly or "ETH")
except Exception:
row_mode = "usdc"
premium_ccy = "USDC"
idx_px = _safe_float(raw.get("idxPx") or raw.get("idx_px"))
if close_type in ("3", "4"): if close_type in ("3", "4"):
status_label = "强平" status_label = "强平"
else: else:
@@ -1321,12 +1491,17 @@ def format_option_history_row(
"strike": strike, "strike": strike,
"sheets": sheets_i, "sheets": sheets_i,
"eth_amount": eth_amount, "eth_amount": eth_amount,
"ct_mult": ct_mult,
"open_avg_px": open_avg, "open_avg_px": open_avg,
"open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None, "open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None,
"close_avg_px": close_avg, "close_avg_px": close_avg,
"close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None, "close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None,
"premium_paid": premium_paid, "premium_paid": premium_paid,
"premium_paid_fmt": format_usdc_amount(premium_paid), "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"premium_ccy": premium_ccy,
"margin_mode": row_mode,
"margin_mode_label": "币本位" if row_mode == "coin" else "USDC",
"idx_px": idx_px,
"realized_pnl": realized, "realized_pnl": realized,
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
"status": "closed", "status": "closed",
@@ -1349,6 +1524,7 @@ def format_live_option_history_row(
inst_id = str(row.get("inst_id") or "").strip() inst_id = str(row.get("inst_id") or "").strip()
pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None
close_ms = open_ms close_ms = open_ms
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
return { return {
"source": "live", "source": "live",
"history_key": option_history_row_key( "history_key": option_history_row_key(
@@ -1370,6 +1546,10 @@ def format_live_option_history_row(
"close_avg_px_fmt": None, "close_avg_px_fmt": None,
"premium_paid": row.get("premium_paid"), "premium_paid": row.get("premium_paid"),
"premium_paid_fmt": row.get("premium_paid_fmt"), "premium_paid_fmt": row.get("premium_paid_fmt"),
"premium_ccy": premium_ccy,
"margin_mode": row.get("margin_mode"),
"margin_mode_label": row.get("margin_mode_label"),
"idx_px": row.get("idx_px"),
"realized_pnl": row.get("upl"), "realized_pnl": row.get("upl"),
"pnl_ratio_pct": row.get("upl_ratio_pct"), "pnl_ratio_pct": row.get("upl_ratio_pct"),
"status": "open", "status": "open",
@@ -1387,28 +1567,69 @@ def resolve_option_close_from_history(
hist_rows: list[dict[str, Any]], hist_rows: list[dict[str, Any]],
*, *,
open_ms: int | None = None, open_ms: int | None = None,
close_ms: int | None = None,
sheets: float | int | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""从 positions-history 选取最近一条有效平仓/结算记录.""" """从 positions-history 选取匹配的平仓记录.
best: dict[str, Any] | None = None
best_utime = -1 同合约多次开平时,优先按开仓时间(cTimeopen_ms)对齐,再按平仓时间/张数;
无锚点时取开仓后最晚一条(供刚平掉的持仓同步)
"""
candidates: list[tuple[int, dict[str, Any]]] = []
for row in hist_rows: for row in hist_rows:
u_ms = _safe_float(row.get("uTime")) u_ms = _safe_float(row.get("uTime"))
if u_ms is None or u_ms <= 0: if u_ms is None or u_ms <= 0:
continue continue
if open_ms is not None and u_ms < int(open_ms) - 60_000: u_i = int(u_ms)
# 本地时间偶发与交易所差整时区时,放宽到 12h,主要靠 cTime/张数精配
if open_ms is not None and u_i < int(open_ms) - 12 * 3600_000:
continue continue
if u_ms > best_utime: candidates.append((u_i, row))
best = row if not candidates:
best_utime = int(u_ms)
if not best:
return None return None
has_ctime = any(_safe_float(row.get("cTime")) is not None for _, row in candidates)
want_sheets = _safe_float(sheets)
def _score(item: tuple[int, dict[str, Any]]) -> tuple:
u_i, row = item
c_ms = _safe_float(row.get("cTime"))
parts: list[float] = []
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
if want_sheets is not None:
hist_sheets = _safe_float(row.get("closeTotalPos"))
if hist_sheets is None:
hist_sheets = _safe_float(row.get("openMaxPos"))
parts.append(
abs(float(hist_sheets) - float(want_sheets))
if hist_sheets is not None
else 1e12
)
if open_ms is not None and c_ms is not None:
parts.append(float(abs(int(c_ms) - int(open_ms))))
if close_ms is not None:
parts.append(float(abs(u_i - int(close_ms))))
if not parts:
parts.append(float(-u_i))
# 同距时偏向更晚平仓
parts.append(float(-u_i))
return tuple(parts)
if open_ms is None and close_ms is None and want_sheets is None:
u_i, best = max(candidates, key=lambda item: item[0])
elif open_ms is not None and close_ms is None and want_sheets is None and not has_ctime:
# 兼容旧调用:只有 open_ms 时仍取最晚一条
u_i, best = max(candidates, key=lambda item: item[0])
else:
u_i, best = min(candidates, key=_score)
realized = _safe_float(best.get("realizedPnl")) realized = _safe_float(best.get("realizedPnl"))
if realized is None: if realized is None:
realized = _safe_float(best.get("pnl")) realized = _safe_float(best.get("pnl"))
return { return {
"close_quote": _safe_float(best.get("closeAvgPx")), "close_quote": _safe_float(best.get("closeAvgPx")),
"realized_pnl": realized, "realized_pnl": realized,
"close_ms": best_utime, "close_ms": u_i,
"pos_id": str(best.get("posId") or "").strip() or None, "pos_id": str(best.get("posId") or "").strip() or None,
} }
@@ -1562,43 +1783,6 @@ def spot_market_swap_usdt_usdc(
return {"ok": False, "msg": _okx_trade_error_message(e)} return {"ok": False, "msg": _okx_trade_error_message(e)}
def transfer_main_sub_account(
ex: ccxt.okx,
*,
ccy: str,
amount: float,
sub_acct: str,
main_to_sub: bool,
from_account: str = "funding",
to_account: str = "funding",
) -> dict[str, Any]:
"""主账户与子账户之间划转(须主账户 API)."""
if amount <= 0:
return {"ok": False, "msg": "划转金额须大于 0"}
sub = (sub_acct or "").strip()
if not sub:
return {"ok": False, "msg": "未配置子账户名称 OKX_SUB_ACCOUNT_NAME"}
from_code = _OKX_ACCT_CODE.get((from_account or "funding").lower(), "6")
to_code = _OKX_ACCT_CODE.get((to_account or "funding").lower(), "6")
try:
resp = ex.private_post_asset_transfer(
{
"type": "1" if main_to_sub else "2",
"ccy": str(ccy).upper(),
"amt": str(amount),
"from": from_code,
"to": to_code,
"subAcct": sub,
}
)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode", "0")) == "0":
return {"ok": True, "data": data[0], "raw": resp}
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
except Exception as e:
return {"ok": False, "msg": _okx_trade_error_message(e)}
def format_position_row( def format_position_row(
pos: dict[str, Any], pos: dict[str, Any],
ct_mult: float = 0.01, ct_mult: float = 0.01,
@@ -1609,6 +1793,7 @@ def format_position_row(
close_breakeven_idx, close_breakeven_idx,
expiry_breakeven_px, expiry_breakeven_px,
idx_distance_to_be, idx_distance_to_be,
strike_distance_to_be,
total_premium, total_premium,
) )
@@ -1626,6 +1811,16 @@ def format_position_row(
opt_type = parsed_type opt_type = parsed_type
if strike is None: if strike is None:
strike = parsed_strike strike = parsed_strike
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = premium_ccy_for_mode(row_mode, underly)
except Exception:
row_mode = "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = "USDC"
eth_amount = round(abs(sheets) * ct_mult, 8) eth_amount = round(abs(sheets) * ct_mult, 8)
premium_paid = ( premium_paid = (
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
@@ -1636,6 +1831,8 @@ def format_position_row(
strike=strike, strike=strike,
avg_px=avg, avg_px=avg,
be_px_api=_safe_float(pos.get("bePx")), be_px_api=_safe_float(pos.get("bePx")),
inst_id=inst_id,
margin_mode=row_mode,
) )
close_be = close_breakeven_idx( close_be = close_breakeven_idx(
opt_type=str(opt_type or ""), opt_type=str(opt_type or ""),
@@ -1655,11 +1852,14 @@ def format_position_row(
"mark_px": mark, "mark_px": mark,
"avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None, "avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None,
"mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None, "mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None,
"premium_paid_fmt": format_usdc_amount(premium_paid), "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"tick_sz": tick_sz, "tick_sz": tick_sz,
"ct_mult": ct_mult, "ct_mult": ct_mult,
"idx_px": idx_px, "idx_px": idx_px,
"premium_paid": premium_paid, "premium_paid": premium_paid,
"margin_mode": row_mode,
"premium_ccy": premium_ccy,
"underlying": underly,
"upl": upl, "upl": upl,
"upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None,
"exp_time": exp_time_ms, "exp_time": exp_time_ms,
@@ -1669,7 +1869,7 @@ def format_position_row(
"avail_pos": _safe_float(pos.get("availPos")), "avail_pos": _safe_float(pos.get("availPos")),
"expiry_be_px": expiry_be, "expiry_be_px": expiry_be,
"close_be_px": close_be, "close_be_px": close_be,
"dist_expiry_be": idx_distance_to_be(idx_px, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
"dist_close_be": idx_distance_to_be(idx_px, close_be), "dist_close_be": idx_distance_to_be(idx_px, close_be),
"raw": pos, "raw": pos,
} }
@@ -31,7 +31,7 @@ def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
if count_active_plans(conn) > 0: if count_active_plans(conn) > 0:
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)" return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
except Exception: except Exception:
return None return "互斥门控校验失败,暂禁止单独开期权"
return None return None
@@ -77,10 +77,10 @@ def block_hedge_plan_start_msg(
try: try:
rows = fetch_positions(exchange) or [] rows = fetch_positions(exchange) or []
except Exception: except Exception:
return None return "获取期权持仓失败,暂禁止启动对冲计划"
try: try:
if has_standalone_option_position(conn, rows): if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)" return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception: except Exception:
return None return "互斥门控校验失败,暂禁止启动对冲计划"
return None return None
+158 -5
View File
@@ -58,6 +58,42 @@ def option_expiry_pnl(
return value - float(premium_paid) return value - float(premium_paid)
def spot_from_expiry_intrinsic_profit(
*,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
profit: float,
) -> float | None:
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
到期价值=实值×张数×乘数;盈亏=价值权利金 实值/=(profit+权利金)/(张数×乘数).
Call: spot=K+实值/; Put: spot=K实值/.
"""
try:
k = float(strike)
n = float(sheets or 0)
ct = float(ct_mult or 0.01)
prem = float(premium_paid or 0)
pnl = float(profit)
except (TypeError, ValueError):
return None
denom = n * ct
if denom <= 0:
return None
need = (pnl + prem) / denom
if need < 0:
need = 0.0
o = (opt_type or "").strip().upper()
if o in ("C", "CALL"):
return round(k + need, 2)
if o in ("P", "PUT"):
return round(k - need, 2)
return None
def suggest_contracts_from_notional( def suggest_contracts_from_notional(
*, *,
notional: float, notional: float,
@@ -447,11 +483,16 @@ def build_options_options_preview(
target_price: float | None = None, target_price: float | None = None,
target_price_up: float | None = None, target_price_up: float | None = None,
target_price_down: float | None = None, target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float, index_px: float,
leg_a: dict[str, Any], leg_a: dict[str, Any],
leg_b: dict[str, Any], leg_b: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
残值按亏损腿本合约权利金的 20% .
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float: def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl( return option_expiry_pnl(
@@ -463,15 +504,127 @@ def build_options_options_preview(
premium_paid=float(leg.get("premium_paid") or 0), premium_paid=float(leg.get("premium_paid") or 0),
) )
prem_a = float(leg_a.get("premium_paid") or 0)
prem_b = float(leg_b.get("premium_paid") or 0)
prem = prem_a + prem_b
rr = float(profit_rr) if profit_rr is not None else None
# 新:盈亏比情景(不依赖指数上下破价)
if rr is not None and rr > 0:
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
win_profit = rr * prem
a_at_a = win_profit
b_at_a_full = -prem_b
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
b_at_b = win_profit
a_at_b_full = -prem_a
a_at_b_res = -prem_a * 0.8
spot_a = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_a.get("opt_type") or ""),
strike=float(leg_a["strike"]),
sheets=float(leg_a.get("sheets") or 0),
ct_mult=float(leg_a.get("ct_mult") or 0.01),
premium_paid=prem_a,
profit=win_profit,
)
spot_b = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_b.get("opt_type") or ""),
strike=float(leg_b["strike"]),
sheets=float(leg_b.get("sheets") or 0),
ct_mult=float(leg_b.get("ct_mult") or 0.01),
premium_paid=prem_b,
profit=win_profit,
)
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"profit_rr": rr,
"target_price": None,
"target_price_up": None,
"target_price_down": None,
"winner_at_up": "a",
"winner_at_down": "b",
"winner_at_target": "a",
"scenarios": [
{
"id": "rr_leg_a_full",
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_full, 4),
"total": round(a_at_a + b_at_a_full, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_b_full",
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
"spot": spot_b,
"leg_a_pnl": round(a_at_b_full, 4),
"leg_b_pnl": round(b_at_b, 4),
"total": round(a_at_b_full + b_at_b, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_a_residual",
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_res, 4),
"total": round(a_at_a + b_at_a_res, 4),
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
},
{
"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(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"profit_rr": rr,
"spot_at_rr_a": spot_a,
"spot_at_rr_b": spot_b,
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
"at_target_total": round(a_at_a + b_at_a_full, 4),
"expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
"rr_risk_premium": round(prem, 6),
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
},
}
# 兼容旧单目标:若未传上下目标则用 target_price 填两边 # 兼容旧单目标:若未传上下目标则用 target_price 填两边
up = target_price_up if target_price_up is not None else target_price up = target_price_up if target_price_up is not None else target_price
down = target_price_down if target_price_down is not None else target_price down = target_price_down if target_price_down is not None else target_price
if up is None or down is None: if up is None or down is None:
raise ValueError("缺少上破/下破目标价") raise ValueError("缺少盈亏比或上破/下破目标价")
up_f = float(up) up_f = float(up)
down_f = float(down) down_f = float(down)
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
a_up = _leg_pnl(leg_a, up_f) a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f) b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up at_up = a_up + b_up
@@ -528,8 +681,8 @@ def build_options_options_preview(
"id": "max_premium_loss", "id": "max_premium_loss",
"label": "最大保费损耗", "label": "最大保费损耗",
"spot": None, "spot": None,
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4), "leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4), "leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4), "total": round(-prem, 4),
"note": "双腿权利金全部损失", "note": "双腿权利金全部损失",
}, },
+77 -8
View File
@@ -72,8 +72,22 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
) )
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL") _ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL") _ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状) # 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
# 永期「以期权为主」
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
_ensure_column(conn, "hedge_plans", "option_target_points", "REAL")
_ensure_column(conn, "hedge_plans", "perp_target_points", "REAL")
_ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL")
_ensure_column(conn, "hedge_plans", "premium_budget", "REAL")
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
@@ -88,15 +102,19 @@ def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str)
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int: def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
if plan_type: if plan_type:
row = conn.execute( row = conn.execute(
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial') AND plan_type=?", f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
(plan_type,), (plan_type,),
).fetchone() ).fetchone()
else: else:
row = conn.execute( row = conn.execute(
"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ('opening','active','partial')" f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
).fetchone() ).fetchone()
return int((row["c"] if row else 0) or 0) return int((row["c"] if row else 0) or 0)
@@ -189,7 +207,7 @@ def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
if not plan: if not plan:
return {"ok": False, "msg": "计划不存在"} return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "") st = str(plan.get("status") or "")
if st in ("opening", "active", "partial"): if st in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"} return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),)) conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),)) conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
@@ -225,14 +243,30 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
legs = get_plan_legs(conn, int(p["id"])) legs = get_plan_legs(conn, int(p["id"]))
row = dict(p) row = dict(p)
row["legs"] = legs row["legs"] = legs
row["contracts_summary"] = legs_contract_summary(legs) summary = legs_contract_summary(legs)
if str(p.get("status") or "") == "watching" and (not legs or summary == ""):
money = str(p.get("option_moneyness") or "otm")
money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
parts = [f"盯盘·{money_lab}"]
try:
if p.get("strike_interval") not in (None, ""):
parts.append(f"间隔{float(p.get('strike_interval')):g}")
except (TypeError, ValueError):
pass
try:
if p.get("option_leverage") not in (None, ""):
parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
except (TypeError, ValueError):
pass
summary = "·".join(parts)
row["contracts_summary"] = summary
row["missing_leg"] = missing_leg_role(legs) row["missing_leg"] = missing_leg_role(legs)
out.append(row) out.append(row)
return out return out
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。 """返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
这些目标由 hedge_plan_monitor_lib 执行绝不能写入 options_target_monitors 这些目标由 hedge_plan_monitor_lib 执行绝不能写入 options_target_monitors
否则两套监控会同时尝试平掉同一条期权腿 否则两套监控会同时尝试平掉同一条期权腿
@@ -240,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
l.inst_id, l.opt_type p.profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options' WHERE p.plan_type = 'options_options'
@@ -256,9 +290,24 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
row = dict(raw) row = dict(raw)
inst_id = str(row.get("inst_id") or "") inst_id = str(row.get("inst_id") or "")
opt_type = str(row.get("opt_type") or "").upper() opt_type = str(row.get("opt_type") or "").upper()
if not inst_id or inst_id in out:
continue
profit_rr = _sf(row.get("profit_rr"))
if profit_rr is not None and profit_rr > 0:
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"profit_rr": profit_rr,
"target_index": None,
"exit_mode": "profit_rr",
"managed_by": "hedge_plan",
}
continue
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target) target_f = _sf(target)
if not inst_id or target_f is None or target_f <= 0 or inst_id in out: if target_f is None or target_f <= 0:
continue continue
out[inst_id] = { out[inst_id] = {
"plan_id": int(row["plan_id"]), "plan_id": int(row["plan_id"]),
@@ -272,6 +321,26 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
return out return out
def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]:
"""进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组."""
rows = conn.execute(
"""
SELECT DISTINCT l.inst_id
FROM hedge_plan_legs l
JOIN hedge_plans p ON p.id = l.plan_id
WHERE p.status IN ('opening', 'active', 'partial')
AND l.status IN ('open', 'hold_to_expiry')
AND l.inst_id IS NOT NULL
AND TRIM(l.inst_id) != ''
AND (
l.leg_role LIKE 'option%'
OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '')
)
"""
).fetchall()
return {str(r[0]).strip() for r in rows if r and r[0]}
def _sf(v: Any) -> Optional[float]: def _sf(v: Any) -> Optional[float]:
try: try:
if v is None or v == "": if v is None or v == "":
+285
View File
@@ -0,0 +1,285 @@
"""对冲计划虚实值选约与校验.
永期(perp_options):期权腿仅允许实值或平值(禁虚值).
期期(options_options):两腿仅允许平值或虚值(禁实值).
"""
from __future__ import annotations
import os
from typing import Any, Optional
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def itm_max_dist_usd() -> float:
"""过深实值上限(USD).优先对冲专用,否则回退期权页."""
raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip()
if raw:
try:
return max(0.0, float(raw))
except ValueError:
pass
return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0))
def min_option_hours() -> float:
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0))
def min_option_leverage() -> float:
"""指数/卖一 最低杠杆门槛;0=不启用."""
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0))
def _sf(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str:
o = str(opt_type or "").strip().upper()
if o in ("C", "CALL"):
return "C"
if o in ("P", "PUT"):
return "P"
inst = str(inst_id or "").upper()
if inst.endswith("-C") or inst.endswith("-CALL"):
return "C"
if inst.endswith("-P") or inst.endswith("-PUT"):
return "P"
return ""
def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
"""itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径."""
from lib.options.options_pricing_lib import option_moneyness
return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool:
"""Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm."""
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
if m in ("itm", "atm"):
return True
# 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C":
return k <= s + 1e-9
if o == "P":
return k >= s - 1e-9
return False
def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool:
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
if m in ("atm", "otm"):
return True
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C":
return k >= s - 1e-9 # 平值带内或虚值
if o == "P":
return k <= s + 1e-9
return False
def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float:
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C" and k < s:
return s - k
if o == "P" and k > s:
return k - s
return 0.0
def parse_strike_from_inst(inst_id: str) -> Optional[float]:
"""从 OKX 合约名解析行权价: ETH-USD-260731-1800-P."""
parts = str(inst_id or "").strip().upper().split("-")
if len(parts) < 5:
return None
return _sf(parts[-2])
def pick_itm_or_atm_contract(
contracts: list[dict[str, Any]],
*,
opt_type: str,
index_px: float,
itm_max_dist: Optional[float] = None,
) -> Optional[dict[str, Any]]:
"""在合约列表中选距标的最近的实值/平值腿."""
want = normalize_opt_type(opt_type)
if not want or index_px <= 0:
return None
max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd()
cands: list[tuple[float, float, dict[str, Any]]] = []
for c in contracts or []:
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
continue
k = _sf(c.get("strike"))
if k is None:
continue
if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px):
continue
depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px)
if max_dist > 0 and depth > max_dist:
continue
cands.append((abs(k - index_px), k, c))
if not cands:
return None
cands.sort(key=lambda x: (x[0], x[1]))
return cands[0][2]
def pick_atm_or_otm_contract(
contracts: list[dict[str, Any]],
*,
opt_type: str,
index_px: float,
prefer: str = "atm",
) -> Optional[dict[str, Any]]:
"""选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值)."""
want = normalize_opt_type(opt_type)
if not want or index_px <= 0:
return None
prefer_l = (prefer or "atm").strip().lower()
cands: list[tuple[float, float, dict[str, Any]]] = []
for c in contracts or []:
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
continue
k = _sf(c.get("strike"))
if k is None:
continue
if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px):
continue
m = classify_moneyness(opt_type=want, strike=k, index_px=index_px)
if prefer_l == "otm" and m != "otm":
continue
if prefer_l == "atm" and m == "otm":
# 仍可入选,但排序靠后(先 atm)
cands.append((1_000_000 + abs(k - index_px), k, c))
else:
cands.append((abs(k - index_px), k, c))
if not cands:
return None
cands.sort(key=lambda x: (x[0], x[1]))
return cands[0][2]
def recommend_oo_legs(
contracts: list[dict[str, Any]],
*,
index_px: float,
template: str = "atm_straddle",
) -> Optional[tuple[dict[str, Any], dict[str, Any]]]:
"""期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put."""
tpl = (template or "atm_straddle").strip().lower()
prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm"
call = pick_atm_or_otm_contract(
contracts, opt_type="C", index_px=index_px, prefer=prefer
)
put = pick_atm_or_otm_contract(
contracts, opt_type="P", index_px=index_px, prefer=prefer
)
if not call or not put:
return None
if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""):
return None
return call, put
def validate_po_option_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
ask: Any = None,
hours_to_expiry: Any = None,
) -> Optional[str]:
"""永期保险腿校验;返回错误文案或 None."""
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return "期权类型无效"
if k is None or s is None or s <= 0:
return "行权价或指数无效,无法校验虚实值"
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
return "永期保险腿须为实值或平值,不可选虚值"
max_dist = itm_max_dist_usd()
depth = itm_depth_usd(opt_type=o, strike=k, index_px=s)
if max_dist > 0 and depth > max_dist:
return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档"
min_h = min_option_hours()
h = _sf(hours_to_expiry)
if min_h > 0 and h is not None and h < min_h:
return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h"
min_lev = min_option_leverage()
a = _sf(ask)
if min_lev > 0 and a is not None and a > 0:
lev = s / a
if lev < min_lev:
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
return None
def validate_oo_leg_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
role: str = "",
) -> Optional[str]:
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return f"{role}期权类型无效"
if k is None or s is None or s <= 0:
return f"{role}行权价或指数无效,无法校验虚实值"
m = classify_moneyness(opt_type=o, strike=k, index_px=s)
if m == "itm":
return f"{role}须为平值或虚值,不可选实值"
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
return f"{role}须为平值或虚值"
return None
def validate_oo_legs_moneyness(
leg_a: dict[str, Any],
leg_b: dict[str, Any],
*,
index_px: Any,
) -> Optional[str]:
err = validate_oo_leg_moneyness(
opt_type=leg_a.get("opt_type"),
strike=leg_a.get("strike"),
index_px=index_px,
role="腿A",
)
if err:
return err
err = validate_oo_leg_moneyness(
opt_type=leg_b.get("opt_type"),
strike=leg_b.get("strike"),
index_px=index_px,
role="腿B",
)
if err:
return err
return None
File diff suppressed because it is too large Load Diff
+40 -18
View File
@@ -46,13 +46,22 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
] ]
) )
else: else:
lines.extend( rr = plan.get("profit_rr")
[ if rr not in (None, ""):
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" lines.extend(
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", [
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
] f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
) ]
)
else:
lines.extend(
[
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
if legs: if legs:
for leg in legs: for leg in legs:
role = leg.get("leg_role") or "" role = leg.get("leg_role") or ""
@@ -81,8 +90,9 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
"target_win_leg": "期期已平盈利腿(中间态)", "target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿", "target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿",
"oo_rest_closing": "期期全平·清残腿中", "profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
"oo_rest_closed": "期期全平·两腿已平", "oo_rest_closing": "期期残值平·清亏损腿中",
"oo_rest_closed": "期期残值平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损", "oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利", "oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口", "expiry": "到期收口",
@@ -152,25 +162,37 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_win_leg", "target_win_leg",
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing", "oo_rest_closing",
) and (plan.get("status") or "") != "closed": ) and (plan.get("status") or "") != "closed":
side = "上破" if "up" in str(plan.get("close_reason")) else ( cr = str(plan.get("close_reason") or "")
"下破" if "down" in str(plan.get("close_reason")) else "目标价" if "profit_rr" in cr:
) side = "盈亏比达标"
elif "up" in cr:
side = "上破"
elif "down" in cr:
side = "下破"
else:
side = "目标"
mode = (plan.get("oo_close_mode") or "").strip().lower() mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平"): if mode in ("close_all", "全平", "残值平"):
rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)" rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
else: else:
rest_txt = "另一腿到期平(持有至到期结算)" rest_txt = "另一腿到期平(持有至到期结算)"
rr = plan.get("profit_rr")
if rr not in (None, ""):
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
else:
detail = (
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
)
notify_hedge( notify_hedge(
cfg, cfg,
build_hedge_alert_message( build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}", title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"), plan_id=plan.get("id"),
detail=( detail=detail,
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
),
), ),
) )
return True return True
@@ -0,0 +1,527 @@
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
from __future__ import annotations
import math
import os
from typing import Any, Optional
PREMIUM_EXEC_FACTOR = 0.95
DEFAULT_MIN_HOURS = 36.0
DEFAULT_STRIKE_INTERVAL = 15.0
DEFAULT_PERP_LEVERAGE = 100
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
DEFAULT_OPT_LEVERAGE_OTM = 200.0
DEFAULT_RATIO_ITM_ATM = 2.0
DEFAULT_RATIO_OTM = 4.0
OTM_LEV_FLOOR = 180.0
def _sf(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
if not body_or_plan:
return False
v = body_or_plan.get("option_primary")
if v in (True, 1, "1", "true", "yes", "on"):
return True
try:
return int(v or 0) == 1
except (TypeError, ValueError):
return False
def fee_rate() -> float:
try:
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
except (TypeError, ValueError):
return 0.0005
def floor2(v: float) -> float:
"""ETH 数量向下取两位小数."""
if v <= 0:
return 0.0
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
def opt_type_for_view(direction: str) -> str:
"""看法做多→Call,做空→Put."""
return "P" if str(direction or "").strip().lower() == "short" else "C"
def perp_direction_for_view(direction: str) -> str:
"""看法做多→永续空,做空→永续多."""
return "long" if str(direction or "").strip().lower() == "short" else "short"
def default_opt_leverage(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
def default_ratio(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
cfg = _sf(configured)
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
if (moneyness or "").strip().lower() == "otm":
return max(base, OTM_LEV_FLOOR)
return base
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
exp = _sf(exp_ms)
if exp is None or exp <= 0:
return None
# OKX exp 多为毫秒
if exp < 1e12:
exp *= 1000.0
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
return (exp - now) / 3600000.0
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤KN.点数须 >0."""
n = float(points or 0)
k = float(strike)
s = float(index_px)
if n <= 0 or k <= 0 or s <= 0:
return False
side = str(view_side or "").strip().lower()
if side == "short":
return s <= (k - n)
return s >= (k + n)
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
b = _sf(bid)
if b is None or b <= 0:
return False, "暂无买一报价,无法平期权"
sz = _sf(bid_sz)
if sz is not None and sz <= 0:
return False, "买一深度为 0,无法平期权"
need = float(need_sheets or 0)
if need > 0 and sz is not None and sz + 1e-12 < need:
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
return True, ""
def size_from_premium(
*,
premium_budget: float,
ask: float,
ct_mult: float,
ratio: float,
contract_size: float,
exec_factor: float = PREMIUM_EXEC_FACTOR,
) -> dict[str, Any]:
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
budget = float(premium_budget or 0)
a = float(ask or 0)
ct = float(ct_mult or 0.01)
r = float(ratio or 0)
cs = float(contract_size or 0.01)
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
return {
"ok": False,
"msg": "定仓参数无效",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# ask 为每 1 币权利金;ETH 数量 = usable / ask
eth_qty = floor2(usable / a)
if eth_qty <= 0:
return {
"ok": False,
"msg": "权利金不足以买入 0.01 ETH 名义期权",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
sheets = eth_qty / ct
# 张数向下取整到整数张(OKX 期权常见整张)
sheets_i = float(math.floor(sheets + 1e-12))
if sheets_i <= 0:
return {
"ok": False,
"msg": "换算期权张数不足 1 张",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# 用整张回写 ETH,保持与下单一致
eth_qty = round(sheets_i * ct, 2)
perp_eth = eth_qty / r
contracts = perp_eth / cs
premium_est = a * sheets_i * ct
return {
"ok": True,
"msg": "",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": sheets_i,
"perp_eth": round(perp_eth, 6),
"contracts": contracts,
"premium_est": round(premium_est, 4),
"ratio": r,
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
}
def estimate_combo_net_pnl(
*,
view_side: str,
strike: float,
index_px: float,
ask_open: float,
bid: float,
sheets: float,
ct_mult: float,
perp_direction: str,
perp_entry: float,
perp_mark: float,
contracts: float,
contract_size: float,
fee: Optional[float] = None,
) -> dict[str, Any]:
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
fr = fee if fee is not None else fee_rate()
ct = float(ct_mult or 0.01)
sh = float(sheets or 0)
a = float(ask_open or 0)
b = float(bid or 0)
premium = a * sh * ct
opt_proceeds = b * sh * ct
opt_open_fee = premium * fr
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
coins = float(contracts or 0) * float(contract_size or 0.01)
entry = float(perp_entry or 0)
mark = float(perp_mark or 0)
pd = str(perp_direction or "").strip().lower()
if pd == "short":
perp_gross = (entry - mark) * coins
else:
perp_gross = (mark - entry) * coins
perp_notional_open = abs(entry * coins)
perp_notional_close = abs(mark * coins)
perp_open_fee = perp_notional_open * fr
perp_close_fee = perp_notional_close * fr
perp_net = perp_gross - perp_open_fee - perp_close_fee
total = opt_net + perp_net
return {
"opt_net": round(opt_net, 4),
"perp_net": round(perp_net, 4),
"net": round(total, 4),
"fee_rate": fr,
"premium": round(premium, 4),
"opt_proceeds": round(opt_proceeds, 4),
}
def validate_option_primary_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
ask: Any = None,
moneyness: str = "atm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
hours_to_expiry: Any = None,
min_opt_leverage: Any = None,
) -> Optional[str]:
from lib.hedge_plan.hedge_plan_moneyness_lib import (
classify_moneyness,
is_atm_or_otm,
is_itm_or_atm,
normalize_opt_type,
)
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return "期权类型无效"
if k is None or s is None or s <= 0:
return "行权价或指数无效"
m_want = (moneyness or "atm").strip().lower()
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
if m_want == "itm":
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
return "所选须为实值或平值"
elif m_want == "atm":
# 平值:距指数在间隔内即可(不强制 classify==atm)
pass
elif m_want == "otm":
if m_got == "itm":
return "虚值模式不可选实值"
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
return "虚值模式须选虚值或平值档"
else:
return "期权类型(实/平/虚)无效"
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
if interval > 0 and abs(k - s) > interval + 1e-9:
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
h = _sf(hours_to_expiry)
if min_h > 0 and h is not None and h < min_h:
return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h"
a = _sf(ask)
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
if min_lev > 0 and a is not None and a > 0:
lev = s / a
if lev < min_lev:
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
return None
def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
"""盯盘启动校验:只要参数,不要求已选具体合约."""
need = (
"direction",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
"option_leverage",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
if float(body["option_leverage"]) <= 0:
return "期权杠杆须大于 0"
lev_perp = _sf(body.get("leverage"))
if lev_perp is not None and lev_perp <= 0:
return "永续杠杆须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
if moneyness not in ("itm", "atm", "otm"):
return "期权类型(实/平/虚)无效"
return None
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
need = (
"direction",
"contracts",
"opt_inst_id",
"sheets",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
return "张数必须大于 0"
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
opt_type = str(body.get("opt_type") or "").strip().upper()
if not opt_type:
inst = str(body.get("opt_inst_id") or "")
if inst.upper().endswith("-P"):
opt_type = "P"
elif inst.upper().endswith("-C"):
opt_type = "C"
want = opt_type_for_view(direction)
if opt_type != want:
return f"以期权为主时做{'' if direction == 'long' else ''}须用 {'Call' if want == 'C' else 'Put'}"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
strike = body.get("strike")
if strike in (None, ""):
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
index_px = body.get("index_px") or body.get("entry")
return validate_option_primary_moneyness(
opt_type=opt_type,
strike=strike,
index_px=index_px,
ask=body.get("ask"),
moneyness=moneyness,
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
hours_to_expiry=body.get("hours_to_expiry"),
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
)
def pick_option_primary_candidate(
chain: dict[str, Any],
*,
direction: str,
moneyness: str = "otm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
min_opt_leverage: Any = None,
) -> Optional[dict[str, Any]]:
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
want = opt_type_for_view(direction)
m_want = (moneyness or "otm").strip().lower()
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
try:
idx = float(chain.get("index_px") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
return None
best: Optional[dict[str, Any]] = None
best_dist: Optional[float] = None
for exp in chain.get("expiries") or []:
h = hours_to_expiry_from_ms(exp.get("exp_time"))
if min_h > 0 and h is not None and h < min_h:
continue
for c in exp.get("contracts") or []:
if str(c.get("opt_type") or "").upper() != want:
continue
try:
k = float(c.get("strike") or 0)
ask = float(c.get("ask") or 0)
except (TypeError, ValueError):
continue
if k <= 0 or ask <= 0:
continue
if interval > 0 and abs(k - idx) > interval + 1e-9:
continue
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
if m_want == "itm" and m_got not in ("itm", "atm"):
continue
if m_want == "atm" and m_got != "atm":
continue
if m_want == "otm" and m_got == "itm":
continue
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
if min_lev > 0 and idx / ask < min_lev - 1e-9:
continue
dist = abs(k - idx)
if best is None or best_dist is None or dist < best_dist:
best = {
**dict(c),
"hours_to_expiry": h,
"exp_time": exp.get("exp_time"),
"moneyness": m_got,
"index_px": idx,
"leverage": round(idx / ask, 1),
}
best_dist = dist
return best
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
"""情景:期权目标 / 永续目标粗估净利."""
view = str(body.get("direction") or "long").lower()
strike = float(body["strike"])
n = float(body.get("option_target_points") or 0)
m = float(body.get("perp_target_points") or 0)
ask = float(body.get("ask") or 0)
sheets = float(body.get("sheets") or 0)
ct = float(body.get("ct_mult") or 0.01)
contracts = float(body.get("contracts") or 0)
cs = float(body.get("contract_size") or 0.01)
entry = float(body.get("entry") or body.get("index_px") or 0)
perp_dir = perp_direction_for_view(view)
# 粗估到点时期权卖价:按内在价值近似(下限 0)
def intrinsic(spot: float) -> float:
o = opt_type_for_view(view)
if o == "C":
return max(0.0, spot - strike)
return max(0.0, strike - spot)
scenarios = []
for label, pts, reason in (
("期权目标", n, "opt_target_points"),
("永续目标", m, "perp_target_points"),
):
spot = strike + pts if view != "short" else strike - pts
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
net = estimate_combo_net_pnl(
view_side=view,
strike=strike,
index_px=spot,
ask_open=ask,
bid=bid_est,
sheets=sheets,
ct_mult=ct,
perp_direction=perp_dir,
perp_entry=entry,
perp_mark=spot,
contracts=contracts,
contract_size=cs,
)
scenarios.append(
{
"label": label,
"reason": reason,
"index": spot,
"perp_pnl": net["perp_net"],
"options_pnl": net["opt_net"],
"total": net["net"],
"note": "扣费净利估价;平仓费按买入费率",
}
)
premium = ask * sheets * ct
return {
"plan_type": "perp_options",
"option_primary": True,
"summary": {
"premium_paid": round(premium, 4),
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
"opt_target_total": scenarios[0]["total"] if scenarios else None,
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
"perp_direction": perp_dir,
"opt_type": opt_type_for_view(view),
},
"scenarios": scenarios,
}
+398 -53
View File
@@ -37,7 +37,15 @@ def partial_auto_close_enabled() -> bool:
def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]: def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"""永期下单路径清单(不交易).""" """永期下单路径清单(不交易)."""
mode = open_order_mode() from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
perp_direction_for_view,
)
opt_primary = is_option_primary(body)
mode = "options_first" if opt_primary else open_order_mode()
view = str(body.get("direction") or "long")
perp_dir = perp_direction_for_view(view) if opt_primary else view
opt = { opt = {
"step": "options_buy_limit", "step": "options_buy_limit",
"account": "options", "account": "options",
@@ -50,11 +58,13 @@ def build_po_path_plan(body: dict[str, Any]) -> list[dict[str, Any]]:
"step": "perp_market_open", "step": "perp_market_open",
"account": "swap", "account": "swap",
"symbol": body.get("exchange_symbol"), "symbol": body.get("exchange_symbol"),
"direction": body.get("direction") or "long", "direction": perp_dir,
"contracts": float(body.get("contracts") or 0), "contracts": float(body.get("contracts") or 0),
"tp": body.get("tp"), "tp": None if opt_primary else body.get("tp"),
"sl": body.get("sl"), "sl": None if opt_primary else body.get("sl"),
"attach_tpsl": True, "attach_tpsl": False if opt_primary else True,
"option_primary": opt_primary,
"view_side": view,
} }
return [opt, perp] if mode == "options_first" else [perp, opt] return [opt, perp] if mode == "options_first" else [perp, opt]
@@ -125,11 +135,30 @@ def _buy_option(
"ref_ask": q.get("ref_ask"), "ref_ask": q.get("ref_ask"),
"can_open": False, "can_open": False,
} }
from lib.options.options_position_limit_lib import option_position_limit_block_msg
pos_limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id=inst_id,
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {"ok": False, "msg": pos_limit_msg, "quote": q, "can_open": False}
sheets_i = max(1, int(round(float(sheets)))) sheets_i = max(1, int(round(float(sheets))))
requested_sheets = sheets_i
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1) capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets_i, ask_sz, min_sz=1)
if capped is None: if capped is None:
return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q} return {"ok": False, "msg": cap_msg or "卖一深度不足,无法买入", "quote": q}
sheets_i = capped if int(capped) < requested_sheets:
return {
"ok": False,
"msg": f"卖一深度仅 {int(capped)} 张,不足请求 {requested_sheets} 张,拒绝缩量成交",
"quote": q,
"can_open": False,
"requested_sheets": requested_sheets,
"ask_sz": ask_sz,
}
sheets_i = int(capped)
ct_mult = float(q.get("ct_mult") or 0.01) ct_mult = float(q.get("ct_mult") or 0.01)
premium = float(ask) * sheets_i * ct_mult premium = float(ask) * sheets_i * ct_mult
if dry_run: if dry_run:
@@ -179,6 +208,14 @@ def _buy_option(
cancel_on_timeout=True, cancel_on_timeout=True,
) )
if not fill.get("ok"): if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
orphan_close = None
if filled_n > 0 and not dry_run:
# 部分成交后撤单:尝试立刻平掉已成交,避免孤儿多头
try:
orphan_close = _sell_option(cfg, inst_id=inst_id, sheets=float(filled_n))
except Exception as e:
orphan_close = {"ok": False, "msg": str(e)}
return { return {
"ok": False, "ok": False,
"msg": fill.get("msg") or "未完全成交,开仓失败", "msg": fill.get("msg") or "未完全成交,开仓失败",
@@ -186,7 +223,8 @@ def _buy_option(
"sheets": sheets_i, "sheets": sheets_i,
"ask": float(ask), "ask": float(ask),
"exchange_ord_id": ord_id, "exchange_ord_id": ord_id,
"filled_sheets": fill.get("filled_sheets"), "filled_sheets": filled_n,
"orphan_close": orphan_close,
"order": order, "order": order,
"fill": fill, "fill": fill,
"can_open": False, "can_open": False,
@@ -221,9 +259,10 @@ def _open_perp(
direction: str, direction: str,
contracts: float, contracts: float,
leverage: int, leverage: int,
tp: float, tp: Optional[float],
sl: float, sl: Optional[float],
dry_run: bool, dry_run: bool,
attach_tpsl: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not symbol or contracts <= 0: if not symbol or contracts <= 0:
return {"ok": False, "msg": "永续符号或张数无效"} return {"ok": False, "msg": "永续符号或张数无效"}
@@ -237,6 +276,9 @@ def _open_perp(
pass pass
if amount <= 0: if amount <= 0:
return {"ok": False, "msg": "张数经精度舍入后为 0"} return {"ok": False, "msg": "张数经精度舍入后为 0"}
use_tpsl = bool(attach_tpsl) and tp is not None and sl is not None
tp_v = float(tp) if use_tpsl else None
sl_v = float(sl) if use_tpsl else None
if dry_run: if dry_run:
return { return {
"ok": True, "ok": True,
@@ -245,8 +287,9 @@ def _open_perp(
"direction": direction, "direction": direction,
"contracts": amount, "contracts": amount,
"leverage": leverage, "leverage": leverage,
"tp": tp, "tp": tp_v,
"sl": sl, "sl": sl_v,
"attach_tpsl": use_tpsl,
} }
ensure = cfg.get("ensure_okx_live_ready") ensure = cfg.get("ensure_okx_live_ready")
if callable(ensure): if callable(ensure):
@@ -257,7 +300,14 @@ def _open_perp(
if not callable(place): if not callable(place):
return {"ok": False, "msg": "永续下单函数未注入"} return {"ok": False, "msg": "永续下单函数未注入"}
try: try:
order = place(symbol, direction, amount, leverage, stop_loss=sl, take_profit=tp) order = place(
symbol,
direction,
amount,
leverage,
stop_loss=sl_v,
take_profit=tp_v,
)
except Exception as e: except Exception as e:
return {"ok": False, "msg": f"永续开仓失败: {e}"} return {"ok": False, "msg": f"永续开仓失败: {e}"}
return { return {
@@ -266,13 +316,60 @@ def _open_perp(
"direction": direction, "direction": direction,
"contracts": amount, "contracts": amount,
"leverage": leverage, "leverage": leverage,
"tp": tp, "tp": tp_v,
"sl": sl, "sl": sl_v,
"attach_tpsl": use_tpsl,
"order": order, "order": order,
"exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""), "exchange_ord_id": str((order or {}).get("id") or (order or {}).get("info", {}).get("ordId") or ""),
} }
def _close_perp(
cfg: dict[str, Any],
*,
symbol: str,
direction: str,
contracts: float,
dry_run: bool = False,
) -> dict[str, Any]:
"""市价平永续(reduce-only);优先用注入的 close_exchange_order."""
if not symbol:
return {"ok": False, "msg": "永续符号无效"}
if dry_run:
return {
"ok": True,
"dry_run": True,
"symbol": symbol,
"direction": direction,
"contracts": float(contracts or 0),
}
close_fn = cfg.get("close_exchange_order")
if callable(close_fn):
try:
order = close_fn(
{
"exchange_symbol": symbol,
"direction": direction,
"order_amount": float(contracts or 0),
"symbol": symbol,
}
)
return {"ok": True, "symbol": symbol, "direction": direction, "order": order}
except Exception as e:
return {"ok": False, "msg": f"永续平仓失败: {e}"}
# 回退:对向市价 reduce-only(若注入了 place + 支持)
place = cfg.get("place_exchange_order")
if not callable(place):
return {"ok": False, "msg": "永续平仓函数未注入"}
try:
# 无 TP/SL 的对向单;依赖交易所 reduceOnly 由 place 实现不保证,优先 close_exchange_order
side_dir = "short" if str(direction).lower() == "long" else "long"
order = place(symbol, side_dir, float(contracts or 0), int(cfg.get("alt_leverage") or 5), None, None)
return {"ok": True, "symbol": symbol, "direction": direction, "order": order, "note": "fallback_place"}
except Exception as e:
return {"ok": False, "msg": f"永续平仓失败: {e}"}
def _sell_option( def _sell_option(
cfg: dict[str, Any], cfg: dict[str, Any],
*, *,
@@ -280,9 +377,17 @@ def _sell_option(
sheets: float, sheets: float,
dry_run: bool = False, dry_run: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""平期权:走买一限价 + 验仓;仅 fully_closed/already_flat 视为成功.
对冲强平/目标平仓不启用 2× 回收门控(require_recycle_gate=False).
"""
from lib.exchange.okx_options_lib import fetch_option_book_depth, fetch_option_positions
from lib.options.options_close_exec_lib import close_option_by_bid1
ex = cfg.get("exchange_options") ex = cfg.get("exchange_options")
quote_fn = cfg.get("quote_option_contract") quote_fn = cfg.get("quote_option_contract")
place_fn = cfg.get("place_option_limit_order") if not inst_id:
return {"ok": False, "msg": "缺少期权合约"}
if not callable(quote_fn) or ex is None: if not callable(quote_fn) or ex is None:
return {"ok": False, "msg": "期权报价能力未就绪"} return {"ok": False, "msg": "期权报价能力未就绪"}
q = quote_fn(ex, inst_id) q = quote_fn(ex, inst_id)
@@ -291,20 +396,77 @@ def _sell_option(
return {"ok": False, "msg": "暂无买一价,无法平期权"} return {"ok": False, "msg": "暂无买一价,无法平期权"}
sheets_i = max(1, int(round(float(sheets)))) sheets_i = max(1, int(round(float(sheets))))
if dry_run: if dry_run:
return {"ok": True, "dry_run": True, "inst_id": inst_id, "sheets": sheets_i, "bid": float(bid)} return {
if not callable(place_fn): "ok": True,
"dry_run": True,
"inst_id": inst_id,
"sheets": sheets_i,
"bid": float(bid),
"fully_closed": True,
}
if not callable(cfg.get("place_option_limit_order")):
return {"ok": False, "msg": "期权平仓未注入"} return {"ok": False, "msg": "期权平仓未注入"}
order = place_fn( close_cfg = dict(cfg)
if not callable(close_cfg.get("fetch_option_positions")):
close_cfg["fetch_option_positions"] = fetch_option_positions
if not callable(close_cfg.get("fetch_option_book_depth")):
close_cfg["fetch_option_book_depth"] = fetch_option_book_depth
if "td_mode" not in close_cfg:
close_cfg["td_mode"] = close_cfg.get("options_td_mode") or "isolated"
result = close_option_by_bid1(
close_cfg,
ex, ex,
inst_id=inst_id, inst_id,
side="sell",
sheets=sheets_i, sheets=sheets_i,
price=float(bid), require_recycle_gate=False,
td_mode="isolated",
tick_sz=q.get("tick_sz"),
reduce_only=True,
) )
return order if order.get("ok") else order out = dict(result or {})
if out.get("already_flat"):
# 二次验仓,避免一次空列表误判已平
import time as _time
_time.sleep(0.35)
try:
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
invalidate_option_positions_cache()
except Exception:
pass
rows2 = close_cfg["fetch_option_positions"](ex)
if rows2 is None:
return {"ok": False, "msg": "二次验仓失败,未确认是否已平", "fully_closed": False}
still = next((p for p in rows2 if str(p.get("instId")) == inst_id), None)
still_sz = 0.0
if still is not None:
try:
still_sz = abs(float(still.get("availPos") or still.get("pos") or 0))
except (TypeError, ValueError):
still_sz = 0.0
if still is not None and still_sz >= 1:
return {
"ok": False,
"msg": "二次验仓仍有持仓,拒绝 already_flat",
"fully_closed": False,
}
out["ok"] = True
out["fully_closed"] = True
out.setdefault("bid", float(bid))
return out
if not out.get("ok"):
out.setdefault("bid", float(bid))
return out
if not out.get("fully_closed"):
return {
"ok": False,
"msg": out.get("msg") or "期权尚未完全平仓,将下轮重试",
"bid": out.get("locked_bid_px") or float(bid),
"fully_closed": False,
"partial": True,
"close": out,
}
out["bid"] = out.get("locked_bid_px") or float(bid)
out["fully_closed"] = True
return out
def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None: def _notify_partial(cfg: dict[str, Any], plan_type: str, msg: str, results: list[dict[str, Any]]) -> None:
@@ -511,8 +673,9 @@ def refresh_oo_sizing_before_start(cfg: dict[str, Any], body: dict[str, Any]) ->
def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
"""永期启动前再拉保险腿卖一(张数沿用页面值,不按预算重算).""" """永期启动前再拉卖一;保险模式张数沿用页面;期权为主时按权利金×0.95重算定仓."""
from lib.exchange.okx_options_lib import option_buy_liquidity_ok from lib.exchange.okx_options_lib import option_buy_liquidity_ok
from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary, size_from_premium
inst = str(body.get("opt_inst_id") or "").strip() inst = str(body.get("opt_inst_id") or "").strip()
if not inst: if not inst:
@@ -535,6 +698,51 @@ def refresh_po_option_quote_before_start(cfg: dict[str, Any], body: dict[str, An
body["ask_sz"] = q.get("ask_sz") body["ask_sz"] = q.get("ask_sz")
if q.get("ct_mult") is not None: if q.get("ct_mult") is not None:
body["ct_mult"] = float(q.get("ct_mult") or 0.01) body["ct_mult"] = float(q.get("ct_mult") or 0.01)
if is_option_primary(body):
cs = float(body.get("contract_size") or 0.01)
get_cs = cfg.get("get_contract_size")
sym = str(body.get("exchange_symbol") or "")
if callable(get_cs) and sym:
try:
cs = float(get_cs(sym) or cs)
except Exception:
pass
sized = size_from_premium(
premium_budget=float(body.get("premium_budget") or 0),
ask=float(body["ask"]),
ct_mult=float(body.get("ct_mult") or 0.01),
ratio=float(body.get("option_perp_ratio") or 2),
contract_size=cs,
)
if not sized.get("ok"):
return {"ok": False, "msg": sized.get("msg") or "定仓失败", "quote": q, "sizing": sized}
body["sheets"] = sized["sheets"]
body["contracts"] = sized["contracts"]
body["eth_qty"] = sized["eth_qty"]
body["contract_size"] = cs
# 深度不足则缩量
ask_sz = float(q.get("ask_sz") or 0)
if ask_sz > 0 and float(body["sheets"]) > ask_sz:
body["sheets"] = float(int(ask_sz))
if body["sheets"] <= 0:
return {"ok": False, "msg": "卖一深度不足 1 张", "quote": q, "sizing": sized}
eth = round(float(body["sheets"]) * float(body.get("ct_mult") or 0.01), 2)
body["eth_qty"] = eth
body["contracts"] = (eth / float(body.get("option_perp_ratio") or 2)) / cs
return {
"ok": True,
"ask": float(q["ask"]),
"ask_sz": q.get("ask_sz"),
"sheets": body.get("sheets"),
"contracts": body.get("contracts"),
"eth_qty": body.get("eth_qty"),
"sizing": sized,
"quote": q,
"msg": (
f"期权为主定仓: 权利金×0.95→{body.get('eth_qty')}ETH / "
f"{body.get('sheets')}张期权 / {float(body.get('contracts') or 0):.4f}张永续 @{q['ask']}"
),
}
return { return {
"ok": True, "ok": True,
"ask": float(q["ask"]), "ask": float(q["ask"]),
@@ -592,15 +800,32 @@ def execute_perp_options_start(
) )
return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results} return {"ok": False, "msg": opt_res.get("msg") or "期权开仓失败", "path": path, "results": results}
else: else:
from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
perp_direction_for_view,
)
opt_primary = is_option_primary(body)
view = str(body.get("direction") or "long")
perp_dir = str(step.get("direction") or (
perp_direction_for_view(view) if opt_primary else view
))
attach = bool(step.get("attach_tpsl", not opt_primary))
tp_v = None if not attach else body.get("tp")
sl_v = None if not attach else body.get("sl")
if attach:
tp_v = float(body["tp"])
sl_v = float(body["sl"])
perp_res = _open_perp( perp_res = _open_perp(
cfg, cfg,
symbol=str(body.get("exchange_symbol") or ""), symbol=str(body.get("exchange_symbol") or ""),
direction=str(body.get("direction") or "long"), direction=perp_dir,
contracts=float(body.get("contracts") or 0), contracts=float(body.get("contracts") or 0),
leverage=int(body.get("leverage") or 10), leverage=int(body.get("leverage") or (100 if opt_primary else 10)),
tp=float(body["tp"]), tp=tp_v,
sl=float(body["sl"]), sl=sl_v,
dry_run=dry_run, dry_run=dry_run,
attach_tpsl=attach,
) )
results.append({"step": step["step"], **perp_res}) results.append({"step": step["step"], **perp_res})
if not perp_res.get("ok"): if not perp_res.get("ok"):
@@ -681,7 +906,24 @@ def execute_options_options_start(
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
leg_a = body.get("leg_a") or {} leg_a = body.get("leg_a") or {}
leg_b = body.get("leg_b") or {} leg_b = body.get("leg_b") or {}
a_res = _buy_option(cfg, inst_id=str(leg_a.get("inst_id") or ""), sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run) inst_a = str(leg_a.get("inst_id") or "")
inst_b = str(leg_b.get("inst_id") or "")
from lib.options.options_position_limit_lib import option_position_limit_block_msg
pos_limit_msg = option_position_limit_block_msg(
cfg.get("exchange_options"),
opening_inst_ids=[inst_a, inst_b],
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {
"ok": False,
"msg": pos_limit_msg,
"path": path,
"results": [],
"refresh": refresh,
}
a_res = _buy_option(cfg, inst_id=inst_a, sheets=float(leg_a.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "a", **a_res}) results.append({"step": "options_buy_limit", "leg": "a", **a_res})
if not a_res.get("ok"): if not a_res.get("ok"):
return { return {
@@ -691,7 +933,7 @@ def execute_options_options_start(
"results": results, "results": results,
"refresh": refresh, "refresh": refresh,
} }
b_res = _buy_option(cfg, inst_id=str(leg_b.get("inst_id") or ""), sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run) b_res = _buy_option(cfg, inst_id=inst_b, sheets=float(leg_b.get("sheets") or 1), dry_run=dry_run)
results.append({"step": "options_buy_limit", "leg": "b", **b_res}) results.append({"step": "options_buy_limit", "leg": "b", **b_res})
if not b_res.get("ok"): if not b_res.get("ok"):
if not dry_run and partial_auto_close_enabled(): if not dry_run and partial_auto_close_enabled():
@@ -769,15 +1011,25 @@ def execute_complete_missing_leg(
role = str(missing.get("leg_role") or "") role = str(missing.get("leg_role") or "")
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
if role == "perp": if role == "perp":
from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
perp_direction_for_view,
)
opt_primary = is_option_primary(start_body)
view = str(start_body.get("direction") or "long")
perp_dir = perp_direction_for_view(view) if opt_primary else view
attach = not opt_primary
res = _open_perp( res = _open_perp(
cfg, cfg,
symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""), symbol=str(start_body.get("exchange_symbol") or missing.get("symbol") or ""),
direction=str(start_body.get("direction") or "long"), direction=perp_dir,
contracts=float(start_body.get("contracts") or missing.get("size") or 0), contracts=float(start_body.get("contracts") or missing.get("size") or 0),
leverage=int(start_body.get("leverage") or 10), leverage=int(start_body.get("leverage") or (100 if opt_primary else 10)),
tp=float(start_body["tp"]), tp=None if not attach else float(start_body["tp"]),
sl=float(start_body["sl"]), sl=None if not attach else float(start_body["sl"]),
dry_run=dry_run, dry_run=dry_run,
attach_tpsl=attach,
) )
results.append({"step": "perp_market_open", "complete": True, **res}) results.append({"step": "perp_market_open", "complete": True, **res})
if not res.get("ok"): if not res.get("ok"):
@@ -820,6 +1072,19 @@ def execute_complete_missing_leg(
def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
pt = (plan_type or "").strip().lower() pt = (plan_type or "").strip().lower()
if pt == "perp_options": if pt == "perp_options":
from lib.hedge_plan.hedge_plan_option_primary_lib import (
is_option_primary,
validate_option_primary_start,
)
if is_option_primary(body):
from lib.hedge_plan.hedge_plan_option_primary_lib import validate_option_primary_watch
# 以期权为主默认盯盘启动(非现场开仓);显式 watch_entry=0 才走即开校验
watch = body.get("watch_entry")
if watch in (None, "", True, 1, "1", "true", "yes", "on"):
return validate_option_primary_watch(body)
return validate_option_primary_start(body)
need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol") need = ("direction", "entry", "tp", "sl", "contracts", "opt_inst_id", "sheets", "exchange_symbol")
for k in need: for k in need:
if body.get(k) in (None, ""): if body.get(k) in (None, ""):
@@ -827,30 +1092,110 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
try: try:
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0: if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
return "张数必须大于 0" return "张数必须大于 0"
if float(body["tp"]) <= 0 or float(body["sl"]) <= 0: entry = float(body["entry"])
return "止盈/止损无效" tp = float(body["tp"])
sl = float(body["sl"])
if tp <= 0 or sl <= 0 or entry <= 0:
return "止盈/止损/入场无效"
except (TypeError, ValueError): except (TypeError, ValueError):
return "数值字段无效" return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
opt_type = str(body.get("opt_type") or "").strip().upper()
if not opt_type:
# 允许从合约名推断 ETH-USD-...-P / -C
inst = str(body.get("opt_inst_id") or "")
if inst.upper().endswith("-P"):
opt_type = "P"
elif inst.upper().endswith("-C"):
opt_type = "C"
if opt_type not in ("P", "C"):
return "缺少期权类型(Put/Call)"
if direction == "long" and opt_type != "P":
return "做多永期对冲须用 Put"
if direction == "short" and opt_type != "C":
return "做空永期对冲须用 Call"
if direction == "long" and not (sl < entry < tp):
return "做多须满足 止损 < 入场 < 止盈"
if direction == "short" and not (tp < entry < sl):
return "做空须满足 止盈 < 入场 < 止损"
from lib.hedge_plan.hedge_plan_moneyness_lib import (
parse_strike_from_inst,
validate_po_option_moneyness,
)
strike = body.get("strike")
if strike in (None, ""):
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
index_px = body.get("index_px")
if index_px in (None, ""):
index_px = entry
money_err = validate_po_option_moneyness(
opt_type=opt_type,
strike=strike,
index_px=index_px,
ask=body.get("ask"),
hours_to_expiry=body.get("hours_to_expiry"),
)
if money_err:
return money_err
return None return None
if pt == "options_options": if pt == "options_options":
a = body.get("leg_a") or {} a = body.get("leg_a") or {}
b = body.get("leg_b") or {} b = body.get("leg_b") or {}
if not a.get("inst_id") or not b.get("inst_id"): if not a.get("inst_id") or not b.get("inst_id"):
return "请选用两条期权腿" return "请选用两条期权腿"
up = body.get("target_price_up") rr_raw = body.get("profit_rr")
down = body.get("target_price_down") if rr_raw not in (None, ""):
legacy = body.get("target_price") try:
if up in (None, "") and legacy not in (None, ""): rr = float(rr_raw)
up = legacy except (TypeError, ValueError):
if down in (None, "") and legacy not in (None, ""): return "盈亏比无效"
down = legacy if rr <= 0:
if up in (None, "") or down in (None, ""): return "盈亏比须大于0"
return "请填写上破与下破目标价" else:
try: # 兼容旧上/下破
if float(up) <= float(down): up = body.get("target_price_up")
return "上破目标价必须大于下破目标价" down = body.get("target_price_down")
except (TypeError, ValueError): legacy = body.get("target_price")
return "目标价无效" if up in (None, "") and legacy not in (None, ""):
up = legacy
if down in (None, "") and legacy not in (None, ""):
down = legacy
if up in (None, "") or down in (None, ""):
return "请填写盈亏比"
try:
if float(up) <= float(down):
return "上破目标价必须大于下破目标价"
except (TypeError, ValueError):
return "目标价无效"
from lib.hedge_plan.hedge_plan_moneyness_lib import (
parse_strike_from_inst,
validate_oo_legs_moneyness,
)
def _leg_for_money(leg: dict) -> dict:
strike = leg.get("strike")
if strike in (None, ""):
strike = parse_strike_from_inst(str(leg.get("inst_id") or ""))
opt_type = leg.get("opt_type")
if not opt_type:
inst = str(leg.get("inst_id") or "").upper()
if inst.endswith("-C"):
opt_type = "C"
elif inst.endswith("-P"):
opt_type = "P"
return {"opt_type": opt_type, "strike": strike}
index_px = body.get("index_px")
money_err = validate_oo_legs_moneyness(
_leg_for_money(a),
_leg_for_money(b),
index_px=index_px,
)
if money_err:
return money_err
return None return None
return "未知计划类型" return "未知计划类型"
@@ -955,7 +1300,7 @@ def execute_manual_end_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> dic
if not plan: if not plan:
return {"ok": False, "msg": "计划不存在"} return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "") st = str(plan.get("status") or "")
if st not in ("opening", "active", "partial"): if st not in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": f"当前状态 {st or ''} 不可结束"} return {"ok": False, "msg": f"当前状态 {st or ''} 不可结束"}
notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id)) notes = reconcile_unfilled_option_legs(cfg, conn, int(plan_id))
+485 -104
View File
@@ -79,6 +79,7 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None), "ensure_markets_loaded": getattr(app_module, "ensure_markets_loaded", None),
"ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None), "ensure_okx_live_ready": getattr(app_module, "ensure_okx_live_ready", None),
"place_exchange_order": getattr(app_module, "place_exchange_order", None), "place_exchange_order": getattr(app_module, "place_exchange_order", None),
"close_exchange_order": getattr(app_module, "close_exchange_order", None),
"get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None), "get_live_position_contracts": getattr(app_module, "get_live_position_contracts", None),
"amount_to_precision": _amount_to_precision, "amount_to_precision": _amount_to_precision,
"build_option_chain": build_option_chain, "build_option_chain": build_option_chain,
@@ -108,15 +109,21 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
def _hedge_enabled() -> bool: def _hedge_enabled() -> bool:
return _env_bool("HEDGE_PLAN_ENABLED", False) from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled
return hedge_module_enabled()
def _show_perp_options() -> bool: def _show_perp_options() -> bool:
return _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True) from lib.hedge_plan.okx_trade_mode_lib import show_perp_options
return show_perp_options()
def _show_options_options() -> bool: def _show_options_options() -> bool:
return _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True) from lib.hedge_plan.okx_trade_mode_lib import show_options_options
return show_options_options()
def _oo_close_mode_enabled() -> bool: def _oo_close_mode_enabled() -> bool:
@@ -180,13 +187,14 @@ def _gates_dict(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
raw = fetch_option_positions(ex) if ex is not None else [] raw = fetch_option_positions(ex) if ex is not None else []
has_standalone = has_standalone_option_position(conn, raw or []) has_standalone = has_standalone_option_position(conn, raw or [])
except Exception: except Exception:
has_standalone = False has_standalone = True # fail-closed
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
except Exception: except Exception:
active = 0 # fail-closed:探测失败视为不可开仓
has_standalone = False active = 10**9
has_standalone = True
return gate_status( return gate_status(
hedge_enabled=_hedge_enabled(), hedge_enabled=_hedge_enabled(),
sizing_mode=load_position_sizing_mode(), sizing_mode=load_position_sizing_mode(),
@@ -214,31 +222,45 @@ def _gates_public(cfg: dict[str, Any], plan_type: str) -> dict[str, Any]:
def _maybe_start_monitor(cfg: dict[str, Any]) -> None: def _maybe_start_monitor(cfg: dict[str, Any]) -> None:
if not _hedge_enabled(): # 始终启动监控线程:单独期权模式下仍需收口遗留 active/partial 计划
return with _hedge_start_lock():
try: if cfg.get("hedge_monitor_thread") is not None:
secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15") return
except ValueError: try:
secs = 15.0 secs = float(os.getenv("HEDGE_PLAN_MONITOR_POLL_SECONDS") or "15")
secs = max(5.0, secs) except ValueError:
secs = 15.0
secs = max(5.0, secs)
def _loop() -> None: def _loop() -> None:
import time import time
from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans from lib.hedge_plan.hedge_plan_monitor_lib import tick_active_plans
while True: while True:
try: try:
tick_active_plans(cfg) tick_active_plans(cfg)
except Exception: except Exception:
pass pass
time.sleep(secs) time.sleep(secs)
import threading import threading
t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True) t = threading.Thread(target=_loop, name="hedge-plan-monitor", daemon=True)
t.start() t.start()
cfg["hedge_monitor_thread"] = t cfg["hedge_monitor_thread"] = t
_start_lock = None
def _hedge_start_lock():
global _start_lock
if _start_lock is None:
import threading
_start_lock = threading.Lock()
return _start_lock
def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str: def _start_body_json(body: dict[str, Any], missing_leg: Optional[str] = None) -> str:
@@ -277,34 +299,57 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
opt_ok = True opt_ok = True
perp_ok = True perp_ok = True
premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0 premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0
plan_id = insert_plan( from lib.hedge_plan.hedge_plan_option_primary_lib import (
conn, is_option_primary,
{ perp_direction_for_view,
"plan_type": "perp_options",
"status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(),
"direction": str(body.get("direction") or "long"),
"entry_mark": float(body.get("entry") or 0),
"tp": float(body.get("tp") or 0),
"sl": float(body.get("sl") or 0),
"sizing_mode_at_open": load_position_sizing_mode(),
"perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"margin": body.get("margin"),
"leverage": float(body.get("leverage") or 10),
"premium_total": premium,
"preview_json": _start_body_json(body, missing or None),
"close_reason": "partial_fail" if is_partial else None,
"opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
},
) )
opt_primary = is_option_primary(body)
view = str(body.get("direction") or "long")
perp_dir = (
str((perp or {}).get("direction") or "")
or (perp_direction_for_view(view) if opt_primary else view)
)
plan_row = {
"plan_type": "perp_options",
"status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(),
"direction": view,
"entry_mark": float(body.get("entry") or 0),
"tp": float(body.get("tp") or 0) if not opt_primary else 0,
"sl": float(body.get("sl") or 0) if not opt_primary else 0,
"sizing_mode_at_open": load_position_sizing_mode(),
"perp_size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"margin": body.get("margin"),
"leverage": float(body.get("leverage") or (100 if opt_primary else 10)),
"premium_total": premium,
"preview_json": _start_body_json(body, missing or None),
"close_reason": "partial_fail" if is_partial else None,
"opened_at": result.get("opened_at"),
"note": (result.get("msg") or "")[:500] if is_partial else None,
"option_primary": 1 if opt_primary else 0,
"perp_direction": perp_dir,
}
if opt_primary:
plan_row.update(
{
"option_target_points": float(body.get("option_target_points") or 0),
"perp_target_points": float(body.get("perp_target_points") or 0),
"option_perp_ratio": float(body.get("option_perp_ratio") or 0),
"premium_budget": float(body.get("premium_budget") or 0),
"strike_interval": float(body.get("strike_interval") or 15),
"min_option_hours": float(body.get("min_option_hours") or 36),
"option_moneyness": str(body.get("moneyness") or body.get("option_moneyness") or ""),
}
)
plan_id = insert_plan(conn, plan_row)
insert_leg( insert_leg(
conn, conn,
{ {
"plan_id": plan_id, "plan_id": plan_id,
"leg_role": "perp", "leg_role": "perp",
"symbol": str(body.get("exchange_symbol") or ""), "symbol": str(body.get("exchange_symbol") or ""),
"side": str(body.get("direction") or "long"), "side": perp_dir,
"size": float((perp or {}).get("contracts") or body.get("contracts") or 0), "size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0) if perp_ok else None, "avg_open": float(body.get("entry") or 0) if perp_ok else None,
"status": "open" if perp_ok else "pending", "status": "open" if perp_ok else "pending",
@@ -322,8 +367,9 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
"strike": (opt or {}).get("strike") or body.get("strike"), "strike": (opt or {}).get("strike") or body.get("strike"),
"side": "buy", "side": "buy",
"size": float((opt or {}).get("sheets") or body.get("sheets") or 1), "size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
"avg_open": float((opt or {}).get("ask") or 0) if opt_ok else None, "avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None,
"premium": premium if opt_ok else 0, "premium": premium if opt_ok else 0,
"ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01),
"status": "open" if opt_ok else "pending", "status": "open" if opt_ok else "pending",
"exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""), "exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if opt_ok else None, "opened_at": result.get("opened_at") if opt_ok else None,
@@ -341,6 +387,134 @@ def _persist_po(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
conn.close() conn.close()
def _persist_po_watching(cfg: dict[str, Any], body: dict[str, Any]) -> int:
"""以期权为主:只落库盯盘计划,不下单."""
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_plan
from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
conn = cfg["get_db"]()
try:
init_hedge_plan_tables(conn)
view = str(body.get("direction") or "long")
money = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
plan_id = insert_plan(
conn,
{
"plan_type": "perp_options",
"status": "watching",
"underlying": str(body.get("underlying") or "ETH").upper(),
"direction": view,
"entry_mark": float(body.get("index_px") or body.get("entry") or 0) or None,
"tp": 0,
"sl": 0,
"sizing_mode_at_open": None,
"perp_size": None,
"margin": None,
"leverage": float(body.get("leverage") or 100),
"premium_total": 0,
"preview_json": _start_body_json(body),
"close_reason": None,
"opened_at": None,
"note": "盯盘中:等待杠杆/间隔达标后自动开仓",
"option_primary": 1,
"perp_direction": perp_direction_for_view(view),
"option_target_points": float(body.get("option_target_points") or 0),
"perp_target_points": float(body.get("perp_target_points") or 0),
"option_perp_ratio": float(body.get("option_perp_ratio") or 0),
"premium_budget": float(body.get("premium_budget") or 0),
"strike_interval": float(body.get("strike_interval") or 15),
"min_option_hours": float(body.get("min_option_hours") or 36),
"option_moneyness": money,
"option_leverage": float(body.get("option_leverage") or 0),
},
)
conn.commit()
return plan_id
finally:
conn.close()
def _activate_watching_po(
cfg: dict[str, Any],
conn: Any,
plan_id: int,
result: dict[str, Any],
body: dict[str, Any],
) -> None:
"""盯盘命中后:写入腿并把 watching → active/partial."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, insert_leg, update_plan
from lib.hedge_plan.hedge_plan_notify_lib import notify_plan_start
from lib.hedge_plan.hedge_plan_option_primary_lib import perp_direction_for_view
is_partial = bool(result.get("partial"))
missing = str(result.get("missing_leg") or "") if is_partial else ""
opt = result.get("option") or {}
perp = result.get("perp") or {}
if is_partial:
opt_ok = missing != "option_hedge" and bool(result.get("option"))
perp_ok = missing != "perp" and bool(result.get("perp"))
else:
opt_ok = True
perp_ok = True
premium = float((opt or {}).get("premium") or 0) if opt_ok else 0.0
view = str(body.get("direction") or "long")
perp_dir = (
str((perp or {}).get("direction") or "")
or perp_direction_for_view(view)
)
update_plan(
conn,
int(plan_id),
status="partial" if is_partial else "active",
entry_mark=float(body.get("entry") or body.get("index_px") or 0) or None,
perp_size=float((perp or {}).get("contracts") or body.get("contracts") or 0),
leverage=float(body.get("leverage") or 100),
premium_total=premium,
preview_json=_start_body_json(body, missing or None),
close_reason="partial_fail" if is_partial else None,
opened_at=result.get("opened_at"),
note=(result.get("msg") or "")[:500] if is_partial else "盯盘达标已开仓",
perp_direction=perp_dir,
)
insert_leg(
conn,
{
"plan_id": int(plan_id),
"leg_role": "perp",
"symbol": str(body.get("exchange_symbol") or ""),
"side": perp_dir,
"size": float((perp or {}).get("contracts") or body.get("contracts") or 0),
"avg_open": float(body.get("entry") or 0) if perp_ok else None,
"status": "open" if perp_ok else "pending",
"exchange_ord_id": str((perp or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if perp_ok else None,
},
)
insert_leg(
conn,
{
"plan_id": int(plan_id),
"leg_role": "option_hedge",
"inst_id": str((opt or {}).get("inst_id") or body.get("opt_inst_id") or ""),
"opt_type": str((opt or {}).get("opt_type") or body.get("opt_type") or ""),
"strike": (opt or {}).get("strike") or body.get("strike"),
"side": "buy",
"size": float((opt or {}).get("sheets") or body.get("sheets") or 1),
"avg_open": float((opt or {}).get("ask") or body.get("ask") or 0) if opt_ok else None,
"premium": premium if opt_ok else 0,
"ct_mult": float(body.get("ct_mult") or (opt or {}).get("ct_mult") or 0.01),
"status": "open" if opt_ok else "pending",
"exchange_ord_id": str((opt or {}).get("exchange_ord_id") or ""),
"opened_at": result.get("opened_at") if opt_ok else None,
},
)
if not is_partial:
plan = get_plan(conn, int(plan_id))
legs = get_plan_legs(conn, int(plan_id))
if plan:
notify_plan_start(cfg, conn, plan, legs)
def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int: def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any]) -> int:
from lib.hedge_plan.hedge_plan_db import ( from lib.hedge_plan.hedge_plan_db import (
get_plan, get_plan,
@@ -363,27 +537,36 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
float(b.get("premium") or 0) if b_ok else 0.0 float(b.get("premium") or 0) if b_ok else 0.0
) )
rr_raw = body.get("profit_rr")
try:
profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
except (TypeError, ValueError):
profit_rr = 2.0
if profit_rr <= 0:
profit_rr = 2.0
# 旧字段兼容:不再要求上/下破;有传则原样落库
def _opt_float(key: str, *alts: str) -> float | None:
for k in (key, *alts):
v = body.get(k)
if v not in (None, ""):
try:
return float(v)
except (TypeError, ValueError):
continue
return None
up_f = _opt_float("target_price_up", "target_price")
down_f = _opt_float("target_price_down", "target_price")
plan_id = insert_plan( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "options_options", "plan_type": "options_options",
"status": "partial" if is_partial else "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float( "target_price": up_f,
body.get("target_price_up") "target_price_up": up_f,
or body.get("target_price") "target_price_down": down_f,
or 0 "profit_rr": profit_rr,
),
"target_price_up": float(
body.get("target_price_up")
or body.get("target_price")
or 0
),
"target_price_down": float(
body.get("target_price_down")
or body.get("target_price")
or 0
),
"sizing_mode_at_open": load_position_sizing_mode(), "sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium, "premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
@@ -454,22 +637,43 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
direction = (request.args.get("direction") or "long").strip().lower() direction = (request.args.get("direction") or "long").strip().lower()
if direction not in ("long", "short"): if direction not in ("long", "short"):
direction = "long" direction = "long"
option_primary = (request.args.get("option_primary") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
data, err = _fetch_perp_market(cfg, base) data, err = _fetch_perp_market(cfg, base)
if err: if err:
return jsonify({"ok": False, "msg": err}), 400 return jsonify({"ok": False, "msg": err}), 400
sizing_mode = load_position_sizing_mode() sizing_mode = load_position_sizing_mode()
gates = _gates_dict(cfg, "perp_options") gates = _gates_dict(cfg, "perp_options")
if option_primary:
from lib.hedge_plan.hedge_plan_option_primary_lib import (
opt_type_for_view,
perp_direction_for_view,
)
suggested = opt_type_for_view(direction)
perp_dir = perp_direction_for_view(direction)
acct_note = "以期权为主:看法腿买期权,永续反向对冲"
else:
suggested = "P" if direction == "long" else "C"
perp_dir = direction
acct_note = "永续腿使用合约(交易)账户可用 USDT"
out = { out = {
"ok": True, "ok": True,
"base": base, "base": base,
"direction": direction, "direction": direction,
"suggested_opt_type": "P" if direction == "long" else "C", "option_primary": option_primary,
"suggested_opt_type": suggested,
"perp_direction": perp_dir,
**data, **data,
"gates": gates, "gates": gates,
"sizing_mode": sizing_mode, "sizing_mode": sizing_mode,
"account_kind": "perp", "account_kind": "perp",
"account_label": cfg.get("perp_account_label") or "合约账户", "account_label": cfg.get("perp_account_label") or "合约账户",
"account_note": "永续腿使用合约(交易)账户可用 USDT", "account_note": acct_note,
} }
return jsonify(out) return jsonify(out)
@@ -482,29 +686,91 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
if ex is None: if ex is None:
return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400 return jsonify({"ok": False, "msg": "期权交易所未初始化"}), 400
u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper() u = (request.args.get("underlying") or cfg.get("default_underly") or "ETH").upper()
# 热更新:链展示天数每次读 env
chain_max_dte = float(
os.getenv("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS")
or os.getenv("OKX_OPTIONS_MAX_DTE_DAYS")
or cfg.get("chain_max_dte")
or 14
)
try: try:
chain = cfg["build_option_chain"]( chain = cfg["build_option_chain"](
ex, ex,
u, u,
max_dte_days=float(cfg.get("chain_max_dte") or 14), max_dte_days=chain_max_dte,
itm_only=False, itm_only=False,
itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"), itm_max_dist_usd=float(os.getenv("OKX_OPTIONS_ITM_MAX_DIST_USD") or "30"),
) )
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500 return jsonify({"ok": False, "msg": f"拉取期权链失败: {e}"}), 500
# 可选:永期以期权为主时按最低剩余小时/行权间隔过滤(仅当请求显式带 option_primary)
# 默认拉链不再带此过滤,避免期期看不到明天到期
option_primary = (request.args.get("option_primary") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
min_hours = None
strike_interval = None
try:
if request.args.get("min_hours") not in (None, ""):
min_hours = float(request.args.get("min_hours"))
except (TypeError, ValueError):
min_hours = 36.0 if option_primary else None
try:
if request.args.get("strike_interval") not in (None, ""):
strike_interval = float(request.args.get("strike_interval"))
except (TypeError, ValueError):
strike_interval = 15.0 if option_primary else None
if option_primary and min_hours is None:
min_hours = 36.0
if option_primary and strike_interval is None:
strike_interval = 15.0
if min_hours is not None or strike_interval is not None:
from lib.hedge_plan.hedge_plan_option_primary_lib import hours_to_expiry_from_ms
idx = None
try:
idx = float(chain.get("index_px") or 0) or None
except (TypeError, ValueError):
idx = None
filtered = []
for exp in chain.get("expiries") or []:
h = hours_to_expiry_from_ms(exp.get("exp_time"))
if min_hours is not None and h is not None and h < min_hours:
continue
contracts = []
for c in exp.get("contracts") or []:
row = dict(c)
row["hours_to_expiry"] = h
if strike_interval is not None and idx and idx > 0:
try:
k = float(row.get("strike") or 0)
except (TypeError, ValueError):
k = 0.0
if k > 0 and abs(k - idx) > strike_interval + 1e-9:
continue
contracts.append(row)
if contracts:
filtered.append({**exp, "contracts": contracts, "hours_to_expiry": h})
chain = {**chain, "expiries": filtered}
opt_acct = _options_account_snapshot(cfg) opt_acct = _options_account_snapshot(cfg)
return jsonify( return jsonify(
{ {
"ok": True, "ok": True,
**chain, **chain,
"underlying": u, "underlying": u,
"chain_max_dte_days": cfg.get("chain_max_dte"), "chain_max_dte_days": chain_max_dte,
"account_kind": "options", "account_kind": "options",
"account_label": cfg.get("options_account_label") or "期权账户", "account_label": cfg.get("options_account_label") or "期权账户",
"account_note": "期权腿使用期权账户(交易 USDC)", "account_note": "期权腿使用期权账户(交易 USDC)",
"options_account": opt_acct, "options_account": opt_acct,
"trade_budget_usdc": cfg.get("trade_budget_usdc"), "trade_budget_usdc": cfg.get("trade_budget_usdc"),
"budget_buffer": cfg.get("budget_buffer"), "budget_buffer": cfg.get("budget_buffer"),
"option_primary": option_primary,
"min_hours": min_hours,
"strike_interval": strike_interval,
} }
) )
@@ -560,37 +826,84 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
plan_type = (body.get("plan_type") or "perp_options").strip().lower() plan_type = (body.get("plan_type") or "perp_options").strip().lower()
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
gates = _gates_dict(cfg, plan_type) try:
if not dry_run and not gates.get("can_start"): from lib.options.options_margin_mode_lib import is_coin_margin_mode
if is_coin_margin_mode() and not dry_run:
return jsonify(
{
"ok": False,
"msg": "当前单笔期权为币本位模式,对冲计划仅支持 USDC 期权;请将 OKX_OPTIONS_MARGIN_MODE=usdc 并重启后再开对冲",
}
), 400
except Exception as e:
return jsonify( return jsonify(
{"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates} {"ok": False, "msg": f"期权本位校验失败,已拒绝开对冲: {e}"}
), 400 ), 400
err = validate_start_body(plan_type, body) with _hedge_start_lock():
if err: gates = _gates_dict(cfg, plan_type)
return jsonify({"ok": False, "msg": err, "gates": gates}), 400 if not dry_run and not gates.get("can_start"):
# 补齐永续杠杆 return jsonify(
if plan_type == "perp_options" and not body.get("leverage"): {"ok": False, "msg": "; ".join(gates.get("reasons") or ["不可开仓"]), "gates": gates}
base = str(body.get("underlying") or "ETH").upper() ), 400
body["leverage"] = cfg.get("btc_leverage") if base == "BTC" else (cfg.get("btc_leverage") or 10) err = validate_start_body(plan_type, body)
# ETH 也用 BTC 档 10x 按方案;ALT 为 alt_leverage 仅非 BTC/ETH if err:
if base in ("BTC", "ETH"): return jsonify({"ok": False, "msg": err, "gates": gates}), 400
body["leverage"] = int(cfg.get("btc_leverage") or 10) # 补齐永续杠杆(以期权为主默认 100;保险模式 BTC/ETH 用 btc_leverage)
if plan_type == "options_options": if plan_type == "perp_options" and not body.get("leverage"):
out = execute_options_options_start( from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
cfg,
body, if is_option_primary(body):
dry_run=dry_run, body["leverage"] = 100
persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))), else:
) base = str(body.get("underlying") or "ETH").upper()
else: if base in ("BTC", "ETH"):
out = execute_perp_options_start( body["leverage"] = int(cfg.get("btc_leverage") or 10)
cfg, else:
body, body["leverage"] = int(cfg.get("alt_leverage") or 5)
dry_run=dry_run, # 以期权为主:策略启动=盯盘,不现场开仓
persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))), if plan_type == "perp_options":
) from lib.hedge_plan.hedge_plan_option_primary_lib import is_option_primary
out["gates"] = gates
return jsonify(out), (200 if out.get("ok") else 400) watch = body.get("watch_entry")
watch_on = watch in (None, "", True, 1, "1", "true", "yes", "on")
if is_option_primary(body) and watch_on:
if dry_run:
return jsonify(
{
"ok": True,
"dry_run": True,
"watching": True,
"msg": "dry_run:将创建盯盘计划(不落库)",
"gates": gates,
}
)
plan_id = _persist_po_watching(cfg, body)
return jsonify(
{
"ok": True,
"watching": True,
"plan_id": plan_id,
"msg": "已启动盯盘,杠杆/间隔达标后自动开仓",
"gates": gates,
}
)
if plan_type == "options_options":
out = execute_options_options_start(
cfg,
body,
dry_run=dry_run,
persist=(None if dry_run else (lambda r, b: _persist_oo(cfg, r, b))),
)
else:
out = execute_perp_options_start(
cfg,
body,
dry_run=dry_run,
persist=(None if dry_run else (lambda r, b: _persist_po(cfg, r, b))),
)
out["gates"] = gates
return jsonify(out), (200 if out.get("ok") else 400)
@app.route("/api/hedge-plan/<int:plan_id>/end", methods=["POST"]) @app.route("/api/hedge-plan/<int:plan_id>/end", methods=["POST"])
@lr @lr
@@ -628,12 +941,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
if not dry_run and not _hedge_enabled():
return jsonify({"ok": False, "msg": "当前交易模式为单独期权,不可补开对冲腿"}), 400
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_hedge_plan_tables(conn) init_hedge_plan_tables(conn)
plan = get_plan(conn, plan_id) plan = get_plan(conn, plan_id)
if not plan: if not plan:
return jsonify({"ok": False, "msg": "计划不存在"}), 404 return jsonify({"ok": False, "msg": "计划不存在"}), 404
pt = str(plan.get("plan_type") or "")
if pt == "perp_options" and not _show_perp_options():
return jsonify({"ok": False, "msg": "当前模式非永期对冲,不可补开"}), 400
if pt == "options_options" and not _show_options_options():
return jsonify({"ok": False, "msg": "当前模式非期期对冲,不可补开"}), 400
if str(plan.get("status") or "") != "partial": if str(plan.get("status") or "") != "partial":
return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400 return jsonify({"ok": False, "msg": "仅半腿待补(partial)计划可补开"}), 400
legs = get_plan_legs(conn, plan_id) legs = get_plan_legs(conn, plan_id)
@@ -770,17 +1090,19 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
try: try:
init_hedge_plan_tables(conn) init_hedge_plan_tables(conn)
rows = [] rows = []
for status in ("opening", "active", "partial"): for status in ("watching", "opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80)) rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
for row in rows: for row in rows:
if str(row.get("status") or "") == "watching":
continue
try: try:
reconcile_unfilled_option_legs(cfg, conn, int(row["id"])) reconcile_unfilled_option_legs(cfg, conn, int(row["id"]))
except Exception: except Exception:
pass pass
# 校正后可能 status 变化,重新拉一遍 # 校正后可能 status 变化,重新拉一遍
rows = [] rows = []
for status in ("opening", "active", "partial"): for status in ("watching", "opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80)) rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
plans = attach_legs_to_plans(conn, rows) plans = attach_legs_to_plans(conn, rows)
@@ -862,6 +1184,37 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
def _preview_po(body: dict[str, Any]) -> dict[str, Any]: def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_po_option_moneyness
from lib.hedge_plan.hedge_plan_option_primary_lib import (
build_option_primary_preview,
is_option_primary,
size_from_premium,
validate_option_primary_start,
)
if is_option_primary(body):
err = validate_option_primary_start(body)
if err:
raise ValueError(err)
sized = size_from_premium(
premium_budget=float(body.get("premium_budget") or 0),
ask=float(body.get("ask") or 0),
ct_mult=float(body.get("ct_mult") or 0.01),
ratio=float(body.get("option_perp_ratio") or 2),
contract_size=float(body.get("contract_size") or 0.01),
)
if not sized.get("ok"):
raise ValueError(sized.get("msg") or "定仓失败")
body = dict(body)
body["sheets"] = sized["sheets"]
body["contracts"] = sized["contracts"]
body["eth_qty"] = sized["eth_qty"]
if not body.get("entry"):
body["entry"] = body.get("index_px") or 0
out = build_option_primary_preview(body)
out["sizing"] = sized
return out
direction = str(body.get("direction") or "long").lower() direction = str(body.get("direction") or "long").lower()
entry = float(body["entry"]) entry = float(body["entry"])
tp = float(body["tp"]) tp = float(body["tp"])
@@ -879,6 +1232,16 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
raise ValueError("缺少权利金或卖一价") raise ValueError("缺少权利金或卖一价")
premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult) premium = option_premium_total(ask=float(ask), sheets=sheets, ct_mult=ct_mult)
index_px = body.get("index_px") index_px = body.get("index_px")
idx_for_money = float(index_px) if index_px is not None else entry
money_err = validate_po_option_moneyness(
opt_type=opt_type,
strike=strike,
index_px=idx_for_money,
ask=ask,
hours_to_expiry=body.get("hours_to_expiry"),
)
if money_err:
raise ValueError(money_err)
return build_perp_options_preview( return build_perp_options_preview(
direction=direction, direction=direction,
entry=entry, entry=entry,
@@ -896,6 +1259,14 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
rr_raw = body.get("profit_rr")
profit_rr = None
if rr_raw not in (None, ""):
profit_rr = float(rr_raw)
if profit_rr <= 0:
raise ValueError("盈亏比须大于0")
up = body.get("target_price_up") up = body.get("target_price_up")
down = body.get("target_price_down") down = body.get("target_price_down")
legacy = body.get("target_price") legacy = body.get("target_price")
@@ -903,13 +1274,19 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
up = legacy up = legacy
if down in (None, "") and legacy not in (None, ""): if down in (None, "") and legacy not in (None, ""):
down = legacy down = legacy
if up in (None, "") or down in (None, ""): if profit_rr is None and (up in (None, "") or down in (None, "")):
raise ValueError("请填写上破与下破目标价") raise ValueError("请填写盈亏比")
up_f = float(up) up_f = float(up) if up not in (None, "") else None
down_f = float(down) down_f = float(down) if down not in (None, "") else None
if up_f <= down_f: if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f:
raise ValueError("上破目标价必须大于下破目标价") raise ValueError("上破目标价必须大于下破目标价")
index_px = float(body.get("index_px") or ((up_f + down_f) / 2)) index_px = body.get("index_px")
if index_px in (None, ""):
if up_f is not None and down_f is not None:
index_px = (up_f + down_f) / 2
else:
raise ValueError("缺少指数价格")
index_px = float(index_px)
leg_a = body.get("leg_a") or {} leg_a = body.get("leg_a") or {}
leg_b = body.get("leg_b") or {} leg_b = body.get("leg_b") or {}
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
@@ -923,7 +1300,11 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
) )
if leg.get("premium_paid") is None: if leg.get("premium_paid") is None:
raise ValueError(f"缺少 {name} 权利金") raise ValueError(f"缺少 {name} 权利金")
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px)
if money_err:
raise ValueError(money_err)
return build_options_options_preview( return build_options_options_preview(
profit_rr=profit_rr,
target_price_up=up_f, target_price_up=up_f,
target_price_down=down_f, target_price_down=down_f,
index_px=index_px, index_px=index_px,
+5 -1
View File
@@ -109,7 +109,11 @@ def resolve_option_leg_realized_pnl(
except Exception: except Exception:
rows = None rows = None
if rows: if rows:
info = resolve_option_close_from_history(rows, open_ms=open_ms) close_ms = _parse_opened_ms(leg.get("closed_at"))
sheets = _sf(leg.get("size")) or _sf(leg.get("sheets"))
info = resolve_option_close_from_history(
rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets
)
pnl = _sf((info or {}).get("realized_pnl")) if info else None pnl = _sf((info or {}).get("realized_pnl")) if info else None
if pnl is not None: if pnl is not None:
return round(float(pnl), 4), "exchange" return round(float(pnl), 4), "exchange"
+103
View File
@@ -0,0 +1,103 @@
"""OKX 期权/对冲三选一模式(env: OKX_TRADE_MODE).
options 仅单独期权(隐藏对冲导航与对冲 env 配置)
perp_options 仅永期对冲(不可单独开期权;对冲组数上限 MAX_ACTIVE_HEDGE_PLANS)
options_options 仅期期对冲(同上)
"""
from __future__ import annotations
import os
from typing import Optional
MODE_OPTIONS = "options"
MODE_PERP = "perp_options"
MODE_OO = "options_options"
VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO})
_ALIASES = {
"option": MODE_OPTIONS,
"standalone": MODE_OPTIONS,
"期权": MODE_OPTIONS,
"单独期权": MODE_OPTIONS,
"po": MODE_PERP,
"perp": MODE_PERP,
"永期": MODE_PERP,
"永期对冲": MODE_PERP,
"oo": MODE_OO,
"期期": MODE_OO,
"期期对冲": MODE_OO,
}
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def normalize_okx_trade_mode(raw: Optional[str]) -> str:
s = str(raw or "").strip().lower()
if s in VALID_MODES:
return s
if s in _ALIASES:
return _ALIASES[s]
return ""
def legacy_infer_okx_trade_mode() -> str:
"""未配置 OKX_TRADE_MODE 时,按旧开关推断,避免已有部署行为突变."""
if not _env_bool("HEDGE_PLAN_ENABLED", False):
return MODE_OPTIONS
show_po = _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
show_oo = _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
if show_po and not show_oo:
return MODE_PERP
if show_oo and not show_po:
return MODE_OO
if show_po:
return MODE_PERP
if show_oo:
return MODE_OO
return MODE_OPTIONS
def get_okx_trade_mode() -> str:
m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
if m:
return m
return legacy_infer_okx_trade_mode()
def hedge_module_enabled() -> bool:
return get_okx_trade_mode() in (MODE_PERP, MODE_OO)
def show_perp_options() -> bool:
return get_okx_trade_mode() == MODE_PERP
def show_options_options() -> bool:
return get_okx_trade_mode() == MODE_OO
def standalone_options_open_allowed() -> bool:
return get_okx_trade_mode() == MODE_OPTIONS
def mode_label(mode: Optional[str] = None) -> str:
m = mode or get_okx_trade_mode()
return {
MODE_OPTIONS: "单独期权",
MODE_PERP: "永期对冲",
MODE_OO: "期期对冲",
}.get(m, m or "")
def block_standalone_open_by_mode_msg() -> Optional[str]:
if standalone_options_open_allowed():
return None
return (
f"当前交易模式为「{mode_label()}」,不可单独开期权;"
"请在 env「交易模式」切换为「单独期权」"
)
+143 -60
View File
@@ -2,9 +2,10 @@
data-default-underly="{{ options_default_underly | default('ETH') }}" data-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}" data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}" data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options | default(true) else '0' }}" data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
data-show-oo="{{ '1' if hedge_plan_show_options_options | default(true) else '0' }}" data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}"
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled | default(true) else '0' }}" data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}"
data-option-primary="{{ '1' if hedge_plan_option_primary|default(true) else '0' }}"
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}" data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
data-sizing-mode="{{ position_sizing_mode | default('risk') }}" data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}"> data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
@@ -14,8 +15,8 @@
{% if not options_enabled %} {% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div> <div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %} {% endif %}
{% if hedge_plan_enabled and not (hedge_plan_show_perp_options | default(true)) and not (hedge_plan_show_options_options | default(true)) %} {% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %}
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:<code>env配置 → 对冲计划</code> 打开显示开关;进行中/历史仍可查看.</div> <div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
{% endif %} {% endif %}
<div class="card hp-head-card"> <div class="card hp-head-card">
@@ -26,10 +27,10 @@
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button> <button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div> </div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类"> <div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
{% if hedge_plan_show_perp_options | default(true) %} {% if hedge_plan_show_perp_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button> <button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
{% endif %} {% endif %}
{% if hedge_plan_show_options_options | default(true) %} {% if hedge_plan_show_options_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button> <button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
{% endif %} {% endif %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button> <button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
@@ -43,13 +44,19 @@
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel"> <div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout"> <div class="options-dual-grid" id="hp-po-layout">
<div class="card hp-po-perp-card"> <div class="card hp-po-perp-card">
<h2>永续 · <span id="hp-perp-uly-label">ETH</span> <span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span></h2> <h2>
<span id="hp-po-mode-badge" class="hp-po-mode-badge">以期权为主</span>
<span id="hp-po-card-title">执行参数</span>
· <span id="hp-perp-uly-label">ETH</span>
<span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span>
</h2>
<details class="tip-collapse hp-rule-collapse"> <details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary> <summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip"> <div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);保险期权走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p> <p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>下单</strong>先「计算」再「启动」。启动瞬间会再拉卖一并以 IOC 等完全成交;半腿失败可补开或「结束计划」(不平仓)。永期开仓需全仓计仓 + 对冲实盘门禁</p> <p><strong>模式</strong>在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式</p>
<p><strong>板块</strong>左填永续开仓/止盈止损与张数;右选保险腿(做多配 Put、做空配 Call)。止盈后保险腿默认可持有;止损会联动平期权</p> <p><strong>保险模式</strong>做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场</p>
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
</div> </div>
</details> </details>
<div class="form-row hp-uly-row"> <div class="form-row hp-uly-row">
@@ -58,13 +65,11 @@
</div> </div>
<div class="hp-po-top"> <div class="hp-po-top">
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向"> <div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多永续"><span class="hp-oo-check" aria-hidden="true"></span>做多</button> <button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多=买Call+永续"><span class="hp-oo-check" aria-hidden="true"></span>做多</button>
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空永续"><span class="hp-oo-check" aria-hidden="true"></span>做空</button> <button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空=买Put+永续"><span class="hp-oo-check" aria-hidden="true"></span>做空</button>
</div> </div>
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
</div> </div>
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p> <div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
<div class="hp-po-fields">
<label class="hp-po-field"> <label class="hp-po-field">
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span> <span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /> <input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
@@ -82,45 +87,118 @@
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /> <input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label> </label>
</div> </div>
<div id="hp-po-fields-option-primary">
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆配置</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
<label class="hp-po-field">
<span class="hp-po-field-lab">权利金 <em>USDC</em></span>
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续杠杆</span>
<input type="number" step="1" id="hp-perp-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权杠杆 <em>启动校验</em></span>
<input type="number" step="1" id="hp-opt-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-select">
<h3 id="hp-po-sec-select" class="hp-po-section-title">选约条件</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--select">
<label class="hp-po-field">
<span class="hp-po-field-lab">到期时间 <em>最短h</em></span>
<input type="number" step="1" id="hp-min-hours" value="36" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权间隔 <em></em></span>
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--type">
<span class="hp-po-field-lab">类型</span>
<select id="hp-money-select" aria-label="虚实值类型">
<option value="otm" selected>虚值</option>
<option value="itm">实值/平值</option>
<option value="atm">仅平值</option>
</select>
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
<div class="hp-po-fields hp-po-fields--section">
<label class="hp-po-field">
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-perp-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
</div>
<div class="hp-po-summary"> <div class="hp-po-summary">
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div> <div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
<div id="hp-sizing-line" class="muted hp-po-sizing"></div> <div id="hp-sizing-line" class="muted hp-po-sizing"></div>
</div> </div>
</div> </div>
<div class="card hp-opt-card"> <div class="card hp-po-right-card">
<h2>期权 · <span id="hp-opt-type-label">Put</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2> <div class="hp-po-right-stack">
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar"> <div class="hp-po-inner-card hp-po-perp-quote-card">
<select id="hp-exp-select"><option value="">选择到期日</option></select> <h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
<button type="button" class="btn-secondary hp-money-btn active" data-money="all">全部</button> <div class="hp-po-quote-head">
<button type="button" class="btn-secondary hp-money-btn" data-money="itm">实值</button> <span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
<button type="button" class="btn-secondary hp-money-btn" data-money="otm">虚值</button> </div>
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button> <p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span> <p id="hp-po-perp-quote-right" class="muted hp-po-meta" hidden></p>
</div>
<div class="hp-po-inner-card hp-opt-card">
<h2>期权 · <span id="hp-opt-type-label">Call</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
<select id="hp-exp-select"><option value="">选择到期日</option></select>
<span class="hp-po-ins-money" id="hp-po-ins-money" hidden>
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平值</button>
</span>
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="按当前类型自动匹配最近合约">自动匹配</button>
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
</div>
<div class="options-strike-table-wrap hp-strike-table-wrap--6">
<table class="options-strike-table" id="hp-strike-table">
<thead>
<tr>
<th>行权价</th>
<th>实虚值</th>
<th title="指数÷卖一">杠杆</th>
<th>卖一/张</th>
<th>买一/张</th>
<th>操作</th>
</tr>
</thead>
<tbody id="hp-strike-tbody">
<tr><td colspan="6" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst"></code></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span class="muted" id="hp-premium-line"></span>
</div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
</div>
</div> </div>
<div class="options-strike-table-wrap hp-strike-table-wrap--5"> <div class="form-row hp-action-row hp-po-action-row">
<table class="options-strike-table" id="hp-strike-table"> <span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
<thead> <button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
<tr>
<th>行权价</th>
<th>实虚值</th>
<th>卖一/张</th>
<th>买一/张</th>
<th>操作</th>
</tr>
</thead>
<tbody id="hp-strike-tbody">
<tr><td colspan="5" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst"></code></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span class="muted" id="hp-premium-line"></span>
</div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn">计算</button>
</div> </div>
</div> </div>
</div> </div>
@@ -135,7 +213,7 @@
<div class="tip-collapse-body rule-tip"> <div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p> <p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p> <p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p> <p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
</div> </div>
</details> </details>
<div class="form-row hp-uly-row"> <div class="form-row hp-uly-row">
@@ -143,8 +221,7 @@
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div> </div>
<div class="form-row hp-target-row hp-oo-target-row"> <div class="form-row hp-target-row hp-oo-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label> <label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span> <span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div> </div>
<div class="hp-oo-controls"> <div class="hp-oo-controls">
@@ -159,7 +236,7 @@
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row"> <div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span> <span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式"> <div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后立刻买一清另一腿(无2×,失败重试)"><span class="hp-oo-check" aria-hidden="true"></span></button> <button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>残值</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button> <button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div> </div>
</div> </div>
@@ -185,24 +262,30 @@
<h2>期权 T 型报价</h2> <h2>期权 T 型报价</h2>
<div class="form-row"> <div class="form-row">
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select> <select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary hp-oo-money-btn is-selected active" data-oo-money="atm_otm" title="平值+虚值" aria-pressed="true"><span class="hp-oo-check" aria-hidden="true"></span>平/虚</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>仅平值</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>仅虚值</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-atm" data-oo-rec="atm_straddle" title="最近平值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>推荐跨式</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-otm" data-oo-rec="double_otm" title="最近虚值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>推荐双虚</button>
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button> <button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
<button type="button" class="btn-secondary hp-oo-expand-btn" id="hp-oo-expand-all" title="展开该到期全部平值/虚值行权价;若当前为「仅平值」会自动切到「平/虚」" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true"></span>显示全部</button>
</div> </div>
<div class="options-strike-table-wrap options-strike-table-wrap--t"> <div class="options-strike-table-wrap hp-oo-table-wrap" id="hp-oo-table-wrap">
<table class="options-strike-table options-strike-table--t" id="hp-oo-table"> <table class="options-strike-table options-strike-table--t" id="hp-oo-table">
<thead> <thead>
<tr> <tr>
<th colspan="3" class="opt-t-head-call">Call</th> <th colspan="4" class="opt-t-head-call">Call</th>
<th class="opt-t-head-mid">行权</th> <th class="opt-t-head-mid">行权</th>
<th colspan="3" class="opt-t-head-put">Put</th> <th colspan="4" class="opt-t-head-put">Put</th>
</tr> </tr>
<tr> <tr>
<th>卖一/张</th><th>实虚值</th><th>选用</th> <th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
<th>K</th> <th>K</th>
<th>实虚值</th><th>卖一/张</th><th>选用</th> <th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
</tr> </tr>
</thead> </thead>
<tbody id="hp-oo-tbody"> <tbody id="hp-oo-tbody">
<tr><td colspan="7" class="muted">请刷新期权链</td></tr> <tr><td colspan="9" class="muted">请刷新期权链</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -322,4 +405,4 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/static/hedge_plan.js?v=33"></script> <script src="/static/hedge_plan.js?v=46"></script>
+1
View File
@@ -76,6 +76,7 @@ def install_instance_theme_static(app) -> None:
"instance_live.js": "application/javascript; charset=utf-8", "instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.js": "application/javascript; charset=utf-8", "instance_settings_prefs.js": "application/javascript; charset=utf-8",
"instance_dashboard.js": "application/javascript; charset=utf-8", "instance_dashboard.js": "application/javascript; charset=utf-8",
"account_ledger.js": "application/javascript; charset=utf-8",
"options_expiry_countdown.js": "application/javascript; charset=utf-8", "options_expiry_countdown.js": "application/javascript; charset=utf-8",
"options_panel.js": "application/javascript; charset=utf-8", "options_panel.js": "application/javascript; charset=utf-8",
"order_entry_model.js": "application/javascript; charset=utf-8", "order_entry_model.js": "application/javascript; charset=utf-8",
+4 -3
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from lib.hub.hub_trades_lib import current_trading_day from lib.hub.hub_trades_lib import current_trading_day
from lib.hub.hub_options_funds_lib import merge_board_row_balances from lib.hub.hub_options_funds_lib import merge_board_row_balances, repair_double_counted_fund_entry
from lib.paths import manual_trading_hub_dir from lib.paths import manual_trading_hub_dir
@@ -275,7 +275,7 @@ def _series_from_history(
total = 0.0 total = 0.0
n = 0 n = 0
for key in account_keys: for key in account_keys:
ac = ac_map.get(key) or {} ac = repair_double_counted_fund_entry(ac_map.get(key) or {})
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None: if t is None:
t = _safe_float(ac.get("total_usdt")) t = _safe_float(ac.get("total_usdt"))
@@ -291,7 +291,8 @@ def _series_from_history(
def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]: def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for day in sorted(history.keys()): for day in sorted(history.keys()):
ac = (history.get(day) or {}).get("accounts", {}).get(key) or {} raw = (history.get(day) or {}).get("accounts", {}).get(key) or {}
ac = repair_double_counted_fund_entry(raw)
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt")) t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
if t is None: if t is None:
t = _safe_float(ac.get("total_usdt")) t = _safe_float(ac.get("total_usdt"))
+26 -1
View File
@@ -7,6 +7,7 @@ from lib.hub.hub_options_funds_lib import (
options_float_pnl_usdt, options_float_pnl_usdt,
options_open_position_count as count_options_positions, options_open_position_count as count_options_positions,
) )
from lib.hub.hub_position_metrics import is_option_like_position
def _coerce_float(value: Any) -> float | None: def _coerce_float(value: Any) -> float | None:
@@ -27,6 +28,27 @@ def position_unrealized_pnl(pos: dict[str, Any]) -> float:
def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]: def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(agent, dict):
return []
positions = agent.get("positions")
if not isinstance(positions, list):
return []
out: list[dict[str, Any]] = []
for p in positions:
if not isinstance(p, dict):
continue
if is_option_like_position(p):
continue
try:
c = abs(float(p.get("contracts") or 0))
except (TypeError, ValueError):
c = 0.0
if c > 1e-12:
out.append(p)
return out
def _raw_open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(agent, dict): if not isinstance(agent, dict):
return [] return []
positions = agent.get("positions") positions = agent.get("positions")
@@ -79,8 +101,11 @@ def aggregate_monitor_board_totals(
ag = row.get("agent") if isinstance(row.get("agent"), dict) else {} ag = row.get("agent") if isinstance(row.get("agent"), dict) else {}
open_pos = _open_positions(ag) open_pos = _open_positions(ag)
open_position_count += len(open_pos) open_position_count += len(open_pos)
raw_pos = _raw_open_positions(ag)
contaminated = any(is_option_like_position(p) for p in raw_pos)
agent_upnl = _coerce_float(ag.get("total_unrealized_pnl")) agent_upnl = _coerce_float(ag.get("total_unrealized_pnl"))
if agent_upnl is not None: # 子代理若把期权混进永续合计,改按过滤后腿求和;期权浮盈由下方 options 段计入
if agent_upnl is not None and not contaminated:
float_pnl_u += agent_upnl float_pnl_u += agent_upnl
else: else:
float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos) float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos)
+166 -16
View File
@@ -36,30 +36,127 @@ def _sum_optional(*values: Any) -> Optional[float]:
def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]: def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]:
"""从期权 snapshot 提取资金户/交易户 USDT 等价余额.""" """从期权 snapshot 提取资金户/交易户 USDT 等价余额.
- funding_usdt / trading_usdt: USDT+USDC(+USDG) 全账户(勿与永续 USDT 再加总)
- funding_usdc_equiv / trading_usdc_equiv: 仅非 USDT 稳定币,可安全加到永续 USDT
"""
snap = options_snap if isinstance(options_snap, dict) else {} snap = options_snap if isinstance(options_snap, dict) else {}
if snap.get("enabled") is False: if snap.get("enabled") is False:
return {"ok": False, "funding_usdt": None, "trading_usdt": None} return {
"ok": False,
"funding_usdt": None,
"trading_usdt": None,
"funding_usdc_equiv": None,
"trading_usdc_equiv": None,
}
if snap.get("ok") is False: if snap.get("ok") is False:
return {"ok": False, "funding_usdt": None, "trading_usdt": None} return {
"ok": False,
"funding_usdt": None,
"trading_usdt": None,
"funding_usdc_equiv": None,
"trading_usdc_equiv": None,
}
bal = snap.get("balances") if isinstance(snap.get("balances"), dict) else snap bal = snap.get("balances") if isinstance(snap.get("balances"), dict) else snap
funding = _sum_optional(bal.get("funding_usdt"), bal.get("funding_usdc")) funding_usdc_equiv = _sum_optional(bal.get("funding_usdc"), bal.get("funding_usdg"))
trading = _sum_optional(bal.get("trading_usdt"), bal.get("trading_usdc")) trading_usdc_equiv = _sum_optional(bal.get("trading_usdc"), bal.get("trading_usdg"))
funding = _sum_optional(bal.get("funding_usdt"), funding_usdc_equiv)
trading = _sum_optional(bal.get("trading_usdt"), trading_usdc_equiv)
ok = funding is not None and trading is not None ok = funding is not None and trading is not None
return {"ok": ok, "funding_usdt": funding, "trading_usdt": trading} return {
"ok": ok,
"funding_usdt": funding,
"trading_usdt": trading,
"funding_usdc_equiv": funding_usdc_equiv,
"trading_usdc_equiv": trading_usdc_equiv,
}
def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]: def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]:
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U."""
snap = options_snap if isinstance(options_snap, dict) else {} snap = options_snap if isinstance(options_snap, dict) else {}
if snap.get("enabled") is False or snap.get("ok") is False: if snap.get("enabled") is False or snap.get("ok") is False:
return None return None
upl = snap.get("upl_total_usdc")
if upl is None: def _safe(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _index_px() -> float | None:
px = _safe(snap.get("options_index_px") or snap.get("index_px"))
if px is not None and px > 0:
return px
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
px = _safe(p.get("idx_px") or p.get("idxPx"))
if px is not None and px > 0:
return px
return None return None
def _row_is_coin(p: dict[str, Any]) -> bool:
ccy = str(p.get("premium_ccy") or "").strip().upper()
if ccy in ("ETH", "BTC"):
return True
if str(p.get("margin_mode") or "").strip().lower() == "coin":
return True
mid = str(p.get("inst_id") or "")
return "-USD-" in mid.upper() and "_UM" not in mid.upper()
mode = str(snap.get("options_margin_mode") or snap.get("margin_mode") or "").strip().lower()
snap_coin = mode == "coin"
idx = _index_px()
try: try:
return round(float(upl), 4) from lib.options.options_positions_lib import display_pnl_from_option_row
except (TypeError, ValueError):
return None total_u = 0.0
found = False
missing_fx = False
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
pnl = display_pnl_from_option_row(p)
if pnl is None:
continue
found = True
if snap_coin or _row_is_coin(p):
px = _safe(p.get("idx_px") or p.get("idxPx"))
if px is None or px <= 0:
px = idx
if px is None or px <= 0:
missing_fx = True
continue
total_u += float(pnl) * float(px)
else:
total_u += float(pnl)
if found and not missing_fx:
return round(total_u, 4)
if found and missing_fx and abs(total_u) > 1e-12:
# 部分腿已换算成功时仍返回可得合计
return round(total_u, 4)
except Exception:
pass
upl = snap.get("upl_total_usdc")
if upl is not None:
try:
raw = float(upl)
except (TypeError, ValueError):
return None
if snap_coin or any(
isinstance(p, dict) and _row_is_coin(p) for p in (snap.get("positions") or [])
):
if idx is None or idx <= 0:
return None
return round(raw * float(idx), 4)
return round(raw, 4)
return None
def options_open_position_count(options_snap: dict[str, Any] | None) -> int: def options_open_position_count(options_snap: dict[str, Any] | None) -> int:
@@ -80,19 +177,45 @@ def merge_perp_options_balances(
perpetual_trading_usdt: Any, perpetual_trading_usdt: Any,
options_snap: dict[str, Any] | None, options_snap: dict[str, Any] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""永续 + 期权余额合并为中控 USDT 统计口径.""" """永续 USDT + 期权非 USDT 稳定币合并为中控总资金(避免 OKX 同账户 USDT 双计).
与实例顶栏 total_funds_usdt(..., options_usdc, None, None) 口径一致:
期权 snapshot 里的 USDT 与永续资金/交易户是同一钱包,只把 USDC/USDG 加上.
"""
opt = options_balances_usdt_equiv(options_snap) opt = options_balances_usdt_equiv(options_snap)
funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdt")) # 展示用期权户:优先非 USDT 稳定币;若仅有 USDT 则仍给出全量以便辨识
trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdt")) opt_fund_disp = opt.get("funding_usdc_equiv")
opt_trade_disp = opt.get("trading_usdc_equiv")
if opt_fund_disp is None and opt_trade_disp is None and opt.get("ok"):
opt_fund_disp = opt.get("funding_usdt")
opt_trade_disp = opt.get("trading_usdt")
if opt.get("ok"):
if perpetual_funding_usdt is None and perpetual_trading_usdt is None:
# 永续账户未取到时,期权 snapshot 已含同账户 USDT+USDC,直接用全量
funding = opt.get("funding_usdt")
trading = opt.get("trading_usdt")
else:
funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdc_equiv"))
trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdc_equiv"))
else:
funding = _safe_float(perpetual_funding_usdt)
trading = _safe_float(perpetual_trading_usdt)
total = _account_total_usdt(funding, trading) total = _account_total_usdt(funding, trading)
# 任一侧齐全即可展示;永续缺一侧但有期权 USDC 时仍尽量给出合计
if total is None:
total = _sum_optional(funding, trading)
perp_total = _account_total_usdt(perpetual_funding_usdt, perpetual_trading_usdt) perp_total = _account_total_usdt(perpetual_funding_usdt, perpetual_trading_usdt)
opt_total = _account_total_usdt(opt.get("funding_usdt"), opt.get("trading_usdt")) opt_total = _account_total_usdt(opt.get("funding_usdt"), opt.get("trading_usdt"))
data_ok = total is not None data_ok = total is not None
return { return {
"perpetual_funding_usdt": _safe_float(perpetual_funding_usdt), "perpetual_funding_usdt": _safe_float(perpetual_funding_usdt),
"perpetual_trading_usdt": _safe_float(perpetual_trading_usdt), "perpetual_trading_usdt": _safe_float(perpetual_trading_usdt),
"options_funding_usdt": opt.get("funding_usdt"), "options_funding_usdt": opt_fund_disp,
"options_trading_usdt": opt.get("trading_usdt"), "options_trading_usdt": opt_trade_disp,
"options_funding_full_usdt": opt.get("funding_usdt"),
"options_trading_full_usdt": opt.get("trading_usdt"),
"options_ok": bool(opt.get("ok")), "options_ok": bool(opt.get("ok")),
"funding_usdt": funding, "funding_usdt": funding,
"trading_usdt": trading, "trading_usdt": trading,
@@ -103,6 +226,33 @@ def merge_perp_options_balances(
} }
def repair_double_counted_fund_entry(ac: dict[str, Any]) -> dict[str, Any]:
"""识别并修复历史快照中「永续 USDT + 期权(USDT+USDC)」的双计.
旧口径 options_* 存的是 USDT+USDC 全量, funding2×期权资金户 USDT 部分.
新口径 options_* 多为纯 USDC,不会误伤.
"""
if not isinstance(ac, dict):
return {}
out = dict(ac)
ofu = _safe_float(ac.get("options_funding_usdt"))
otu = _safe_float(ac.get("options_trading_usdt"))
fu = _safe_float(ac.get("funding_usdt"))
tu = _safe_float(ac.get("trading_usdt"))
if ofu is None or otu is None or fu is None or tu is None:
return out
if ofu < 1.0:
return out
ratio = fu / ofu if ofu > 0 else 0.0
# 经典双计:合并资金户 ≈ 2 × 期权资金户(同钱包 USDT 加了两遍)
if 1.8 <= ratio <= 2.25:
out["funding_usdt"] = ofu
out["trading_usdt"] = otu
out["total_usdt"] = round(ofu + otu, 4)
out["repaired_double_count"] = True
return out
def merge_board_row_balances(row: dict[str, Any]) -> dict[str, Any]: def merge_board_row_balances(row: dict[str, Any]) -> dict[str, Any]:
"""监控板行 → 含期权的资金统计.""" """监控板行 → 含期权的资金统计."""
caps = row.get("capabilities") or [] caps = row.get("capabilities") or []
+67 -21
View File
@@ -63,16 +63,51 @@ def _parse_base_common(
}, None }, None
def _move_for_perp_correct(*, spot: float, target: float, premium: float, fee_rate: float) -> float: def _move_for_perp_correct(
"""净利 = move premium fee(move) = target → 解 move. *,
spot: float,
target: float,
premium: float,
fee_rate: float,
perp_coins: float = 1.0,
) -> float:
"""净利 = qty*move premium fee(move,qty) = target → 解 move.
fee = (2*spot + move) * fee_rate fee = (2*spot + move) * qty * fee_rate
move*(1-fee_rate) = target + premium + 2*spot*fee_rate qty*move*(1-fee_rate) = target + premium + 2*spot*qty*fee_rate
""" """
denom = 1.0 - float(fee_rate) qty = float(perp_coins)
if qty <= 0:
return 0.0
denom = qty * (1.0 - float(fee_rate))
if denom <= 0: if denom <= 0:
return 0.0 return 0.0
return (float(target) + float(premium) + 2.0 * float(spot) * float(fee_rate)) / denom return (float(target) + float(premium) + 2.0 * float(spot) * qty * float(fee_rate)) / denom
def _case_sideways(
*,
spot: float,
premium_total: float,
perp_coins: float = 1.0,
) -> dict[str, Any]:
"""横盘/到期无方向:永续≈0,期权权利金全亏,另计永续开平同价手续费.
最大亏损(正数) = 权利金总额 + 开平手续费(exit=entry)
组合净利 = 最大亏损
"""
qty = float(perp_coins) if float(perp_coins) > 0 else PERP_COINS
fee_flat = estimate_roundtrip_fee_usdt(spot, spot, qty=qty, contract_size=1.0)
prem = float(premium_total)
max_loss = prem + float(fee_flat)
return {
"label": "横盘",
"perp_pnl_u": 0.0,
"premium_u": round(prem, 8),
"fee_u": round(float(fee_flat), 8),
"max_loss_u": round(max_loss, 8),
"net_u": round(-max_loss, 8),
}
def calc_perp_options_hedge( def calc_perp_options_hedge(
@@ -186,6 +221,7 @@ def calc_perp_options_hedge(
"perp_pnl_u": round(perp_loss, 8), "perp_pnl_u": round(perp_loss, 8),
"portfolio_net_u": round(portfolio_net, 8), "portfolio_net_u": round(portfolio_net, 8),
}, },
"case_sideways": _case_sideways(spot=s, premium_total=premium_total),
}, None }, None
@@ -201,12 +237,12 @@ def calc_perp_options_points(
ratio_opt: float = 2.0, ratio_opt: float = 2.0,
ct_mult: float = DEFAULT_CT_MULT, ct_mult: float = DEFAULT_CT_MULT,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]: ) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""按永续:期权比例 + 目标盈利,反推两套情景所需波动点数. """按永续/期权币数 + 目标盈利,反推两套情景所需波动点数.
永续币数固定为 ratio 归一后的 1 币侧(perp_coins = PERP_COINS). 永续币数 = ratio_perp, 期权币数 = ratio_opt(按绝对币数,不再归一到 1 ).
期权币数 = PERP_COINS * (ratio_opt / ratio_perp), 1:2 2 . 2:4 永续 2 + 期权 4 ;1:2 永续 1 + 期权 2 .
A 永续方向对: move premium fee(move) = 目标盈利 A 永续方向对: qty*move premium fee(move,qty) = 目标盈利
B 期权方向对: B 期权方向对:
- 期权净利达目标: opt_coins*move premium = 目标 - 期权净利达目标: opt_coins*move premium = 目标
- 组合净利达目标: move*(opt_coins perp_coins) premium = 目标 - 组合净利达目标: move*(opt_coins perp_coins) premium = 目标
@@ -226,7 +262,7 @@ def calc_perp_options_points(
rp = _f(ratio_perp) rp = _f(ratio_perp)
ro = _f(ratio_opt) ro = _f(ratio_opt)
if rp is None or ro is None or rp <= 0 or ro <= 0: if rp is None or ro is None or rp <= 0 or ro <= 0:
return None, "永续:期权比例须大于 0" return None, "永续/期权币数须大于 0"
s = common["spot"] s = common["spot"]
capital = common["capital"] capital = common["capital"]
@@ -235,28 +271,35 @@ def calc_perp_options_points(
o_lev = common["o_lev"] o_lev = common["o_lev"]
ct = common["ct"] ct = common["ct"]
prem_per_coin = common["prem_per_coin"] prem_per_coin = common["prem_per_coin"]
margin = common["margin"]
fee_rate = common["fee_rate"] fee_rate = common["fee_rate"]
b = (base or "ETH").strip().upper() b = (base or "ETH").strip().upper()
opt_coins = PERP_COINS * (ro / rp) perp_coins = rp
opt_coins = ro
premium_total = opt_coins * prem_per_coin premium_total = opt_coins * prem_per_coin
opt_sheets = opt_coins / ct opt_sheets = opt_coins / ct
margin = (s * perp_coins) / p_lev
move_a = _move_for_perp_correct(spot=s, target=target, premium=premium_total, fee_rate=fee_rate) move_a = _move_for_perp_correct(
spot=s,
target=target,
premium=premium_total,
fee_rate=fee_rate,
perp_coins=perp_coins,
)
if move_a <= 0: if move_a <= 0:
return None, "无法解出永续方向对所需点数" return None, "无法解出永续方向对所需点数"
fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=PERP_COINS, contract_size=1.0) fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=perp_coins, contract_size=1.0)
net_a = move_a * PERP_COINS - premium_total - fee_a net_a = move_a * perp_coins - premium_total - fee_a
# 期权净利 = 目标 # 期权净利 = 目标
move_b_opt = (target + premium_total) / opt_coins move_b_opt = (target + premium_total) / opt_coins
opt_net_at_b_opt = opt_coins * move_b_opt - premium_total opt_net_at_b_opt = opt_coins * move_b_opt - premium_total
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * PERP_COINS portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * perp_coins
# 组合净利 = 目标 # 组合净利 = 目标
edge = opt_coins - PERP_COINS edge = opt_coins - perp_coins
if edge <= 0: if edge <= 0:
move_b_port = None move_b_port = None
port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利" port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利"
@@ -265,7 +308,7 @@ def calc_perp_options_points(
port_err = None port_err = None
if move_b_port is not None: if move_b_port is not None:
opt_net_at_b_port = opt_coins * move_b_port - premium_total opt_net_at_b_port = opt_coins * move_b_port - premium_total
portfolio_at_b_port = opt_net_at_b_port - move_b_port * PERP_COINS portfolio_at_b_port = opt_net_at_b_port - move_b_port * perp_coins
else: else:
opt_net_at_b_port = None opt_net_at_b_port = None
portfolio_at_b_port = None portfolio_at_b_port = None
@@ -279,7 +322,7 @@ def calc_perp_options_points(
"ratio_perp": round(rp, 8), "ratio_perp": round(rp, 8),
"ratio_opt": round(ro, 8), "ratio_opt": round(ro, 8),
"ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}", "ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}",
"perp_coins": PERP_COINS, "perp_coins": round(perp_coins, 8),
"opt_coins": round(opt_coins, 8), "opt_coins": round(opt_coins, 8),
"opt_sheets": round(opt_sheets, 8), "opt_sheets": round(opt_sheets, 8),
"perp_leverage": round(p_lev, 8), "perp_leverage": round(p_lev, 8),
@@ -294,7 +337,7 @@ def calc_perp_options_points(
"label": "永续方向对", "label": "永续方向对",
"move_points": round(move_a, 8), "move_points": round(move_a, 8),
"move_pct": round(move_a / s * 100.0, 8), "move_pct": round(move_a / s * 100.0, 8),
"perp_pnl_u": round(move_a * PERP_COINS, 8), "perp_pnl_u": round(move_a * perp_coins, 8),
"premium_u": round(premium_total, 8), "premium_u": round(premium_total, 8),
"fee_u": round(fee_a, 8), "fee_u": round(fee_a, 8),
"net_u": round(net_a, 8), "net_u": round(net_a, 8),
@@ -316,6 +359,9 @@ def calc_perp_options_points(
"portfolio_error": port_err, "portfolio_error": port_err,
"premium_u": round(premium_total, 8), "premium_u": round(premium_total, 8),
}, },
"case_sideways": _case_sideways(
spot=s, premium_total=premium_total, perp_coins=perp_coins
),
}, None }, None
+45
View File
@@ -0,0 +1,45 @@
"""中控后台轮询等待:防止 request_refresh 连锁打满 CPU."""
from __future__ import annotations
import asyncio
import time
async def wait_poll_interval(
*,
refresh: asyncio.Event,
stop: asyncio.Event,
interval_sec: float,
started_at: float,
min_early_wake_sec: float | None = None,
) -> None:
"""距 started_at 至少间隔 interval_sec 再进入下一轮.
期间若收到 refresh:仅当已过 min_early_wake_sec 才提前结束(兼顾手动刷新与防抖).
"""
interval = max(0.05, float(interval_sec))
min_early = (
float(min_early_wake_sec)
if min_early_wake_sec is not None
else min(2.0, interval * 0.4)
)
while not stop.is_set():
left = interval - (time.monotonic() - started_at)
if left <= 0:
return
refresh.clear()
stop_task = asyncio.create_task(stop.wait())
refresh_task = asyncio.create_task(refresh.wait())
done, pending = await asyncio.wait(
{stop_task, refresh_task},
timeout=left,
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
if stop.is_set():
return
if not done:
return
if refresh_task in done and (time.monotonic() - started_at) >= min_early:
return
+40
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import math import math
import re
from typing import Any, Callable from typing import Any, Callable
@@ -23,6 +24,45 @@ def _coerce_float(*values: Any) -> float | None:
return None return None
# OKX ccxt: ETH/USD:USD-260806-1875-C ; instId: ETH-USD-260806-1875-C
_OPTION_SYM_RE = re.compile(
r"(?:^|[/:])[A-Z0-9]+(?:-USD)?(?::USD)?-\d{6}-\d+-(?:C|P|CALL|PUT)$",
re.IGNORECASE,
)
def is_option_like_position(pos: dict[str, Any] | None) -> bool:
"""识别期权仓(子代理/中控浮盈合计须排除,避免按永续线性公式误算)."""
if not isinstance(pos, dict):
return False
info = pos.get("info") if isinstance(pos.get("info"), dict) else {}
inst_type = str(
info.get("instType")
or info.get("inst_type")
or pos.get("type")
or ""
).upper()
if inst_type in ("OPTION", "OPT"):
return True
sym = str(
pos.get("symbol")
or info.get("instId")
or info.get("instrument_name")
or info.get("contract")
or ""
).strip()
if not sym:
return False
if _OPTION_SYM_RE.search(sym.replace(" ", "")):
return True
su = sym.upper()
if su.endswith("-C") or su.endswith("-P") or su.endswith("-CALL") or su.endswith("-PUT"):
# 永续多为 BTC/USDT:USDT;期权常带到期日段
if re.search(r"-\d{6}-\d+-(?:C|P|CALL|PUT)$", su):
return True
return False
CONTRACTS_QTY_DECIMALS = 2 CONTRACTS_QTY_DECIMALS = 2
+156 -26
View File
@@ -121,20 +121,40 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
return default return default
def _format_profit_exit_mult(mult: Any) -> str:
try:
n = float(mult)
except (TypeError, ValueError):
return "1倍"
if n <= 0:
return "1倍"
if abs(n - round(n)) < 1e-9:
return f"{int(round(n))}"
return f"{n:g}"
def _format_options_target(p: dict[str, Any]) -> str: def _format_options_target(p: dict[str, Any]) -> str:
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
if hedge: if hedge:
rr = _safe_float(hedge.get("profit_rr"))
pid = hedge.get("plan_id")
if rr is not None and rr > 0:
return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}"
ot = str(hedge.get("opt_type") or opt_type).upper() ot = str(hedge.get("opt_type") or opt_type).upper()
side = "Put ≤" if ot == "P" else "Call ≥" side = "Put ≤" if ot == "P" else "Call ≥"
tgt = _safe_float(hedge.get("target_index")) tgt = _safe_float(hedge.get("target_index"))
pid = hedge.get("plan_id")
if tgt is not None: if tgt is not None:
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
parts: list[str] = []
tgt = _safe_float(p.get("target_index")) tgt = _safe_float(p.get("target_index"))
if tgt is not None and tgt > 0: if tgt is not None and tgt > 0:
side = "Put ≤" if opt_type == "P" else "Call ≥" side = "Put ≤" if opt_type == "P" else "Call ≥"
return f"{side} {tgt:g}" parts.append(f"{side} {tgt:g}")
if p.get("profit_exit_enabled"):
parts.append(_format_profit_exit_mult(p.get("profit_exit_mult")))
if parts:
return " · ".join(parts)
return "" return ""
@@ -142,12 +162,12 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
# 看板期权列固定用净盈亏(买一回收−权利金);残档买一则空. # 看板期权列:优先买一净盈亏,残档回退交易所 upl
pnl = None pnl = None
try: try:
from lib.options.options_positions_lib import net_pnl_from_display_row from lib.options.options_positions_lib import display_pnl_from_option_row
pnl = net_pnl_from_display_row(p) pnl = display_pnl_from_option_row(p)
except Exception: except Exception:
pnl = None pnl = None
pos = _safe_float(p.get("pos")) pos = _safe_float(p.get("pos"))
@@ -350,42 +370,151 @@ def collect_options_items(
raw = fetch_options_positions() or [] raw = fetch_options_positions() or []
except Exception: except Exception:
return [] return []
pe_map: dict[str, dict[str, Any]] = {}
tgt_map: dict[str, dict[str, Any]] = {}
hedge_map: dict[str, dict[str, Any]] = {}
if conn is not None:
try:
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import targets_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
pe_map = profit_exit_by_inst(conn)
tgt_map = targets_by_inst(conn)
hedge_map = active_options_targets_by_inst(conn)
except Exception:
pe_map, tgt_map, hedge_map = {}, {}, {}
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for p in raw: for p in raw:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
out.append(_format_options_item(p, conn=conn)) row = dict(p)
inst = str(row.get("inst_id") or row.get("instId") or "").strip()
mon = tgt_map.get(inst)
if mon:
row["target_index"] = mon.get("target_index")
pe = pe_map.get(inst)
if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult")
hedge = hedge_map.get(inst)
if hedge:
row["hedge_plan_target"] = hedge
if not mon:
row["target_index"] = hedge.get("target_index")
out.append(_format_options_item(row, conn=conn))
return out return out
def _swap_symbol_candidates(row: dict[str, Any]) -> list[str]:
"""优先永续 symbol(含 settle),避免用现货 BTC/USDT 查到 contractSize=1."""
raw: list[str] = []
for key in ("symbol", "exchange_symbol", "price_symbol"):
s = str(row.get(key) or "").strip()
if s and s not in raw:
raw.append(s)
swapish: list[str] = []
others: list[str] = []
for s in raw:
if ":" in s:
swapish.append(s)
continue
others.append(s)
if "/" in s:
base, quote = s.split("/", 1)
q = quote.split(":")[0].strip()
if base and q:
swapish.append(f"{base}/{q}:{q}")
out: list[str] = []
for s in swapish + others:
if s and s not in out:
out.append(s)
return out
def _resolve_contract_size(
row_or_sym: Any,
*,
get_contract_size: Optional[Callable[[str], Any]] = None,
) -> float:
if not callable(get_contract_size):
return 1.0
if isinstance(row_or_sym, dict):
candidates = _swap_symbol_candidates(row_or_sym)
else:
sym = str(row_or_sym or "").strip()
candidates = _swap_symbol_candidates({"symbol": sym}) if sym else []
for sym in candidates:
try:
cs = float(get_contract_size(sym) or 0)
if cs > 0:
return cs
except Exception:
continue
return 1.0
def _fill_order_pnl_fields(row: dict[str, Any], *, mark: Optional[float], contract_size: float) -> None:
"""按线性 U 本位补看板「盈利金额 / 浮盈」."""
direction = str(row.get("direction") or "long").lower()
entry = _safe_float(row.get("entry"))
contracts = _safe_float(row.get("contracts"))
tp = _safe_float(row.get("take_profit"))
if entry is None or contracts is None or contracts <= 0:
return
cs = float(contract_size) if contract_size and contract_size > 0 else 1.0
if mark is not None:
try:
from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt
upnl = estimate_linear_swap_upnl_usdt(direction, entry, mark, contracts, cs)
if upnl is not None:
row["float_pnl"] = upnl
except Exception:
pass
if tp is not None and tp > 0:
try:
from lib.strategy.strategy_trend_lib import calc_tp_profit_usdt
profit = calc_tp_profit_usdt(direction, entry, tp, contracts, cs)
if profit is not None:
row["tp_profit"] = round(float(profit), 2)
except Exception:
pass
def enrich_order_items_with_marks( def enrich_order_items_with_marks(
items: list[dict[str, Any]], items: list[dict[str, Any]],
*, *,
get_price: Optional[Callable[[str], Any]] = None, get_price: Optional[Callable[[str], Any]] = None,
get_contract_size: Optional[Callable[[str], Any]] = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""后台聚合时补标记价(不打全量 fetch_positions;浮盈仍由实盘页口径负责).""" """后台聚合时补标记价,并按张数×合约面值估算盈利金额/浮盈."""
if not items or not callable(get_price): if not items:
return items
if not callable(get_price) and not callable(get_contract_size):
return items return items
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for it in items: for it in items:
row = dict(it) row = dict(it)
sym = str(row.get("price_symbol") or row.get("symbol") or "").strip() # 标记价:先试 price_symbol,再试永续候选
if not sym: mark = _safe_float(row.get("mark_price"))
out.append(row) if callable(get_price):
continue ordered: list[str] = []
try: for s in [str(row.get("price_symbol") or "").strip()] + _swap_symbol_candidates(row):
px = get_price(sym) if s and s not in ordered:
except Exception: ordered.append(s)
px = None for sym in ordered:
mark = _safe_float(px) try:
if mark is None and ":" in sym: px = get_price(sym)
try: except Exception:
px = get_price(sym.split(":", 1)[0]) px = None
except Exception: mark = _safe_float(px)
px = None if mark is not None:
mark = _safe_float(px) row["mark_price"] = mark
if mark is not None: break
row["mark_price"] = mark cs = _resolve_contract_size(row, get_contract_size=get_contract_size)
_fill_order_pnl_fields(row, mark=mark, contract_size=cs)
out.append(row) out.append(row)
return out return out
@@ -402,7 +531,8 @@ def build_instance_dashboard_payload(
rolls = collect_rolls(conn) rolls = collect_rolls(conn)
strategy_items = trends + rolls strategy_items = trends + rolls
options_items = collect_options_items(fetch_options_positions, conn=conn) options_items = collect_options_items(fetch_options_positions, conn=conn)
hedge_items = collect_hedge_plans(conn) if hedge_enabled else [] hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关
# hedge_enabled 仅影响「新建」入口,不隐藏已有仓
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
return { return {
"ok": True, "ok": True,
+10 -2
View File
@@ -12,19 +12,27 @@ def register_instance_dashboard_routes(
login_required: Callable, login_required: Callable,
get_db: Callable, get_db: Callable,
fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None, fetch_options_positions: Optional[Callable[[], list[dict[str, Any]]]] = None,
hedge_enabled: bool = False, hedge_enabled: bool | Callable[[], bool] = False,
enrich_orders: Optional[Callable[[list[dict[str, Any]]], list[dict[str, Any]]]] = None, enrich_orders: Optional[Callable[[list[dict[str, Any]]], list[dict[str, Any]]]] = None,
) -> None: ) -> None:
from lib.instance.instance_dashboard_cache import instance_dashboard_store from lib.instance.instance_dashboard_cache import instance_dashboard_store
from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload from lib.instance.instance_dashboard_lib import build_instance_dashboard_payload
def _hedge_on() -> bool:
if callable(hedge_enabled):
try:
return bool(hedge_enabled())
except Exception:
return False
return bool(hedge_enabled)
def _build() -> dict[str, Any]: def _build() -> dict[str, Any]:
conn = get_db() conn = get_db()
try: try:
payload = build_instance_dashboard_payload( payload = build_instance_dashboard_payload(
conn, conn,
fetch_options_positions=fetch_options_positions, fetch_options_positions=fetch_options_positions,
hedge_enabled=bool(hedge_enabled), hedge_enabled=_hedge_on(),
) )
if callable(enrich_orders) and payload.get("ok") and isinstance(payload.get("orders"), dict): if callable(enrich_orders) and payload.get("ok") and isinstance(payload.get("orders"), dict):
items = list(payload["orders"].get("items") or []) items = list(payload["orders"].get("items") or [])
@@ -9,6 +9,7 @@ DISPLAY_RUNTIME_PREFIX = "display."
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_dashboard": False, "show_nav_dashboard": False,
"show_nav_account_ledger": False,
"show_nav_key_monitor": True, "show_nav_key_monitor": True,
"show_nav_trade": True, "show_nav_trade": True,
"show_nav_strategy": True, "show_nav_strategy": True,
@@ -30,6 +31,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
DISPLAY_LABELS: dict[str, str] = { DISPLAY_LABELS: dict[str, str] = {
"show_nav_dashboard": "数据看板", "show_nav_dashboard": "数据看板",
"show_nav_account_ledger": "账户流水",
"show_nav_key_monitor": "关键位监控", "show_nav_key_monitor": "关键位监控",
"show_nav_trade": "实盘下单", "show_nav_trade": "实盘下单",
"show_nav_strategy": "策略交易", "show_nav_strategy": "策略交易",
@@ -51,6 +53,7 @@ DISPLAY_LABELS: dict[str, str] = {
NAV_TAB_ALLOWED: dict[str, str] = { NAV_TAB_ALLOWED: dict[str, str] = {
"dashboard": "show_nav_dashboard", "dashboard": "show_nav_dashboard",
"account_ledger": "show_nav_account_ledger",
"key_monitor": "show_nav_key_monitor", "key_monitor": "show_nav_key_monitor",
"trade": "show_nav_trade", "trade": "show_nav_trade",
"strategy": "show_nav_strategy", "strategy": "show_nav_strategy",
@@ -116,6 +119,7 @@ def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
def display_meta_for_ui() -> list[dict[str, Any]]: def display_meta_for_ui() -> list[dict[str, Any]]:
nav_keys = [ nav_keys = [
"show_nav_dashboard", "show_nav_dashboard",
"show_nav_account_ledger",
"show_nav_key_monitor", "show_nav_key_monitor",
"show_nav_trade", "show_nav_trade",
"show_nav_strategy", "show_nav_strategy",
+79 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -10,6 +11,20 @@ EMBED_STRATEGY_PAGES = frozenset({"strategy", "strategy_trend", "strategy_roll",
_WIN_EPS = 1e-9 _WIN_EPS = 1e-9
def env_truthy(raw: str | None, default: bool = False) -> bool:
if raw is None or str(raw).strip() == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def show_perp_funds_enabled(*, exchange_key: str | None = None) -> bool:
"""OKX:是否在顶栏显示永续资金账户/交易账户.其他所恒为 True."""
ex = (exchange_key or "").strip().lower()
if ex and ex != "okx":
return True
return env_truthy(os.getenv("OKX_SHOW_PERP_FUNDS"), default=True)
@dataclass(frozen=True) @dataclass(frozen=True)
class EmbedRenderPlan: class EmbedRenderPlan:
exchange_capitals: bool exchange_capitals: bool
@@ -88,13 +103,72 @@ def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float
def options_funding_label( def options_funding_label(
funding_usdc: float | None, funding_usdc: float | None,
funding_usdt: float | None = None, funding_usdt: float | None = None,
funding_eth: float | None = None,
margin_mode: str | None = None,
underly: str = "ETH",
) -> str: ) -> str:
"""期权侧顶栏文案(仅 USDC 模式使用;币本位不展示期权资金/交易两列)."""
if funding_usdc is None:
return ""
try:
return f"{float(funding_usdc):.2f} USDC"
except (TypeError, ValueError):
return ""
def _fmt_coin_amount(v: float | None, *, min_amt: float = 1e-6) -> str | None:
if v is None:
return None
try:
n = float(v)
except Exception:
# Jinja Undefined 等也吞掉,避免顶栏 float(Undefined) → HTTP 500
return None
if n < min_amt:
return None
txt = f"{n:.6f}".rstrip("0").rstrip(".")
return txt or None
def trading_account_label(
usdt: float | None,
eth: float | None = None,
btc: float | None = None,
*,
margin_mode: str | None = None,
) -> str:
"""交易账户顶栏文案.
币本位:USDT / ETH / BTC 多行(有余额才带上,不显示其它币种).
其它模式:xx.xxU.
"""
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
mode = normalize_options_margin_mode(margin_mode)
except Exception:
mode = str(margin_mode or "coin").strip().lower() or "coin"
if mode != "coin":
if usdt is None:
return ""
try:
return f"{float(usdt):.2f}U"
except (TypeError, ValueError):
return ""
parts: list[str] = [] parts: list[str] = []
if funding_usdc is not None and float(funding_usdc) > 0: if usdt is not None:
parts.append(f"{float(funding_usdc):.2f} USDC") try:
if funding_usdt is not None and float(funding_usdt) > 0: parts.append(f"{float(usdt):.2f} USDT")
parts.append(f"{float(funding_usdt):.2f} USDT") except (TypeError, ValueError):
return " · ".join(parts) if parts else "" pass
eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
if eth_txt is not None:
parts.append(f"{eth_txt} ETH")
btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
if btc_txt is not None:
parts.append(f"{btc_txt} BTC")
# 顶栏多行:USDT / ETH 各占一行
return "\n".join(parts) if parts else ""
def total_funds_usdt( def total_funds_usdt(
+20 -3
View File
@@ -7,11 +7,12 @@ import os
from typing import Callable from typing import Callable
from urllib.parse import parse_qsl, urlencode, urlsplit from urllib.parse import parse_qsl, urlencode, urlsplit
from flask import Flask, Response, jsonify, redirect, request, session from flask import Flask, Response, jsonify, make_response, redirect, request, session
from jinja2 import ChoiceLoader, FileSystemLoader from jinja2 import ChoiceLoader, FileSystemLoader
EMBED_TABS: tuple[str, ...] = ( EMBED_TABS: tuple[str, ...] = (
"dashboard", "dashboard",
"account_ledger",
"key_monitor", "key_monitor",
"trade", "trade",
"strategy", "strategy",
@@ -31,6 +32,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "trade", "/": "trade",
"/trade": "trade", "/trade": "trade",
"/dashboard": "dashboard", "/dashboard": "dashboard",
"/account_ledger": "account_ledger",
"/key_monitor": "key_monitor", "/key_monitor": "key_monitor",
"/strategy": "strategy", "/strategy": "strategy",
"/strategy/trend": "strategy", "/strategy/trend": "strategy",
@@ -184,7 +186,10 @@ def register_embed_routes(
if tab not in EMBED_TABS: if tab not in EMBED_TABS:
tab = "trade" tab = "trade"
session["hub_embed_shell"] = True session["hub_embed_shell"] = True
return render_main_page_fn(tab, embed_mode="shell") resp = make_response(render_main_page_fn(tab, embed_mode="shell"))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
@login_required @login_required
@app.route("/api/embed/page/<tab>") @app.route("/api/embed/page/<tab>")
@@ -198,7 +203,10 @@ def register_embed_routes(
html = render_main_page_fn(tab, embed_mode="fragment") html = render_main_page_fn(tab, embed_mode="fragment")
if isinstance(html, Response): if isinstance(html, Response):
html = html.get_data(as_text=True) html = html.get_data(as_text=True)
return jsonify({"ok": True, "page": tab, "html": html}) resp = jsonify({"ok": True, "page": tab, "html": html})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
def pwa_app_name(exchange_key: str) -> str: def pwa_app_name(exchange_key: str) -> str:
@@ -212,10 +220,19 @@ def pwa_app_name(exchange_key: str) -> str:
def embed_context_extras(exchange_key: str) -> dict: def embed_context_extras(exchange_key: str) -> dict:
# 顶栏共享模板调用 trading_account_label / options_funding_label;
# 须注入三所,否则 Gate/Binance 渲染会 UndefinedError → HTTP 500.
from lib.instance.instance_embed_context_lib import (
options_funding_label,
trading_account_label,
)
return { return {
"order_rule_tips_tpl": order_rule_tips_template(exchange_key), "order_rule_tips_tpl": order_rule_tips_template(exchange_key),
"include_transfer_block": include_transfer_block(exchange_key), "include_transfer_block": include_transfer_block(exchange_key),
"ui_open_guard_enabled": ui_open_guard_enabled(exchange_key), "ui_open_guard_enabled": ui_open_guard_enabled(exchange_key),
"ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key), "ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key),
"pwa_app_name": pwa_app_name(exchange_key), "pwa_app_name": pwa_app_name(exchange_key),
"options_funding_label": options_funding_label,
"trading_account_label": trading_account_label,
} }
+9 -11
View File
@@ -152,30 +152,29 @@ def build_instance_settings_view(
"title": "整点强制清仓", "title": "整点强制清仓",
"rows": [ "rows": [
_row("强制清仓", _on_off(force_close_on)), _row("强制清仓", _on_off(force_close_on)),
_row("执行时刻", f"北京时间 {force_close_hour}:00 起 15 分钟内"), _row(
"执行时刻",
f"北京时间 {force_close_hour}:00 起 {_env_int('FORCE_CLOSE_GRACE_MINUTES', 5)} 分钟内",
),
], ],
} }
) )
if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False): if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False):
opt_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip() api_key = (os.getenv("OKX_API_KEY") or "").strip()
sections.append( sections.append(
{ {
"title": "期权设置", "title": "期权设置",
"rows": [ "rows": [
_row("期权模块", "已启用"), _row("期权模块", "已启用"),
_row( _row(
"期权 API", "账户 API",
f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置", f"已配置(…{api_key[-4:]})" if len(api_key) >= 4 else "未配置 OKX_API_*",
), "永续与期权共用 OKX_API_*",
_row(
"子账户",
(os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip() or "未配置 OKX_SUB_ACCOUNT_NAME",
"主/子账户划转用",
), ),
_row( _row(
"说明", "说明",
"币种兑换与账户划转到右侧「期权设置」卡片操作", "币种兑换与账户划转到右侧「期权设置」卡片操作",
), ),
], ],
} }
@@ -196,7 +195,6 @@ def build_instance_settings_view(
"show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"), "show_transfer": (exchange_key or "").strip().lower() in ("gate", "binance", "okx"),
"options_settings_enabled": (exchange_key or "").strip().lower() == "okx" "options_settings_enabled": (exchange_key or "").strip().lower() == "okx"
and _env_bool("OKX_OPTIONS_ENABLED", False), and _env_bool("OKX_OPTIONS_ENABLED", False),
"options_sub_account": (os.getenv("OKX_SUB_ACCOUNT_NAME") or "").strip(),
"auto_transfer_enabled": auto_transfer_on, "auto_transfer_enabled": auto_transfer_on,
"auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8), "auto_transfer_bj_hour": _env_int("AUTO_TRANSFER_BJ_HOUR", 8),
"auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30), "auto_transfer_amount": _env_float("AUTO_TRANSFER_AMOUNT", 30),
@@ -106,6 +106,24 @@ def register_instance_settings_routes(
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path) clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
if not clean: if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
if "OKX_OPTIONS_MARGIN_MODE" in clean:
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
lines = read_env_lines(env_path)
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
if old_mode != new_mode:
conn_m = get_db()
try:
block = mode_switch_block_msg(conn_m, None)
if block:
return jsonify({"ok": False, "msg": block}), 400
finally:
conn_m.close()
except Exception as e:
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
changed = apply_env_updates(env_path, clean) changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path) groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups) reload_info = apply_env_reload(env_path, get_db, changed, groups)
@@ -10,7 +10,7 @@
<div class="display-prefs-checks"> <div class="display-prefs-checks">
{% for item in group.entries %} {% for item in group.entries %}
<label class="chk-label"> <label class="chk-label">
<input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}> <input type="checkbox" data-pref-key="{{ item.key }}"{% if item.key in ('show_nav_dashboard', 'show_nav_account_ledger', 'show_nav_system_guide') %}{% if display.get(item.key) %} checked{% endif %}{% elif display.get(item.key, true) %} checked{% endif %}>
{{ item.label }} {{ item.label }}
</label> </label>
{% endfor %} {% endfor %}
+180 -35
View File
@@ -741,13 +741,10 @@ function paintExchangeTpslRow(orderId, tpsl){
const tpText = document.getElementById(`ex-tp-text-${orderId}`); const tpText = document.getElementById(`ex-tp-text-${orderId}`);
const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`); const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`);
const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`); const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`);
const intraday = (document.body && document.body.getAttribute("data-intraday-discipline")) === "1";
if(slText) slText.innerText = formatExTpslLine('sl', data.sl); if(slText) slText.innerText = formatExTpslLine('sl', data.sl);
if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp); if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp);
if(!intraday){ if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id);
if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id); if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
}
} }
function toggleTpslModalMode(){ function toggleTpslModalMode(){
const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price'; const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price';
@@ -1078,7 +1075,7 @@ function refreshOrderDefaults(){
}).catch(()=>{}); }).catch(()=>{});
} }
function paintRealtimePnl(v){ function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]'); const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return; if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){ if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1089,23 +1086,48 @@ function paintRealtimePnl(v){
return; return;
} }
const n = Number(v); const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : ""; const sign = n > 0 ? "+" : "";
const text = `${sign}${n.toFixed(2)}U`; let text;
let tone = n;
if (u === "ETH" || u === "BTC") {
// 顶栏实时盈亏只显示 U(按指数/现货换算),不展示币数量
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
tone = uu;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${uSign}${uAbs}U`;
} else {
text = "—";
tone = 0;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
nodes.forEach((pnlEl) => { nodes.forEach((pnlEl) => {
pnlEl.innerText = text; pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", n > 0); pnlEl.classList.toggle("pnl-pos", tone > 0);
pnlEl.classList.toggle("pnl-neg", n < 0); pnlEl.classList.toggle("pnl-neg", tone < 0);
}); });
} }
let lastRealtimePnl = null; let lastRealtimePnl = null;
function updateRealtimePnl(v){ let lastRealtimePnlUnit = "U";
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){ if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v); lastRealtimePnl = Number(v);
paintRealtimePnl(v); if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return; return;
} }
if(lastRealtimePnl != null) return; if(lastRealtimePnl != null) return;
paintRealtimePnl(v); paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
} }
function sumOrdersFloatPnl(orders){ function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null; if(!orders || !orders.length) return null;
@@ -1133,17 +1155,75 @@ function paintRealtimePnlFromSnapshot(data){
const perp = data.order_prices && data.order_prices.length const perp = data.order_prices && data.order_prices.length
? sumOrdersFloatPnl(data.order_prices) ? sumOrdersFloatPnl(data.order_prices)
: null; : null;
const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl); const opt = data.options_unrealized_pnl;
if(combined !== null || perp !== null || data.options_unrealized_pnl != null){ const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
paintRealtimePnl(combined); const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) {
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0);
pnlEl.classList.toggle("pnl-neg", totalU < 0);
});
return;
}
paintRealtimePnl(opt, underly, spotPx);
return;
}
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) {
return;
}
}
const combined = combineRealtimeFloatPnl(perp, opt);
if(combined !== null || perp !== null || opt != null){
paintRealtimePnl(combined, "U");
} }
} }
function formatOptionsFundingLabel(usdc, usdt) { function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
if (usdc === null || usdc === undefined || usdc === "") return "—";
const n = Number(usdc);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`;
}
function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
// 缺省按 usdc(xx.xxU):Gate/Binance 快照无 options_margin_mode;OKX 会显式下发.
const mode = String(marginMode || "usdc").toLowerCase();
if (mode !== "coin") {
if (usdt === null || usdt === undefined || usdt === "") return "—";
const n = Number(usdt);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
const parts = []; const parts = [];
if (usdc !== null && usdc !== undefined && Number(usdc) > 0) parts.push(`${Number(usdc).toFixed(2)} USDC`); if (usdt !== null && usdt !== undefined && usdt !== "") {
if (usdt !== null && usdt !== undefined && Number(usdt) > 0) parts.push(`${Number(usdt).toFixed(2)} USDT`); const n = Number(usdt);
return parts.length ? parts.join(" · ") : "—"; if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
const pushCoin = (v, ccy) => {
if (v === null || v === undefined || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
pushCoin(eth, "ETH");
pushCoin(btc, "BTC");
return parts.length ? parts.join("\n") : "—";
} }
function setFundsFieldText(field, text){ function setFundsFieldText(field, text){
@@ -1152,8 +1232,28 @@ function setFundsFieldText(field, text){
el.innerText = text; el.innerText = text;
}); });
} }
function applyPerpFundsVisibility(show){
const on = show !== false;
document.querySelectorAll("[data-perp-funds='1']").forEach((el) => {
el.style.display = on ? "" : "none";
});
}
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){ function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true; if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){
const hasTotal = data.total_funds != null && data.total_funds !== "";
const hasOpt =
data.options_funding_usdc != null ||
data.options_funding_usdt != null ||
data.options_trading_usdc != null ||
data.options_trading_usdt != null;
return !hasTotal && !hasOpt;
}
const hasFunding = data.funding_usdt != null && data.funding_usdt !== ""; const hasFunding = data.funding_usdt != null && data.funding_usdt !== "";
const hasTotal = data.total_funds != null && data.total_funds !== ""; const hasTotal = data.total_funds != null && data.total_funds !== "";
const hasTrading = data.current_capital != null && data.current_capital !== ""; const hasTrading = data.current_capital != null && data.current_capital !== "";
@@ -1162,6 +1262,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0; let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if(typeof data.show_perp_funds !== "undefined"){
applyPerpFundsVisibility(data.show_perp_funds !== false || coinMode);
} else if (coinMode) {
applyPerpFundsVisibility(true);
}
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} }
@@ -1169,18 +1276,47 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} }
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`); setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
} }
if(data.options_funding_usdc != null || data.options_funding_usdt != null){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt); const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding); setFundsFieldText("options-funding-usdc", optFunding);
} }
if(data.options_trading_usdc != null || data.options_trading_usdt != null){ if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt); const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
updateRealtimePnl(data.unrealized_pnl); const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) {
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
} }
if(typeof data.total !== "undefined" && data.total !== null){ if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total)); setFundsFieldText("stat-total", String(data.total));
@@ -1214,11 +1350,15 @@ function applyAccountSnapshot(data){
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
TimeCloseUI.paintForceCloseHeader(data.force_close); TimeCloseUI.paintForceCloseHeader(data.force_close);
} }
if(window.OpenSubmitGate) OpenSubmitGate.apply(data);
let canTradeText = "可开仓"; let canTradeText = "可开仓";
if(!data.can_trade){ if(!data.can_trade){
const parts = []; const parts = [];
if(data.open_block_note) parts.push(data.open_block_note);
if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){ if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){
parts.push(data.risk_status.reason); if(!data.open_block_note || data.open_block_note.indexOf(data.risk_status.reason) < 0){
parts.push(data.risk_status.reason);
}
} }
const ac = Number(data.active_count || 0); const ac = Number(data.active_count || 0);
const max = Number(data.max_active_positions || {{ max_active_positions }}); const max = Number(data.max_active_positions || {{ max_active_positions }});
@@ -1226,9 +1366,8 @@ function applyAccountSnapshot(data){
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
const opens = Number(data.opens_today); const opens = Number(data.opens_today);
if(hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`); if(hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
if(!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`); if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`);
else parts.push(`或未到北京时间 {{ reset_hour }}:00`); canTradeText = parts.length ? `不可开仓(${parts.join(";")})` : "不可开仓";
canTradeText = `不可开仓(${parts.join(";")})`;
} }
const opensToday = Number(data.opens_today); const opensToday = Number(data.opens_today);
const hardLim = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); const hardLim = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
@@ -1279,6 +1418,12 @@ if(fullMarginEl){
} }
const sltpModeEl = document.getElementById("sltp-mode"); const sltpModeEl = document.getElementById("sltp-mode");
function setOmFieldVisible(inputEl, show){
if(!inputEl) return;
inputEl.style.display = show ? "" : "none";
const wrap = inputEl.closest(".om-field");
if(wrap) wrap.style.display = show ? "" : "none";
}
function toggleSltpMode(){ function toggleSltpMode(){
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
const slEl = document.getElementById("order-sl"); const slEl = document.getElementById("order-sl");
@@ -1289,14 +1434,14 @@ function toggleSltpMode(){
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
const pct = mode === "pct"; const pct = mode === "pct";
const fixed = mode === "fixed_rr"; const fixed = mode === "fixed_rr";
slEl.style.display = pct ? "none" : ""; setOmFieldVisible(slEl, !pct);
tpEl.style.display = (pct || fixed) ? "none" : ""; setOmFieldVisible(tpEl, !(pct || fixed));
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; setOmFieldVisible(fixedRrEl, fixed);
slEl.required = !pct; slEl.required = !pct;
tpEl.required = !pct && !fixed; tpEl.required = !pct && !fixed;
if(fixedRrEl) fixedRrEl.required = fixed; if(fixedRrEl) fixedRrEl.required = fixed;
slPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(slPctEl, pct);
tpPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(tpPctEl, pct);
slPctEl.required = pct; slPctEl.required = pct;
tpPctEl.required = pct; tpPctEl.required = pct;
refreshOrderTpPreview(); refreshOrderTpPreview();
+42 -51
View File
@@ -78,11 +78,49 @@
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}</div></div> <div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}</div></div>
</div> </div>
</details> </details>
{% if period_key == 'all' %}
<div class="inst-stats-block inst-stats-monthly" style="margin-top:14px">
<div class="inst-stats-block-title">按月统计</div>
{% if s.monthly_rows %}
<div class="inst-stats-month-table-wrap">
<table class="inst-stats-month-table">
<thead>
<tr>
<th>月份</th>
<th>开单</th>
<th>平仓</th>
<th>胜率</th>
<th>净盈亏</th>
<th>最大回撤</th>
</tr>
</thead>
<tbody>
{% for m in s.monthly_rows %}
{% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %}
<tr>
<td>{{ m.month_key }}</td>
<td>{{ m.opens_count }}</td>
<td>{{ m.closed_count }}</td>
<td>{% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %}</td>
<td class="{{ m_net_cls }}">{% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }}</td>
<td class="pos-pnl-loss">{{ funds_fmt(m.max_drawdown_u) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="inst-stats-empty" style="margin-top:0">暂无按月平仓数据</p>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
<div class="grid"> <div class="grid">
{% if page == 'dashboard' %} {% if page == 'dashboard' %}
{% include 'dashboard_panel.html' %} {% include 'dashboard_panel.html' %}
{% elif page == 'account_ledger' %}
{% include 'account_ledger_panel.html' %}
{% elif page == 'key_monitor' %} {% elif page == 'key_monitor' %}
{% include 'key_monitor_panel.html' %} {% include 'key_monitor_panel.html' %}
{% elif page == 'trade' %} {% elif page == 'trade' %}
@@ -97,50 +135,7 @@
{% endif %} {% endif %}
</div> </div>
{% include order_rule_tips_tpl %} {% include order_rule_tips_tpl %}
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}"> {% include 'order_monitor_open_form.html' %}
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
{% if not intraday_discipline %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">{{ open_position_button_label }}</button>
</form>
{% include 'order_plan_preview_bar.html' %}
</div> </div>
<div class="card"> <div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2> <h2 style="margin-bottom:8px">实时持仓</h2>
@@ -182,10 +177,8 @@
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span> <span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div> </div>
<div class="pos-head-actions"> <div class="pos-head-actions">
{% if not intraday_discipline %}
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button> <button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a> <a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
{% endif %}
</div> </div>
</div> </div>
<div class="pos-meta"> <div class="pos-meta">
@@ -245,15 +238,11 @@
<div class="pos-ex-orders-title">交易所止盈止损</div> <div class="pos-ex-orders-title">交易所止盈止损</div>
<div class="pos-ex-order-row"> <div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span> <span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
{% if not intraday_discipline %}
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button> <button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
{% endif %}
</div> </div>
<div class="pos-ex-order-row"> <div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span> <span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
{% if not intraday_discipline %}
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button> <button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -306,6 +295,7 @@
{% if page == 'records' %} {% if page == 'records' %}
{% include 'records_panel.html' %} {% include 'records_panel.html' %}
{% endif %} {% endif %}
</div>
{% if page == 'env_config' %} {% if page == 'env_config' %}
{% include 'env_config_panel.html' %} {% include 'env_config_panel.html' %}
{% endif %} {% endif %}
@@ -349,13 +339,14 @@
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button> <button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button> <button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button> <button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
</div> </div>
{{ period_stats_pane("day", seg.day) }} {{ period_stats_pane("day", seg.day) }}
{{ period_stats_pane("week", seg.week) }} {{ period_stats_pane("week", seg.week) }}
{{ period_stats_pane("month", seg.month) }} {{ period_stats_pane("month", seg.month) }}
{{ period_stats_pane("all", seg.all) }}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div>
+9 -5
View File
@@ -7,9 +7,10 @@
<script src="/static/autofill_guard.js?v=1"></script> <script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=11"> <link rel="stylesheet" href="/static/instance_page.css?v=13">
<link rel="stylesheet" href="/static/instance_theme.css?v=108"> <link rel="stylesheet" href="/static/instance_theme.css?v=117">
<script src="/static/account_risk_badge.js?v=4"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<script src="/static/open_submit_gate.js?v=1"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
</head> </head>
@@ -31,6 +32,7 @@
</div> </div>
<nav class="top-nav embed-top-nav" aria-label="实例导航"> <nav class="top-nav embed-top-nav" aria-label="实例导航">
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a> <a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if initial_tab == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a> <a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a> <a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
{% if not intraday_discipline and display.show_nav_strategy %} {% if not intraday_discipline and display.show_nav_strategy %}
@@ -110,6 +112,7 @@
<p class="inst-mobile-more-hint">次要页面 · 完整界面请用电脑</p> <p class="inst-mobile-more-hint">次要页面 · 完整界面请用电脑</p>
<nav class="inst-mobile-more-nav" aria-label="更多页面"> <nav class="inst-mobile-more-nav" aria-label="更多页面">
<a href="/dashboard" data-embed-tab="dashboard"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a> <a href="/dashboard" data-embed-tab="dashboard"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
<a href="/account_ledger" data-embed-tab="account_ledger"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
{% if display.show_nav_records %} {% if display.show_nav_records %}
<a href="/records" data-embed-tab="records">交易记录</a> <a href="/records" data-embed-tab="records">交易记录</a>
{% endif %} {% endif %}
@@ -158,17 +161,18 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script src="/static/symbol_live_price.js?v=2"></script> <script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/strategy_roll.js?v=6"></script> <script src="/static/strategy_roll.js?v=6"></script>
<script src="/static/key_monitor_form.js?v=2"></script> <script src="/static/key_monitor_form.js?v=2"></script>
<script src="/static/instance_stats.js?v=4"></script> <script src="/static/instance_stats.js?v=5"></script>
{% include 'embed_boot_scripts.html' %} {% include 'embed_boot_scripts.html' %}
<script src="/static/records_review_page.js?v=4"></script> <script src="/static/records_review_page.js?v=4"></script>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/instance_dashboard.js?v=5"></script> <script src="/static/instance_dashboard.js?v=5"></script>
<script src="/static/account_ledger.js?v=1"></script>
<script> <script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </script>
<script src="/static/instance_settings_prefs.js?v=16"></script> <script src="/static/instance_settings_prefs.js?v=22"></script>
<script src="/static/instance_live.js?v=6"></script> <script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=29"></script> <script src="/static/instance_embed.js?v=31"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script> <script src="/static/instance_mobile_nav.js?v=2"></script>
</body> </body>
</html> </html>
+6 -2
View File
@@ -16,7 +16,11 @@
</div> </div>
{% if env_config_groups %} {% if env_config_groups %}
<div class="env-config-body card" data-env-ssr="1" id="env-config-body"> {% set ns = namespace(mode_idx=0) %}
{% for group in env_config_groups %}
{% if '期权/对冲模式' in (group.title or '') %}{% set ns.mode_idx = loop.index0 %}{% endif %}
{% endfor %}
<div class="env-config-body card" data-env-ssr="1" id="env-config-body" data-env-mode-section-idx="{{ ns.mode_idx }}">
{% for group in env_config_groups %} {% for group in env_config_groups %}
<input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}> <input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
{% endfor %} {% endfor %}
@@ -33,7 +37,7 @@
{% endif %} {% endif %}
<div class="env-form-grid"> <div class="env-form-grid">
{% for field in group.fields %} {% for field in group.fields %}
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}"> <div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}" data-env-key="{{ field.key }}"{% if field.hidden %} hidden style="display:none"{% endif %}>
<label class="env-field-label" for="env-f-{{ field.key }}"> <label class="env-field-label" for="env-f-{{ field.key }}">
{{ field.label or field.key }} {{ field.label or field.key }}
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %} {% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
+233 -86
View File
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=4"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<script src="/static/open_submit_gate.js?v=1"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="{{ pwa_app_name }}"> <meta name="apple-mobile-web-app-title" content="{{ pwa_app_name }}">
@@ -17,8 +18,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest"> <link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=11"> <link rel="stylesheet" href="/static/instance_page.css?v=13">
<link rel="stylesheet" href="/static/instance_theme.css?v=105"> <link rel="stylesheet" href="/static/instance_theme.css?v=117">
</head> </head>
<body <body
@@ -110,6 +111,42 @@
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}</div></div> <div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}</div></div>
</div> </div>
</details> </details>
{% if period_key == 'all' %}
<div class="inst-stats-block inst-stats-monthly" style="margin-top:14px">
<div class="inst-stats-block-title">按月统计</div>
{% if s.monthly_rows %}
<div class="inst-stats-month-table-wrap">
<table class="inst-stats-month-table">
<thead>
<tr>
<th>月份</th>
<th>开单</th>
<th>平仓</th>
<th>胜率</th>
<th>净盈亏</th>
<th>最大回撤</th>
</tr>
</thead>
<tbody>
{% for m in s.monthly_rows %}
{% set m_net_cls = 'pos-pnl-profit' if m.net_pnl_u > 0 else ('pos-pnl-loss' if m.net_pnl_u < 0 else '') %}
<tr>
<td>{{ m.month_key }}</td>
<td>{{ m.opens_count }}</td>
<td>{{ m.closed_count }}</td>
<td>{% if m.win_rate_pct is not none %}{{ m.win_rate_pct }}%{% else %}—{% endif %}</td>
<td class="{{ m_net_cls }}">{% if m.net_pnl_u > 0 %}+{% endif %}{{ funds_fmt(m.net_pnl_u) }}</td>
<td class="pos-pnl-loss">{{ funds_fmt(m.max_drawdown_u) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="inst-stats-empty" style="margin-top:0">暂无按月平仓数据</p>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
<div class="container"> <div class="container">
@@ -118,6 +155,7 @@
</div> </div>
<div class="top-nav"> <div class="top-nav">
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a> <a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a> <a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a> <a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
{% if not intraday_discipline and display.show_nav_strategy %} {% if not intraday_discipline and display.show_nav_strategy %}
@@ -160,6 +198,8 @@
<div class="grid"> <div class="grid">
{% if page == 'dashboard' %} {% if page == 'dashboard' %}
{% include 'dashboard_panel.html' %} {% include 'dashboard_panel.html' %}
{% elif page == 'account_ledger' %}
{% include 'account_ledger_panel.html' %}
{% elif page == 'key_monitor' %} {% elif page == 'key_monitor' %}
{% include 'key_monitor_panel.html' %} {% include 'key_monitor_panel.html' %}
{% elif page == 'trade' %} {% elif page == 'trade' %}
@@ -174,50 +214,7 @@
{% endif %} {% endif %}
</div> </div>
{% include order_rule_tips_tpl %} {% include order_rule_tips_tpl %}
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}"> {% include 'order_monitor_open_form.html' %}
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
{% if not intraday_discipline %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">下单成交价以交易所成交回报为准</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<button type="submit">{{ open_position_button_label }}</button>
</form>
{% include 'order_plan_preview_bar.html' %}
</div> </div>
<div class="card"> <div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2> <h2 style="margin-bottom:8px">实时持仓</h2>
@@ -259,10 +256,8 @@
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span> <span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div> </div>
<div class="pos-head-actions"> <div class="pos-head-actions">
{% if not intraday_discipline %}
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button> <button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a> <a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
{% endif %}
</div> </div>
</div> </div>
<div class="pos-meta"> <div class="pos-meta">
@@ -322,15 +317,11 @@
<div class="pos-ex-orders-title">交易所止盈止损</div> <div class="pos-ex-orders-title">交易所止盈止损</div>
<div class="pos-ex-order-row"> <div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span> <span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
{% if not intraday_discipline %}
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button> <button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
{% endif %}
</div> </div>
<div class="pos-ex-order-row"> <div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span> <span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
{% if not intraday_discipline %}
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button> <button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -427,10 +418,12 @@
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button> <button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button> <button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button> <button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
</div> </div>
{{ period_stats_pane("day", seg.day) }} {{ period_stats_pane("day", seg.day) }}
{{ period_stats_pane("week", seg.week) }} {{ period_stats_pane("week", seg.week) }}
{{ period_stats_pane("month", seg.month) }} {{ period_stats_pane("month", seg.month) }}
{{ period_stats_pane("all", seg.all) }}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -472,7 +465,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script src="/static/manual_order_rr_preview.js?v=5"></script> <script src="/static/manual_order_rr_preview.js?v=5"></script>
<script src="/static/symbol_live_price.js?v=2"></script> <script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/strategy_roll.js?v=6"></script> <script src="/static/strategy_roll.js?v=6"></script>
<script src="/static/instance_stats.js?v=4"></script> <script src="/static/instance_stats.js?v=5"></script>
<script> <script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }}; const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
const JOURNAL_ORDER_TYPE_OPTIONS = {{ order_type_options | tojson }}; const JOURNAL_ORDER_TYPE_OPTIONS = {{ order_type_options | tojson }};
@@ -1195,13 +1188,10 @@ function paintExchangeTpslRow(orderId, tpsl){
const tpText = document.getElementById(`ex-tp-text-${orderId}`); const tpText = document.getElementById(`ex-tp-text-${orderId}`);
const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`); const slBtn = document.getElementById(`ex-sl-cancel-${orderId}`);
const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`); const tpBtn = document.getElementById(`ex-tp-cancel-${orderId}`);
const intraday = (document.body && document.body.getAttribute("data-intraday-discipline")) === "1";
if(slText) slText.innerText = formatExTpslLine('sl', data.sl); if(slText) slText.innerText = formatExTpslLine('sl', data.sl);
if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp); if(tpText) tpText.innerText = formatExTpslLine('tp', data.tp);
if(!intraday){ if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id);
if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id); if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id);
}
} }
function toggleTpslModalMode(){ function toggleTpslModalMode(){
const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price'; const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price';
@@ -1566,7 +1556,7 @@ function refreshOrderDefaults(){
}).catch(()=>{}); }).catch(()=>{});
} }
function paintRealtimePnl(v){ function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]'); const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return; if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){ if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1577,23 +1567,48 @@ function paintRealtimePnl(v){
return; return;
} }
const n = Number(v); const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : ""; const sign = n > 0 ? "+" : "";
const text = `${sign}${n.toFixed(2)}U`; let text;
let tone = n;
if (u === "ETH" || u === "BTC") {
// 顶栏实时盈亏只显示 U(按指数/现货换算),不展示币数量
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
tone = uu;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${uSign}${uAbs}U`;
} else {
text = "—";
tone = 0;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
nodes.forEach((pnlEl) => { nodes.forEach((pnlEl) => {
pnlEl.innerText = text; pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", n > 0); pnlEl.classList.toggle("pnl-pos", tone > 0);
pnlEl.classList.toggle("pnl-neg", n < 0); pnlEl.classList.toggle("pnl-neg", tone < 0);
}); });
} }
let lastRealtimePnl = null; let lastRealtimePnl = null;
function updateRealtimePnl(v){ let lastRealtimePnlUnit = "U";
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){ if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v); lastRealtimePnl = Number(v);
paintRealtimePnl(v); if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return; return;
} }
if(lastRealtimePnl != null) return; if(lastRealtimePnl != null) return;
paintRealtimePnl(v); paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
} }
function sumOrdersFloatPnl(orders){ function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null; if(!orders || !orders.length) return null;
@@ -1621,17 +1636,76 @@ function paintRealtimePnlFromSnapshot(data){
const perp = data.order_prices && data.order_prices.length const perp = data.order_prices && data.order_prices.length
? sumOrdersFloatPnl(data.order_prices) ? sumOrdersFloatPnl(data.order_prices)
: null; : null;
const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl); const opt = data.options_unrealized_pnl;
if(combined !== null || perp !== null || data.options_unrealized_pnl != null){ const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
paintRealtimePnl(combined); const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) {
// 币本位期权盈亏单位为币,勿与永续 U 混加成「xxU」
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0);
pnlEl.classList.toggle("pnl-neg", totalU < 0);
});
return;
}
paintRealtimePnl(opt, underly, spotPx);
return;
}
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) {
return;
}
}
const combined = combineRealtimeFloatPnl(perp, opt);
if(combined !== null || perp !== null || opt != null){
paintRealtimePnl(combined, "U");
} }
} }
function formatOptionsFundingLabel(usdc, usdt) { function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
if(usdc == null || usdc === "") return "—";
const n = Number(usdc);
if(Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`;
}
function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
// 缺省按 usdc(xx.xxU):Gate/Binance 快照无 options_margin_mode;OKX 会显式下发.
const mode = String(marginMode || "usdc").toLowerCase();
if (mode !== "coin") {
if (usdt == null || usdt === "") return "—";
const n = Number(usdt);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
const parts = []; const parts = [];
if (usdc !== null && usdc !== undefined && Number(usdc) > 0) parts.push(`${Number(usdc).toFixed(2)} USDC`); if (usdt != null && usdt !== "") {
if (usdt !== null && usdt !== undefined && Number(usdt) > 0) parts.push(`${Number(usdt).toFixed(2)} USDT`); const n = Number(usdt);
return parts.length ? parts.join(" · ") : "—"; if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
const pushCoin = (v, ccy) => {
if (v == null || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
pushCoin(eth, "ETH");
pushCoin(btc, "BTC");
return parts.length ? parts.join("\n") : "—";
} }
function setFundsFieldText(field, text){ function setFundsFieldText(field, text){
@@ -1640,8 +1714,28 @@ function setFundsFieldText(field, text){
el.innerText = text; el.innerText = text;
}); });
} }
function applyPerpFundsVisibility(show){
const on = show !== false;
document.querySelectorAll("[data-perp-funds='1']").forEach((el) => {
el.style.display = on ? "" : "none";
});
}
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){ function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true; if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){
const hasTotal = data.total_funds != null && data.total_funds !== "";
const hasOpt =
data.options_funding_usdc != null ||
data.options_funding_usdt != null ||
data.options_trading_usdc != null ||
data.options_trading_usdt != null;
return !hasTotal && !hasOpt;
}
const hasFunding = data.funding_usdt != null && data.funding_usdt !== ""; const hasFunding = data.funding_usdt != null && data.funding_usdt !== "";
const hasTotal = data.total_funds != null && data.total_funds !== ""; const hasTotal = data.total_funds != null && data.total_funds !== "";
const hasTrading = data.current_capital != null && data.current_capital !== ""; const hasTrading = data.current_capital != null && data.current_capital !== "";
@@ -1650,6 +1744,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0; let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if(typeof data.show_perp_funds !== "undefined"){
applyPerpFundsVisibility(data.show_perp_funds !== false || coinMode);
} else if (coinMode) {
applyPerpFundsVisibility(true);
}
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} }
@@ -1657,18 +1758,48 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} }
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`); setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
} }
if(data.options_funding_usdc != null || data.options_funding_usdt != null){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt); const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding); setFundsFieldText("options-funding-usdc", optFunding);
} }
if(data.options_trading_usdc != null || data.options_trading_usdt != null){ if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt); const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
updateRealtimePnl(data.unrealized_pnl); const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) {
// 币本位:顶栏跟持仓卡同口径(期权净盈亏),勿用混加后的 unrealized_pnl(会被抹成 0.00)
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
} }
if(typeof data.total !== "undefined" && data.total !== null){ if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total)); setFundsFieldText("stat-total", String(data.total));
@@ -1702,11 +1833,15 @@ function applyAccountSnapshot(data){
if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){ if(data.force_close && window.TimeCloseUI && TimeCloseUI.paintForceCloseHeader){
TimeCloseUI.paintForceCloseHeader(data.force_close); TimeCloseUI.paintForceCloseHeader(data.force_close);
} }
if(window.OpenSubmitGate) OpenSubmitGate.apply(data);
let canTradeText = "可开仓"; let canTradeText = "可开仓";
if(!data.can_trade){ if(!data.can_trade){
const parts = []; const parts = [];
if(data.open_block_note) parts.push(data.open_block_note);
if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){ if(data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason){
parts.push(data.risk_status.reason); if(!data.open_block_note || data.open_block_note.indexOf(data.risk_status.reason) < 0){
parts.push(data.risk_status.reason);
}
} }
if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`); if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
@@ -1791,6 +1926,12 @@ if(fullMarginEl){
} }
const sltpModeEl = document.getElementById("sltp-mode"); const sltpModeEl = document.getElementById("sltp-mode");
function setOmFieldVisible(inputEl, show){
if(!inputEl) return;
inputEl.style.display = show ? "" : "none";
const wrap = inputEl.closest(".om-field");
if(wrap) wrap.style.display = show ? "" : "none";
}
function toggleSltpMode(){ function toggleSltpMode(){
const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr"; const mode = sltpModeEl ? sltpModeEl.value : "fixed_rr";
const slEl = document.getElementById("order-sl"); const slEl = document.getElementById("order-sl");
@@ -1801,14 +1942,14 @@ function toggleSltpMode(){
if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; } if(!slEl || !tpEl || !slPctEl || !tpPctEl){ return; }
const pct = mode === "pct"; const pct = mode === "pct";
const fixed = mode === "fixed_rr"; const fixed = mode === "fixed_rr";
slEl.style.display = pct ? "none" : ""; setOmFieldVisible(slEl, !pct);
tpEl.style.display = (pct || fixed) ? "none" : ""; setOmFieldVisible(tpEl, !(pct || fixed));
if(fixedRrEl) fixedRrEl.style.display = fixed ? "" : "none"; setOmFieldVisible(fixedRrEl, fixed);
slEl.required = !pct; slEl.required = !pct;
tpEl.required = !pct && !fixed; tpEl.required = !pct && !fixed;
if(fixedRrEl) fixedRrEl.required = fixed; if(fixedRrEl) fixedRrEl.required = fixed;
slPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(slPctEl, pct);
tpPctEl.style.display = pct ? "" : "none"; setOmFieldVisible(tpPctEl, pct);
slPctEl.required = pct; slPctEl.required = pct;
tpPctEl.required = pct; tpPctEl.required = pct;
refreshOrderTpPreview(); refreshOrderTpPreview();
@@ -2012,6 +2153,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
<script src="/static/records_review_page.js?v=4"></script> <script src="/static/records_review_page.js?v=4"></script>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/instance_dashboard.js?v=5"></script> <script src="/static/instance_dashboard.js?v=5"></script>
<script src="/static/account_ledger.js?v=1"></script>
<script> <script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
{% if page == 'dashboard' %} {% if page == 'dashboard' %}
@@ -2019,7 +2161,12 @@ document.addEventListener("DOMContentLoaded", function () {
if (window.InstanceDashboard) InstanceDashboard.init(true); if (window.InstanceDashboard) InstanceDashboard.init(true);
}); });
{% endif %} {% endif %}
{% if page == 'account_ledger' %}
document.addEventListener("DOMContentLoaded", function () {
if (window.AccountLedgerPage) AccountLedgerPage.boot();
});
{% endif %}
</script> </script>
<script src="/static/instance_settings_prefs.js?v=15"></script> <script src="/static/instance_settings_prefs.js?v=22"></script>
</body> </body>
</html> </html>
@@ -38,11 +38,14 @@
{% include 'instance_header_stats.html' %} {% include 'instance_header_stats.html' %}
</div> </div>
<div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要"> <div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要">
<span class="inst-phone-chip"> {% set _coin_margin = (options_enabled|default(false)) and (options_margin_mode|default('coin')) == 'coin' %}
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
{% set _trading_margin_mode = 'coin' if _coin_margin else 'usdc' %}
<span class="inst-phone-chip"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<em>交易</em> <em>交易</em>
<b data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</b> <b data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth|default(none), options_trading_btc|default(none), margin_mode=_trading_margin_mode) }}</b>
</span> </span>
<span class="inst-phone-chip"> <span class="inst-phone-chip"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<em>资金</em> <em>资金</em>
<b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b> <b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b>
</span> </span>
@@ -1,4 +1,8 @@
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #} {# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
{# 币本位顶栏仅 OKX 期权开启时生效;Gate/Binance 保持原 xx.xxU #}
{% set _coin_margin = (options_enabled|default(false)) and (options_margin_mode|default('coin')) == 'coin' %}
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
{% set _trading_margin_mode = 'coin' if _coin_margin else 'usdc' %}
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}"> <div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
<div class="stat-strip-item stat-strip-item--primary"> <div class="stat-strip-item stat-strip-item--primary">
<div class="label">交易所</div> <div class="label">交易所</div>
@@ -24,22 +28,22 @@
<div class="label">总资金</div> <div class="label">总资金</div>
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div> <div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
</div> </div>
<div class="stat-strip-item"> <div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<div class="label">资金账户</div> <div class="label">资金账户</div>
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div> <div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
</div> </div>
<div class="stat-strip-item"> <div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<div class="label">交易账户</div> <div class="label">交易账户</div>
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div> <div class="value" id="current-capital" data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth|default(none), options_trading_btc|default(none), margin_mode=_trading_margin_mode) }}</div>
</div> </div>
{% if options_enabled %} {% if options_enabled and not _coin_margin %}
<div class="stat-strip-item"> <div class="stat-strip-item" data-options-funds="1">
<div class="label">期权资金账户</div> <div class="label">期权资金账户</div>
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc, options_funding_usdt) }}</div> <div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc|default(none), options_funding_usdt|default(none), options_funding_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}</div>
</div> </div>
<div class="stat-strip-item"> <div class="stat-strip-item" data-options-funds="1">
<div class="label">期权交易账户</div> <div class="label">期权交易账户</div>
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc, options_trading_usdt) }}</div> <div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc|default(none), options_trading_usdt|default(none), options_trading_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}</div>
</div> </div>
{% endif %} {% endif %}
<div class="stat-strip-item stat-strip-item--pnl"> <div class="stat-strip-item stat-strip-item--pnl">
@@ -0,0 +1,77 @@
{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #}
<form id="add-order-form" action="/add_order" method="post" class="order-monitor-form" data-risk-percent="{{ risk_percent }}">
<div class="om-row om-row-policy">
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode" title="止盈止损模式">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
</div>
<div class="om-row om-row-levels">
<label class="om-field" id="om-field-sl">
<span class="om-field-lab">止损价格</span>
<input id="order-sl" name="sl" step="any" placeholder="必填" required>
</label>
<label class="om-field" id="om-field-rr">
<span class="om-field-lab">盈亏比</span>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="默认1.5" value="1.5" title="止盈距离=止损距离×盈亏比">
</label>
<label class="om-field" id="om-field-tp" style="display:none">
<span class="om-field-lab">止盈价格</span>
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格">
</label>
<label class="om-field" id="om-field-sl-pct" style="display:none">
<span class="om-field-lab">止损%</span>
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%">
</label>
<label class="om-field" id="om-field-tp-pct" style="display:none">
<span class="om-field-lab">止盈%</span>
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%">
</label>
<div class="om-live-meta">
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">成交价以交易所回报为准</span>
</div>
</div>
<div class="om-row om-row-opts">
{% if not intraday_discipline %}
<label class="om-check">
<input type="checkbox" name="breakeven_enabled" value="1" checked>
<span>移动保本</span>
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap om-time-close">
<label class="om-check" style="margin:0">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb">
<span>时间平仓</span>
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label class="om-check" title="开仓后生成多周期K线图(各周期100根,含开平仓标记)">
<input type="checkbox" name="order_chart" value="true">
<span>开仓后生成多周期K线</span>
</label>
</div>
<div class="om-row om-row-action">
<button type="submit" class="om-submit{% if not can_trade %} is-blocked{% endif %}" id="om-submit-btn"{% if not can_trade %} disabled aria-disabled="true"{% endif %}>{{ open_position_button_label }}</button>
<span id="om-open-block-note" class="om-open-block-note"{% if can_trade or not (open_block_note|default('')) %} hidden{% endif %}>{% if not can_trade and (open_block_note|default('')) %}{{ open_block_note }}{% endif %}</span>
</div>
</form>
{% include 'order_plan_preview_bar.html' %}
+1 -1
View File
@@ -36,7 +36,7 @@
{% include 'password_settings_panel.html' %} {% include 'password_settings_panel.html' %}
{% elif tab.key == 'transfer' %} {% elif tab.key == 'transfer' %}
<h2>永续资金划转</h2> <h2>永续资金划转</h2>
<p class="settings-subcard-desc">账户永续:资金账户与交易账户之间划转 USDT.</p> <p class="settings-subcard-desc">账户:资金账户与交易账户之间划转 USDT.</p>
{% include 'instance_transfer_panel.html' %} {% include 'instance_transfer_panel.html' %}
{% elif tab.key == 'export' %} {% elif tab.key == 'export' %}
<h2>数据导出</h2> <h2>数据导出</h2>
+64 -15
View File
@@ -58,7 +58,13 @@ def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None =
if strike is None: if strike is None:
strike = ps strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) return close_ref_prices(
mark_px=mark,
opt_type=str(opt_type or ""),
strike=strike,
index_px=idx,
inst_id=inst_id,
)
def _avail_sheets(pos: dict[str, Any]) -> int: def _avail_sheets(pos: dict[str, Any]) -> int:
@@ -100,7 +106,7 @@ def close_option_by_bid1(
- 限价 = 校验通过时锁定的买一价 - 限价 = 校验通过时锁定的买一价
- 永不市价 - 永不市价
- 始终校验有效流动性(残档买一禁止) - 始终校验有效流动性(残档买一禁止)
- require_recycle_gate=True :首次还需可回收2×权利金并持续 hold ; - require_recycle_gate=True :首次还需目标门控(权利金×倍数或净盈亏,U 口径)并持续 hold ;
一旦通过后对同仓续批只验流动性 一旦通过后对同仓续批只验流动性
""" """
from lib.exchange.okx_options_lib import ( from lib.exchange.okx_options_lib import (
@@ -139,6 +145,8 @@ def close_option_by_bid1(
premium_paid = _open_premium_paid(cfg, inst_id) premium_paid = _open_premium_paid(cfg, inst_id)
if premium_paid is None: if premium_paid is None:
premium_paid = _safe_float(pos.get("premium_paid")) premium_paid = _safe_float(pos.get("premium_paid"))
premium_ccy = str(q.get("premium_ccy") or pos.get("premium_ccy") or "USDC").strip().upper() or "USDC"
index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px"))
# 已有未成交卖平单:等成交,不撤不重挂 # 已有未成交卖平单:等成交,不撤不重挂
try: try:
@@ -187,8 +195,14 @@ def close_option_by_bid1(
max_levels=1, max_levels=1,
) )
if preview.get("bid_invalid") or preview.get("auto_close_blocked"): if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
_cancel_sell_pending(ex, inst_id) # 不撤他人挂单:仅拒绝本轮下单
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓", "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
@@ -202,7 +216,13 @@ def close_option_by_bid1(
bid_px = _safe_float(q.get("bid")) bid_px = _safe_float(q.get("bid"))
stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub or bid_px is None or bid_px <= 0: if stub or bid_px is None or bid_px <= 0:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": stub_reason or "暂无买一,无法限价平仓", "msg": stub_reason or "暂无买一,无法限价平仓",
@@ -225,7 +245,13 @@ def close_option_by_bid1(
stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub_lv: if stub_lv:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": stub_lv_reason or "暂无有效买盘,禁止平仓", "msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
@@ -239,6 +265,8 @@ def close_option_by_bid1(
inst_id, inst_id,
recycle_usdc=_safe_float(preview.get("total_received")), recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=premium_paid, premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
) )
if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"): if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"):
return { return {
@@ -248,8 +276,6 @@ def close_option_by_bid1(
"auto_close_blocked": True, "auto_close_blocked": True,
"close_gate": gate, "close_gate": gate,
} }
if gate.get("ready"):
mark_close_gate_passed(inst_id)
locked_bid_px = level_px locked_bid_px = level_px
before_avail = avail before_avail = avail
@@ -272,6 +298,9 @@ def close_option_by_bid1(
"locked_bid_px": locked_bid_px, "locked_bid_px": locked_bid_px,
"batch_sheets": level_sheets, "batch_sheets": level_sheets,
} }
# 仅下单被接受后才记门控已通过,避免下单失败却跳过后续 2× 等待
if require_recycle_gate and gate.get("ready"):
mark_close_gate_passed(inst_id)
px = float(order.get("px", locked_bid_px)) px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "") oid = str((order.get("data") or {}).get("ordId") or "")
@@ -279,12 +308,20 @@ def close_option_by_bid1(
time.sleep(0.6) time.sleep(0.6)
invalidate_option_positions_cache() invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex) raw2 = cfg["fetch_option_positions"](ex)
after_avail = 0 if raw2 is None:
if raw2 is not None: return {
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None) "ok": False,
after_avail = _avail_sheets(after_pos) if after_pos else 0 "msg": "下单后获取持仓失败,未确认是否成交",
reduced = max(0, before_avail - after_avail) if raw2 is not None else 0 "stopped_reason": "position_fetch_failed",
remaining_pos = after_avail if raw2 is not None else max(0, before_avail - level_sheets) "locked_bid_px": locked_bid_px,
"batch_sheets": level_sheets,
"close_ord_id": oid or None,
"fully_closed": False,
}
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
after_avail = _avail_sheets(after_pos) if after_pos else 0
reduced = max(0, before_avail - after_avail)
remaining_pos = after_avail
fully_closed = remaining_pos < 1 fully_closed = remaining_pos < 1
if fully_closed: if fully_closed:
@@ -342,7 +379,7 @@ def close_option_by_bid1(
# 自动平已挂过单:同仓续批只验流动性 # 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id) mark_close_gate_passed(inst_id)
return { out = {
"ok": True, "ok": True,
"mode": "bid1", "mode": "bid1",
"orders": [{"order": order, "px": px, "sheets": level_sheets}], "orders": [{"order": order, "px": px, "sheets": level_sheets}],
@@ -360,6 +397,18 @@ def close_option_by_bid1(
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓") + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
), ),
} }
if fully_closed:
try:
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out)
if spot_sell is not None:
out["spot_sell"] = spot_sell
if spot_sell.get("bridge_status") == "pending_sell_spot":
out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回"
except Exception as e:
out["spot_sell"] = {"ok": False, "msg": str(e)}
return out
# 兼容旧名 # 兼容旧名
+171 -21
View File
@@ -1,4 +1,4 @@
"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓.""" """期权按买盘平仓门控:可回收/净盈亏换算为 USDT 后校验,并持续 hold 秒才允许平仓."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -14,10 +14,44 @@ def _env_float(key: str, default: float) -> float:
return default return default
# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 def _env_optional_float(key: str) -> float | None:
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) raw = os.getenv(key)
if raw is None or str(raw).strip() == "":
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
def close_net_pnl_min_u() -> float:
return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", 0.0)
def close_gate_mode() -> str:
m = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower()
return m if m in ("premium", "net_pnl") else "premium"
def resolve_close_recycle_mult(premium_ccy: str | None, min_mult: float | None = None) -> float:
if min_mult is not None:
m = float(min_mult)
return m if m > 0 else 1.05
global_mult = _env_optional_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT")
if global_mult is not None and global_mult > 0:
return global_mult
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy in ("ETH", "BTC"):
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05)
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", 2.0)
# 兼容旧引用
CLOSE_RECYCLE_MIN_MULT = resolve_close_recycle_mult("USDC")
_lock = threading.Lock() _lock = threading.Lock()
# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float} # inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float}
_gates: dict[str, dict[str, Any]] = {} _gates: dict[str, dict[str, Any]] = {}
@@ -32,6 +66,39 @@ def _safe_float(v: Any) -> float | None:
return None return None
def _normalize_premium_ccy(premium_ccy: str | None) -> str:
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy not in ("ETH", "BTC", "USDC"):
ccy = "USDC"
return ccy
def _to_usdt(amount: float | None, ccy: str, index_px: float | None) -> float | None:
if amount is None:
return None
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return None
return float(amount) * float(idx)
return float(amount)
def _fmt_gate_amt(v: float, *, ccy: str) -> str:
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
txt = f"{float(v):.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{float(v):.4f}"
def _fmt_usdt(v: float | None) -> str:
if v is None:
return ""
return f"{float(v):.2f}"
def clear_close_gate(inst_id: str | None = None) -> None: def clear_close_gate(inst_id: str | None = None) -> None:
with _lock: with _lock:
if inst_id: if inst_id:
@@ -41,7 +108,7 @@ def clear_close_gate(inst_id: str | None = None) -> None:
def mark_close_gate_passed(inst_id: str) -> None: def mark_close_gate_passed(inst_id: str) -> None:
"""标记同仓已通过 2× 门控,续批平仓只验流动性.""" """标记同仓已通过门控,续批平仓只验流动性."""
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if not inst: if not inst:
return return
@@ -68,10 +135,13 @@ def update_close_gate(
now: float | None = None, now: float | None = None,
min_mult: float | None = None, min_mult: float | None = None,
hold_seconds: float | None = None, hold_seconds: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
根据当前买盘可回收金额刷新门控. 根据当前买盘可回收金额刷新门控(比较口径均为 USDT 估值).
条件不满足时重置计时;满足时从首次满足起累计持续时间. premium 模式:可回收(U) 权利金(U) × 倍数
net_pnl 模式:净盈亏(U) > OKX_OPTIONS_CLOSE_NET_PNL_MIN_U
""" """
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if not inst: if not inst:
@@ -82,19 +152,38 @@ def update_close_gate(
"msg": "缺少合约", "msg": "缺少合约",
} }
ts = float(now if now is not None else time.time()) ts = float(now if now is not None else time.time())
mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT) mode = close_gate_mode()
hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS)
if mult <= 0:
mult = 2.0
if hold < 0: if hold < 0:
hold = 0.0 hold = 0.0
with _lock:
prev_ccy = (_gates.get(inst) or {}).get("premium_ccy")
ccy = _normalize_premium_ccy(premium_ccy or prev_ccy)
mult = resolve_close_recycle_mult(ccy, min_mult)
min_pnl_u = close_net_pnl_min_u()
prem = _safe_float(premium_paid) prem = _safe_float(premium_paid)
recv = _safe_float(recycle_usdc) recv = _safe_float(recycle_usdc)
need = round(prem * mult, 4) if prem is not None and prem > 0 else None recv_u = _to_usdt(recv, ccy, index_px)
recycle_ok = bool( prem_u = _to_usdt(prem, ccy, index_px)
prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need net_u = round(recv_u - prem_u, 4) if recv_u is not None and prem_u is not None else None
) need_u = round(prem_u * mult, 4) if prem_u is not None and prem_u > 0 and mode == "premium" else None
missing_index = ccy in ("ETH", "BTC") and (index_px is None or _safe_float(index_px) is None or _safe_float(index_px) <= 0)
if missing_index and prem is not None and prem > 0 and recv is not None:
recycle_ok = False
elif mode == "net_pnl":
recycle_ok = bool(net_u is not None and net_u > min_pnl_u + 1e-9)
else:
recycle_ok = bool(
prem_u is not None
and prem_u > 0
and recv_u is not None
and need_u is not None
and recv_u + 1e-9 >= need_u
)
with _lock: with _lock:
prev = _gates.get(inst) or {} prev = _gates.get(inst) or {}
@@ -112,11 +201,19 @@ def update_close_gate(
"ok_since": ok_since, "ok_since": ok_since,
"recycle": recv, "recycle": recv,
"premium": prem, "premium": prem,
"need": need, "need": need_u,
"updated": ts, "updated": ts,
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"passed": passed, "passed": passed,
"premium_ccy": ccy,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
} }
_gates[inst] = state _gates[inst] = state
@@ -125,15 +222,40 @@ def update_close_gate(
msg = "缺少权利金,无法校验平仓门控" msg = "缺少权利金,无法校验平仓门控"
elif recv is None: elif recv is None:
msg = "暂无有效买盘可回收金额" msg = "暂无有效买盘可回收金额"
elif missing_index:
msg = "缺少指数价,无法按 USDT 校验目标平仓门控"
elif mode == "net_pnl":
if not recycle_ok:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) ≤ {_fmt_usdt(min_pnl_u)}U,"
f"目标平仓门控未过"
)
elif not ready:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U,"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
)
else:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U"
f"且持续≥{hold:.0f}s,目标触达后可按买一平仓"
)
elif not recycle_ok: elif not recycle_ok:
msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),目标平仓门控未过" msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) < 权利金×{mult:g}"
f"({_fmt_usdt(need_u)}U),目标平仓门控未过"
)
elif not ready: elif not ready:
msg = ( msg = (
f"可回收已达×{mult:g}({recv:.4f}/{need:.4f})," f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}"
f"({_fmt_usdt(recv_u)}/{_fmt_usdt(need_u)}U),"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
) )
else: else:
msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,目标触达后可按买一平仓" msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}且持续≥{hold:.0f}s,"
f"目标触达后可按买一平仓"
)
auto_blocked = not (ready or passed) auto_blocked = not (ready or passed)
return { return {
@@ -143,13 +265,21 @@ def update_close_gate(
"recycle_ok": recycle_ok, "recycle_ok": recycle_ok,
"recycle_usdc": recv, "recycle_usdc": recv,
"premium_paid": prem, "premium_paid": prem,
"need_recycle_usdc": need, "need_recycle_usdc": need_u,
"premium_ccy": ccy,
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"held_seconds": round(held, 1) if recycle_ok else 0.0, "held_seconds": round(held, 1) if recycle_ok else 0.0,
"remain_seconds": round(remain, 1) if remain is not None else None, "remain_seconds": round(remain, 1) if remain is not None else None,
"ok_since": ok_since, "ok_since": ok_since,
"msg": msg, "msg": msg,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
"auto_close_blocked": auto_blocked, "auto_close_blocked": auto_blocked,
"close_gate_blocked": auto_blocked, "close_gate_blocked": auto_blocked,
} }
@@ -160,25 +290,45 @@ def check_close_gate(
*, *,
recycle_usdc: float | None = None, recycle_usdc: float | None = None,
premium_paid: float | None = None, premium_paid: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
refresh: bool = True, refresh: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""检查是否允许平仓;默认先用最新回收/权利金刷新.""" """检查是否允许平仓;默认先用最新回收/权利金刷新."""
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if refresh: if refresh:
if recycle_usdc is None or premium_paid is None: if recycle_usdc is None or premium_paid is None or premium_ccy is None or index_px is None:
with _lock: with _lock:
prev = _gates.get(inst) or {} prev = _gates.get(inst) or {}
if recycle_usdc is None: if recycle_usdc is None:
recycle_usdc = prev.get("recycle") recycle_usdc = prev.get("recycle")
if premium_paid is None: if premium_paid is None:
premium_paid = prev.get("premium") premium_paid = prev.get("premium")
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) if premium_ccy is None:
premium_ccy = prev.get("premium_ccy")
if index_px is None:
index_px = prev.get("index_px")
return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
with _lock: with _lock:
prev = _gates.get(inst) prev = _gates.get(inst)
if not prev: if not prev:
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return update_close_gate( return update_close_gate(
inst, inst,
recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"), recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"),
premium_paid=premium_paid if premium_paid is not None else prev.get("premium"), premium_paid=premium_paid if premium_paid is not None else prev.get("premium"),
premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"),
index_px=index_px if index_px is not None else prev.get("index_px"),
) )
+481
View File
@@ -0,0 +1,481 @@
"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回."""
from __future__ import annotations
import os
import time
from typing import Any
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
td_mode_for_option_buy,
wait_option_order_full_fill,
)
from lib.options.options_margin_mode_lib import (
calc_sheets_from_coin_balance,
compute_coin_budget_usdt,
is_coin_margin_mode,
margin_mode_from_inst_id,
normalize_options_margin_mode,
plan_coin_open_by_budget,
premium_ccy_for_mode,
)
from lib.options.options_spot_bridge_lib import (
BRIDGE_BOUGHT,
BRIDGE_HOLDING,
bridge_blocks_new_open_msg,
fetch_trading_coin_available,
insert_bridge,
rollback_bought_coin_to_usdt,
sell_residual_after_option_flat,
spot_market_buy_coin_with_usdt,
update_bridge,
)
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances
bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True)
trading = bal.get("trading_usdt_avail")
if trading is None:
trading = bal.get("trading_usdt")
try:
trading_f = float(trading or 0)
except (TypeError, ValueError):
trading_f = 0.0
buf = float(cfg.get("budget_buffer") or 0.95)
return compute_coin_budget_usdt(trading_f, buffer=buf)
def open_coin_option_buy_full(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
signal_note: str = "",
target_index: float | None = None,
profit_exit_enabled: bool = False,
profit_exit_mult: float = 1.0,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算)."""
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if not is_coin_margin_mode():
return {"ok": False, "msg": "当前非币本位模式"}
if margin_mode_from_inst_id(inst_id) != "coin":
return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"}
# 解析标的
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
block = bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return {"ok": False, "msg": compound_block, "can_open": False}
pos_limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id=inst_id,
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {"ok": False, "msg": pos_limit_msg, "can_open": False}
budget_info = coin_budget_preview(cfg, ex)
if not budget_info.get("ok"):
return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info}
budget_usdt = float(budget_info["budget_usdt"])
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return q
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"can_open": False,
}
ct_mult = float(q.get("ct_mult") or 0.01)
min_sz = int(q.get("min_sz") or 1)
idx = None
try:
idx = float(q.get("index_px") or q.get("idxPx") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
try:
from lib.exchange.okx_options_lib import fetch_index_price
idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0)
except Exception:
idx = 0.0
plan = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdt=budget_usdt,
index_px=float(idx),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if not plan.get("ok"):
return {
"ok": False,
"msg": plan.get("msg") or "无法规划买币张数",
"plan": plan,
"budget": budget_info,
"can_open": False,
}
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = fetch_trading_coin_available(ex, underlying) or 0.0
buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
if not buy.get("ok"):
return {
"ok": False,
"msg": f"现货买入 {underlying} 失败: {buy.get('msg')}",
"budget": budget_info,
"plan": plan,
}
bridge_id = insert_bridge(
conn,
underlying=underlying,
status=BRIDGE_BOUGHT,
budget_usdt=buy_usdt,
buy_ord_id=str(buy.get("ord_id") or ""),
inst_id=inst_id,
message="已买币,待开期权",
)
# 等余额落账
time.sleep(1.5)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
coin_after = fetch_trading_coin_available(ex, underlying)
if coin_after is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额"
)
return {
"ok": False,
"msg": "买币后读不到可用余额,已尝试卖回 USDT",
"rollback": rb,
"budget": budget_info,
"plan": plan,
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
if coin_bought <= 0:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
coin_available=float(coin_bought),
)
if not sizing.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=sizing.get("msg") or "张数不足",
coin_amount=float(coin_bought),
)
return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan}
# 实盘以买到的币为准,但不超过规划张数
sheets = min(int(sizing["sheets"]), int(plan["sheets"]))
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz)
if capped is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足"
)
return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb}
if capped < sheets:
sheets = int(capped)
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"ask_depth_capped": True,
}
else:
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
}
tick_sz = q.get("tick_sz")
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
# 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚)
if (not order.get("ok")) and sheets > 1:
msg_l = str(order.get("msg") or "").lower()
if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""):
sheets2 = max(1, sheets // 2)
if sheets2 < sheets:
order2 = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets2,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
if order2.get("ok"):
order = order2
sheets = sheets2
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"retried_half": True,
}
if not order.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败"
)
return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb}
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号"
)
return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb}
try:
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=int(sheets),
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=fill.get("msg") or "未成交",
)
return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb}
sheets = filled_n
eth_amount = round(float(sheets) * ct_mult, 8)
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
message="期权持仓中",
)
trade_id = _insert_coin_trade(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
strike=q.get("strike"),
exp_time=q.get("exp_time"),
sheets=int(sheets),
eth_amount=eth_amount,
open_quote=float(ask),
premium_paid=premium_paid,
signal_note=signal_note,
exchange_ord_id=ord_id,
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
# 目标位 / 翻倍离场 — 复用现有逻辑若存在
try:
if target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor
upsert_target_monitor(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
target_index=float(target_index),
trade_id=trade_id,
sheets=int(sheets),
)
except Exception:
pass
try:
from lib.options.options_notify_lib import notify_options_open
notify_options_open(
cfg,
conn,
trade_id=trade_id,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
sheets=int(sheets),
premium_paid=premium_paid,
open_quote=float(ask),
target_index=target_index,
signal_note=signal_note,
premium_ccy=premium_ccy,
margin_mode="coin",
)
except Exception:
pass
return {
"ok": True,
"msg": f"币本位开仓成功 {sheets}",
"margin_mode": "coin",
"budget": budget_info,
"sizing": sizing,
"sheets": sheets,
"eth_amount": eth_amount,
"premium_paid": premium_paid,
"premium_ccy": premium_ccy,
"bridge_id": bridge_id,
"trade_id": trade_id,
"order": order,
"fill": fill,
}
finally:
try:
conn.close()
except Exception:
pass
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle"
cur = conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
kwargs["underlying"],
kwargs["opt_type"],
kwargs.get("strike"),
str(kwargs.get("exp_time") or ""),
kwargs["sheets"],
kwargs["eth_amount"],
kwargs.get("open_quote"),
kwargs.get("premium_paid"),
kwargs.get("signal_note") or "",
kwargs.get("exchange_ord_id"),
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
pe,
pe_mult,
pe_state,
),
)
conn.commit()
return int(cur.lastrowid)
def maybe_sell_spot_after_close(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
if margin_mode_from_inst_id(inst_id) != "coin":
return None
# 仍有仓则不卖
try:
rows = cfg["fetch_option_positions"](ex) or []
for p in rows:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
except Exception:
pass
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
finally:
try:
conn.close()
except Exception:
pass
+15 -1
View File
@@ -98,11 +98,24 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
for ddl in ( for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
"ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'",
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
): ):
try: try:
conn.execute(ddl) conn.execute(ddl)
except Exception: except Exception:
pass pass
try:
from lib.options.options_spot_bridge_lib import ensure_bridge_table
ensure_bridge_table(conn)
except Exception:
pass
init_options_review_tables(conn) init_options_review_tables(conn)
@@ -121,7 +134,8 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
).fetchone() ).fetchone()
if not row or int(row["n"] or 0) < 1: if not row or int(row["n"] or 0) < 1:
return None return None
return round(float(row["total"] or 0), 4) # 币本位权利金常 <1e-4,保留 8 位避免被裁成 0
return round(float(row["total"] or 0), 8)
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None: def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
+71 -8
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from typing import Any from typing import Any
from lib.exchange.okx_options_lib import format_premium_amount
from lib.options.options_db import init_options_tables, sum_open_premium_paid from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
def enrich_position_row_display( def enrich_position_row_display(
@@ -14,14 +16,73 @@ def enrich_position_row_display(
meta_cache: dict[str, dict[str, Any] | None] | None = None, meta_cache: dict[str, dict[str, Any] | None] | None = None,
premium_override: float | None = None, premium_override: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from lib.exchange.okx_options_lib import format_position_row, format_usdc_amount, tick_sz_and_ct_mult from lib.exchange.okx_options_lib import format_position_row, tick_sz_and_ct_mult
inst_id = str(raw_pos.get("instId") or "").strip() inst_id = str(raw_pos.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz)
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = str(row.get("underlying") or (inst_id.split("-")[0] if inst_id else "ETH") or "ETH")
premium_ccy = premium_ccy_for_mode(row_mode, underly)
row["margin_mode"] = row_mode
row["premium_ccy"] = premium_ccy
row["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
if premium_override is not None: if premium_override is not None:
row["premium_paid"] = premium_override row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_usdc_amount(premium_override) row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
return row
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _load_local_closed_by_inst(conn: Any) -> dict[str, dict[str, Any]]:
"""同合约取最新已平本地单,用于补交易所历史权利金/盈亏."""
out: dict[str, dict[str, Any]] = {}
try:
rows = conn.execute(
"""
SELECT inst_id, premium_paid, realized_pnl, premium_ccy, margin_mode,
open_quote, close_quote, sheets, closed_at
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
"""
).fetchall()
except Exception:
return out
for r in rows:
inst = str(r["inst_id"] or "").strip()
if not inst or inst in out:
continue
out[inst] = dict(r)
return out
def _overlay_local_closed(row: dict[str, Any], local: dict[str, Any] | None) -> dict[str, Any]:
if not local:
return row
prem = _safe_float(row.get("premium_paid"))
pnl = _safe_float(row.get("realized_pnl"))
local_prem = _safe_float(local.get("premium_paid"))
local_pnl = _safe_float(local.get("realized_pnl"))
# 交易所缺数或被两位小数抹成 0 时,用本地币本位落库值
if (prem is None or abs(prem) < 1e-10) and local_prem is not None and abs(local_prem) > 0:
row["premium_paid"] = local_prem
if (pnl is None or abs(pnl) < 1e-10) and local_pnl is not None and abs(local_pnl) > 0:
row["realized_pnl"] = local_pnl
if not row.get("premium_ccy") and local.get("premium_ccy"):
row["premium_ccy"] = local.get("premium_ccy")
if not row.get("margin_mode") and local.get("margin_mode"):
row["margin_mode"] = local.get("margin_mode")
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=ccy)
return row return row
@@ -48,6 +109,7 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
str(r["history_key"]) str(r["history_key"])
for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall()
} }
local_closed = _load_local_closed_by_inst(conn)
for p in raw_live: for p in raw_live:
inst = str(p.get("instId") or "").strip() inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None premium_override = sum_open_premium_paid(conn, inst) if inst else None
@@ -66,15 +128,16 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
except (TypeError, ValueError): except (TypeError, ValueError):
open_ms = None open_ms = None
items.append(format_live_option_history_row(row, open_ms=open_ms)) items.append(format_live_option_history_row(row, open_ms=open_ms))
hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
row = format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)
items.append(_overlay_local_closed(row, local_closed.get(inst_id)))
finally: finally:
conn.close() conn.close()
hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult))
open_rows = [x for x in items if x.get("status") == "open"] open_rows = [x for x in items if x.get("status") == "open"]
closed = [x for x in items if x.get("status") != "open"] closed = [x for x in items if x.get("status") != "open"]
closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True) closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True)
+92 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
@@ -16,6 +17,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
if not ok: if not ok:
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"} return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
try: try:
from lib.options.options_position_limit_lib import options_max_active_positions
from lib.options.options_positions_lib import build_display_option_positions from lib.options.options_positions_lib import build_display_option_positions
raw = cfg["fetch_option_positions"](ex) raw = cfg["fetch_option_positions"](ex)
@@ -27,18 +29,36 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn) target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn) hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values()) target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
{
"inst_id": pe.get("inst_id"),
"exit_mode": "profit_exit",
"profit_exit_mult": pe.get("profit_exit_mult"),
"profit_exit_enabled": True,
}
)
for p in positions: for p in positions:
mon = tgt_map.get(str(p.get("inst_id") or "")) mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon: if mon:
p["target_index"] = mon.get("target_index") p["target_index"] = mon.get("target_index")
p["target_monitor_id"] = mon.get("id") p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon p["target_monitor"] = mon
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
if pe:
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target: if hedge_target:
p["hedge_plan_target"] = hedge_target p["hedge_plan_target"] = hedge_target
@@ -65,18 +85,77 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn.close() conn.close()
except Exception: except Exception:
target_monitors = [] target_monitors = []
from lib.options.options_positions_lib import net_pnl_from_display_row from lib.options.options_positions_lib import display_pnl_from_option_row
upl_total = 0.0 upl_total = 0.0
has_upl = False has_upl = False
for p in positions: for p in positions:
# 与持仓卡「净盈亏」一致(买一回收−权利金);不用交易所标记价 upl # 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
net = net_pnl_from_display_row(p) pnl = display_pnl_from_option_row(p)
if net is None: if pnl is None:
continue continue
has_upl = True has_upl = True
upl_total += float(net) upl_total += float(pnl)
bal = cfg["fetch_options_balances"](ex) bal = cfg["fetch_options_balances"](ex)
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
normalize_options_margin_mode,
premium_ccy_for_mode,
)
margin_mode = normalize_options_margin_mode()
for p in positions:
mid = str(p.get("inst_id") or "")
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode
p["margin_mode"] = row_mode
p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode(
row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH")
)
p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = None
try:
from lib.exchange.okx_options_lib import fetch_index_price
options_index_px = fetch_index_price(ex, underly)
except Exception:
options_index_px = None
if options_index_px is None:
for p in positions:
try:
px = float(p.get("idx_px") or p.get("idxPx") or 0)
except (TypeError, ValueError):
px = 0
if px > 0:
options_index_px = px
break
coin_budget = None
bridge_status = None
open_bridges = []
if is_coin_margin_mode():
try:
from lib.options.options_coin_open_lib import coin_budget_preview
coin_budget = coin_budget_preview(cfg, ex)
except Exception:
coin_budget = None
try:
conn_b = cfg["get_db"]()
try:
from lib.options.options_spot_bridge_lib import list_open_bridges
open_bridges = list_open_bridges(conn_b)
if open_bridges:
bridge_status = str(open_bridges[0].get("status") or "")
finally:
conn_b.close()
except Exception:
open_bridges = []
return { return {
"ok": True, "ok": True,
"enabled": True, "enabled": True,
@@ -93,6 +172,14 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
"stats": {}, "stats": {},
"trade_budget": cfg.get("trade_budget"), "trade_budget": cfg.get("trade_budget"),
"account_label": cfg.get("account_label") or "OKX期权", "account_label": cfg.get("account_label") or "OKX期权",
"max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": underly,
"options_index_px": options_index_px,
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
} }
except Exception as e: except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)} return {"ok": False, "enabled": True, "msg": str(e)}
+325
View File
@@ -0,0 +1,325 @@
"""OKX 单笔期权本位模式与币本位 USDT 预算."""
from __future__ import annotations
import os
from typing import Any
MODE_USDC = "usdc"
MODE_COIN = "coin"
def _env_bool(name: str, default: bool = False) -> bool:
v = (os.getenv(name) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on", "y")
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def normalize_options_margin_mode(raw: Any = None) -> str:
"""返回 usdc | coin;未配置时默认币本位."""
if raw is None:
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
v = str(raw or MODE_COIN).strip().lower()
if v in ("usdc", "usdc_margin", "usd_margin", "u本位", "u"):
return MODE_USDC
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
return MODE_COIN
# 空串或未知值:默认币本位
if not v:
return MODE_COIN
return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool:
return normalize_options_margin_mode(raw) == MODE_COIN
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
u = (underlying or "ETH").strip().upper() or "ETH"
mode = normalize_options_margin_mode(margin_mode)
if mode == MODE_COIN:
return f"{u}-USD"
return f"{u}-USD_UM"
def margin_mode_from_inst_id(inst_id: str) -> str:
inst = (inst_id or "").strip().upper()
if not inst:
return normalize_options_margin_mode()
if "_UM" in inst:
return MODE_USDC
# ETH-USD-260701-2500-C / BTC-USD-...
if "-USD-" in inst and "_UM" not in inst:
return MODE_COIN
return normalize_options_margin_mode()
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
return (underlying or "ETH").strip().upper() or "ETH"
return "USDC"
def spot_quote_inst_id(underlying: str) -> str:
"""现货市价买卖: ETH-USDT / BTC-USDT."""
u = (underlying or "ETH").strip().upper() or "ETH"
return f"{u}-USDT"
def compute_coin_budget_usdt(
trading_usdt: float,
*,
compound: bool | None = None,
buffer: float | None = None,
fixed_budget_usdt: float | None = None,
max_enabled: bool | None = None,
max_usdt: float | None = None,
) -> dict[str, Any]:
"""
币本位单笔 USDT 预算.
复利开: trading_usdt × buffer; 复利关: fixed × buffer.
上限开: min(..., max_usdt).
"""
bal = max(0.0, float(trading_usdt or 0))
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if buf <= 0:
buf = 0.95
fixed = (
float(fixed_budget_usdt)
if fixed_budget_usdt is not None
else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0)
)
if use_compound:
raw = bal * buf
source = "compound"
else:
raw = max(0.0, fixed) * buf
source = "fixed"
capped = False
max_on = (
_env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False)
if max_enabled is None
else bool(max_enabled)
)
max_n = (
float(max_usdt)
if max_usdt is not None
else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0)
)
budget = raw
if max_on and max_n > 0 and budget > max_n:
budget = max_n
capped = True
return {
"ok": budget > 0,
"budget_usdt": round(budget, 8),
"raw_usdt": round(raw, 8),
"trading_usdt": round(bal, 8),
"buffer": buf,
"compound": use_compound,
"source": source,
"max_enabled": max_on,
"max_usdt": max_n if max_on else None,
"capped_by_max": capped,
"msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算",
}
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
if v <= 0:
return 1.10
if v < 1.0:
return 1.0 + v
return v
def plan_coin_open_by_budget(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
budget_usdt: float,
index_px: float,
ask_sz: float | None = None,
spot_buy_buffer: float | None = None,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""
先按预算/卖一估最大可开张数,再按权利金 × 现货缓冲算应买现货 USDT.
不全额把预算换成币.
"""
import math
from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
budget = max(0.0, float(budget_usdt or 0))
idx = float(index_px or 0)
buf = normalize_coin_spot_buy_buffer(spot_buy_buffer)
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "buy_usdt": 0.0}
if idx <= 0:
return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "sheets": 0, "buy_usdt": 0.0}
if budget <= 0:
return {"ok": False, "msg": "USDT 预算无效", "sheets": 0, "buy_usdt": 0.0}
per_sheet_coin = ask * mult
# 每张开仓需买的币(含缓冲)及其约合 USDT
per_sheet_buy_coin = per_sheet_coin * buf
per_sheet_usdt = per_sheet_buy_coin * idx
if per_sheet_usdt <= 0:
return {"ok": False, "msg": "无法计算单张买币成本", "sheets": 0, "buy_usdt": 0.0}
max_by_budget = int(math.floor((budget / per_sheet_usdt) + 1e-12))
if target_sheets is not None:
try:
want = int(target_sheets)
except (TypeError, ValueError):
want = 0
if want < need:
return {
"ok": False,
"msg": f"指定张数无效(需≥{need})",
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
}
sheets = min(want, max_by_budget)
if sheets < want:
return {
"ok": False,
"msg": (
f"预算约可开 {max_by_budget} 张(含现货缓冲×{buf:g}),"
f"不足指定 {want}"
),
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
"spot_buy_buffer": buf,
}
else:
sheets = max_by_budget
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=need)
ask_depth_capped = False
if capped is None:
return {
"ok": False,
"msg": cap_msg or "卖一深度不足",
"sheets": 0,
"buy_usdt": 0.0,
"spot_buy_buffer": buf,
}
if int(capped) < sheets:
sheets = int(capped)
ask_depth_capped = True
if sheets < need:
return {
"ok": False,
"msg": (
f"预算不足,无法买入 {need}"
f"(单张约需 {per_sheet_usdt:.4f} USDT,含现货缓冲×{buf:g})"
),
"sheets": sheets,
"buy_usdt": 0.0,
"per_sheet_usdt": round(per_sheet_usdt, 8),
"spot_buy_buffer": buf,
"max_by_budget": max_by_budget,
}
premium_coin = sheets * per_sheet_coin
buy_coin = premium_coin * buf
buy_usdt = min(budget, buy_coin * idx)
# 再保险:向下对齐,避免浮点导致略超预算
buy_usdt = min(budget, round(buy_usdt, 8))
out = {
"ok": True,
"msg": "" if not ask_depth_capped else (cap_msg or f"已按卖一深度限制为 {sheets}"),
"sheets": sheets,
"eth_amount": round(sheets * mult, 8),
"coin_premium": round(premium_coin, 8),
"total_premium": round(premium_coin, 8),
"per_sheet_coin": per_sheet_coin,
"buy_coin": round(buy_coin, 8),
"buy_usdt": round(buy_usdt, 8),
"budget_usdt": round(budget, 8),
"spot_buy_buffer": buf,
"index_px": idx,
"max_by_budget": max_by_budget,
"ask_depth_capped": ask_depth_capped,
"est_note": (
f"按最大可开 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;"
f"不全额兑换预算"
),
}
if target_sheets is not None:
out["est_note"] = (
f"指定 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;不全额兑换"
)
out["target_sheets"] = int(target_sheets)
return out
def calc_sheets_from_coin_balance(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
coin_available: float,
) -> dict[str, Any]:
"""用可用标的币尽量开满(权利金以币计)."""
import math
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
coin = max(0.0, float(coin_available or 0))
# 留一点手续费/精度缓冲,避免算满张后下单 51008
coin_eff = coin * 0.97
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0}
per_sheet = ask * mult
if per_sheet <= 0:
return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0}
sheets = int(math.floor((coin_eff / per_sheet) + 1e-12))
if sheets < need:
return {
"ok": False,
"msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})",
"sheets": sheets,
"coin_premium": round(sheets * per_sheet, 8),
"per_sheet_coin": per_sheet,
}
prem = sheets * per_sheet
return {
"ok": True,
"msg": "",
"sheets": sheets,
"coin_premium": round(prem, 8),
"per_sheet_coin": per_sheet,
"eth_amount": round(sheets * mult, 8),
}
+60 -12
View File
@@ -30,16 +30,23 @@ def build_profit_alert_message(
upl: float, upl: float,
upl_ratio: float | None, upl_ratio: float | None,
bid: float | None, bid: float | None,
premium_ccy: str | None = None,
) -> str: ) -> str:
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id, premium_ccy=premium_ccy)
d = 6 if ccy in ("ETH", "BTC") else 4
pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "" pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else ""
bid_txt = f"{bid:.4f}" if bid is not None else "" bid_txt = f"{bid:.{d}f}" if bid is not None else ""
mode = "币本位" if ccy != "USDC" else "USDC"
return "\n".join( return "\n".join(
[ [
"【OKX期权·翻倍提醒】", "【OKX期权·翻倍提醒】",
f"账户:{account_label}", f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}", f"合约:{inst_id}",
f"已付权利金:{premium_paid:.4f} USDC", f"已付权利金:{premium_paid:.{d}f} {ccy}",
f"未实现盈亏:{upl:+.4f} USDC({pct})", f"未实现盈亏:{upl:+.{d}f} {ccy}({pct})",
f"当前买一:{bid_txt}(可考虑限价平仓锁利)", f"当前买一:{bid_txt}(可考虑限价平仓锁利)",
] ]
) )
@@ -142,11 +149,13 @@ def _created_at_ms(created_at: Any) -> int | None:
def _group_key_for_closed_trade(row: Any) -> str: def _group_key_for_closed_trade(row: Any) -> str:
inst = str(row["inst_id"] or "").strip() inst = str(row["inst_id"] or "").strip()
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
if ord_id:
return f"{inst}|ord:{ord_id}"
closed = str(row["closed_at"] or "").strip() closed = str(row["closed_at"] or "").strip()
return f"{inst}|close:{(closed[:16] if closed else '')}" close_prefix = closed[:16] if closed else ""
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
# 即使 close_ord_id/posId 相同,也要按平仓时间拆开(OKX 可能复用 posId)
if ord_id:
return f"{inst}|ord:{ord_id}|close:{close_prefix}"
return f"{inst}|close:{close_prefix}"
def backfill_closed_options_realized_pnl_from_history( def backfill_closed_options_realized_pnl_from_history(
@@ -170,7 +179,8 @@ def backfill_closed_options_realized_pnl_from_history(
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, inst_id, sheets, premium_paid, realized_pnl, created_at, closed_at, close_ord_id SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote,
created_at, closed_at, close_ord_id
FROM options_trades FROM options_trades
WHERE status = 'closed' WHERE status = 'closed'
ORDER BY id DESC ORDER BY id DESC
@@ -193,13 +203,26 @@ def backfill_closed_options_realized_pnl_from_history(
inst = str(group[0]["inst_id"] or "").strip() inst = str(group[0]["inst_id"] or "").strip()
open_candidates = [_created_at_ms(r["created_at"]) for r in group] open_candidates = [_created_at_ms(r["created_at"]) for r in group]
open_ms = min((x for x in open_candidates if x is not None), default=None) open_ms = min((x for x in open_candidates if x is not None), default=None)
close_info = resolve_option_close_from_history(by_inst.get(inst) or [], open_ms=open_ms) close_candidates = [_created_at_ms(r["closed_at"]) for r in group]
close_ms = max((x for x in close_candidates if x is not None), default=None)
sheets_hint = None
try:
sheets_hint = sum(float(_safe_float(r["sheets"]) or 0.0) for r in group) or None
except (TypeError, ValueError):
sheets_hint = None
close_info = resolve_option_close_from_history(
by_inst.get(inst) or [],
open_ms=open_ms,
close_ms=close_ms,
sheets=sheets_hint,
)
if not close_info: if not close_info:
continue continue
ex_pnl = _safe_float(close_info.get("realized_pnl")) ex_pnl = _safe_float(close_info.get("realized_pnl"))
if ex_pnl is None: if ex_pnl is None:
continue continue
close_quote = _safe_float(close_info.get("close_quote")) close_quote = _safe_float(close_info.get("close_quote"))
matched_pos = str(close_info.get("pos_id") or "").strip() or None
total_paid = 0.0 total_paid = 0.0
for r in group: for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0) total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
@@ -215,7 +238,14 @@ def backfill_closed_options_realized_pnl_from_history(
share = round(float(ex_pnl) / len(group), 4) share = round(float(ex_pnl) / len(group), 4)
allocated += share allocated += share
local = _safe_float(r["realized_pnl"]) local = _safe_float(r["realized_pnl"])
if local is not None and abs(local - share) < 1e-6: local_close = _safe_float(r["close_quote"])
local_ord = str(r["close_ord_id"] or "").strip()
pnl_ok = local is not None and abs(local - share) < 1e-6
quote_ok = close_quote is None or (
local_close is not None and abs(local_close - float(close_quote)) < 1e-6
)
ord_ok = (not matched_pos) or (local_ord == matched_pos)
if pnl_ok and quote_ok and ord_ok:
continue continue
prem_recv = round(paid + share, 4) prem_recv = round(paid + share, 4)
conn.execute( conn.execute(
@@ -223,10 +253,11 @@ def backfill_closed_options_realized_pnl_from_history(
UPDATE options_trades UPDATE options_trades
SET realized_pnl = ?, SET realized_pnl = ?,
premium_received = ?, premium_received = ?,
close_quote = COALESCE(?, close_quote) close_quote = COALESCE(?, close_quote),
close_ord_id = COALESCE(?, close_ord_id)
WHERE id = ? WHERE id = ?
""", """,
(share, prem_recv, close_quote, int(r["id"])), (share, prem_recv, close_quote, matched_pos, int(r["id"])),
) )
updated += 1 updated += 1
return updated return updated
@@ -404,6 +435,8 @@ def options_monitor_loop(
profit_ratio: float, profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None, sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None, target_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None, stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
stop_event: Any = None, stop_event: Any = None,
) -> None: ) -> None:
@@ -435,6 +468,21 @@ def options_monitor_loop(
account_label=account_label, account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label}, cfg={"send_wechat": send_wechat, "account_label": account_label},
) )
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
run_options_profit_exits(
conn,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
if sync_trades_fn is not None: if sync_trades_fn is not None:
sync_trades_fn(conn) sync_trades_fn(conn)
conn.commit() conn.commit()
+119 -7
View File
@@ -1,4 +1,4 @@
"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).""" """OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).支持 USDC / 币本位(ETH/BTC)."""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
@@ -23,6 +23,63 @@ def _opt_type_label(opt_type: Any) -> str:
return t or "" return t or ""
def resolve_options_premium_ccy(
*,
inst_id: str = "",
underlying: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
row: dict[str, Any] | None = None,
) -> str:
"""权利金计价币种:USDC 或 ETH/BTC."""
raw = premium_ccy
if (raw is None or str(raw).strip() == "") and row:
raw = row.get("premium_ccy")
ccy = str(raw or "").strip().upper()
if ccy:
return ccy
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
mid = str(inst_id or (row or {}).get("inst_id") or "").strip()
mode = margin_mode if margin_mode is not None else (row or {}).get("margin_mode")
if mode is None and mid:
mode = margin_mode_from_inst_id(mid)
u = str(
underlying
or (row or {}).get("underlying")
or (mid.split("-")[0] if mid else "ETH")
or "ETH"
).strip().upper() or "ETH"
return premium_ccy_for_mode(str(mode or "usdc"), u)
except Exception:
return "USDC"
def _amount_decimals(ccy: str) -> int:
c = (ccy or "USDC").strip().upper()
if c in ("ETH", "BTC"):
return 6
return 4
def _mode_tag(*, inst_id: str = "", premium_ccy: str = "", margin_mode: Any = None) -> str:
ccy = (premium_ccy or "").strip().upper()
if ccy and ccy != "USDC":
return "币本位"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode, margin_mode_from_inst_id
mode = margin_mode
if mode is None and inst_id:
mode = margin_mode_from_inst_id(inst_id)
if is_coin_margin_mode(mode):
return "币本位"
except Exception:
pass
return "USDC"
def ensure_options_notify_columns(conn: sqlite3.Connection) -> None: def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
for ddl in ( for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
@@ -57,10 +114,21 @@ def build_options_open_message(
target_index: Any = None, target_index: Any = None,
signal_note: str = "", signal_note: str = "",
trade_id: Any = None, trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str: ) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [ lines = [
"【OKX期权·开仓】", "【OKX期权·开仓】",
f"账户:{account_label or 'OKX期权'}", f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
] ]
if trade_id is not None: if trade_id is not None:
lines.append(f"本地单号:#{trade_id}") lines.append(f"本地单号:#{trade_id}")
@@ -69,8 +137,8 @@ def build_options_open_message(
f"合约:{inst_id}", f"合约:{inst_id}",
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}", f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"张数:{sheets if sheets is not None else ''}", f"张数:{sheets if sheets is not None else ''}",
f"开仓报价:{_fmt(open_quote)} USDC", f"开仓报价:{_fmt(open_quote, d)} {ccy}",
f"权利金:{_fmt(premium_paid)} USDC", f"权利金:{_fmt(premium_paid, d)} {ccy}",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if target_index is not None and str(target_index).strip() != "":
@@ -98,10 +166,21 @@ def build_options_close_message(
target_index: Any = None, target_index: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
trade_id: Any = None, trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str: ) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [ lines = [
"【OKX期权·平仓】", "【OKX期权·平仓】",
f"账户:{account_label or 'OKX期权'}", f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
] ]
if trade_id is not None: if trade_id is not None:
lines.append(f"本地单号:#{trade_id}") lines.append(f"本地单号:#{trade_id}")
@@ -111,9 +190,9 @@ def build_options_close_message(
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}", f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"原因:{(reason or '平仓').strip()}", f"原因:{(reason or '平仓').strip()}",
f"张数:{sheets if sheets is not None else ''}", f"张数:{sheets if sheets is not None else ''}",
f"平仓报价:{_fmt(close_quote)} USDC", f"平仓报价:{_fmt(close_quote, d)} {ccy}",
f"已付/收回:{_fmt(premium_paid)} / {_fmt(premium_received)} USDC", f"已付/收回:{_fmt(premium_paid, d)} / {_fmt(premium_received, d)} {ccy}",
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", f"实现盈亏:{_fmt(realized_pnl, d)} {ccy}",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if target_index is not None and str(target_index).strip() != "":
@@ -142,15 +221,26 @@ def notify_options_open(
open_quote: Any = None, open_quote: Any = None,
target_index: Any = None, target_index: Any = None,
signal_note: str = "", signal_note: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
) -> bool: ) -> bool:
ensure_options_notify_columns(conn) if conn is not None else None ensure_options_notify_columns(conn) if conn is not None else None
row_ccy = premium_ccy
row_mode = margin_mode
if conn is not None and trade_id is not None: if conn is not None and trade_id is not None:
row = conn.execute( row = conn.execute(
"SELECT wechat_open_sent FROM options_trades WHERE id=?", "SELECT wechat_open_sent, premium_ccy, margin_mode, underlying FROM options_trades WHERE id=?",
(int(trade_id),), (int(trade_id),),
).fetchone() ).fetchone()
if row and int(row["wechat_open_sent"] or 0): if row and int(row["wechat_open_sent"] or 0):
return False return False
if row:
if row_ccy is None:
row_ccy = row["premium_ccy"] if "premium_ccy" in row.keys() else None
if row_mode is None:
row_mode = row["margin_mode"] if "margin_mode" in row.keys() else None
if not underlying:
underlying = str(row["underlying"] or "") if "underlying" in row.keys() else underlying
msg = build_options_open_message( msg = build_options_open_message(
account_label=str(cfg.get("account_label") or "OKX期权"), account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id, inst_id=inst_id,
@@ -162,6 +252,8 @@ def notify_options_open(
target_index=target_index, target_index=target_index,
signal_note=signal_note, signal_note=signal_note,
trade_id=trade_id, trade_id=trade_id,
premium_ccy=row_ccy,
margin_mode=row_mode,
) )
ok = notify_options_send(cfg, msg) ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None: if ok and conn is not None and trade_id is not None:
@@ -197,6 +289,8 @@ def notify_options_close(
close_quote: Any = None, close_quote: Any = None,
target_index: Any = None, target_index: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
force: bool = False, force: bool = False,
) -> bool: ) -> bool:
"""平仓必发.默认按 trade_id / 同合约未标记行幂等.""" """平仓必发.默认按 trade_id / 同合约未标记行幂等."""
@@ -230,6 +324,18 @@ def notify_options_close(
).fetchone() ).fetchone()
if q2: if q2:
rows = [dict(q2)] rows = [dict(q2)]
if not rows:
# 已有平仓记录且均已推送:幂等跳过,避免再走「无库行」重复推
exists = conn.execute(
"""
SELECT 1 FROM options_trades
WHERE inst_id=? AND status='closed'
LIMIT 1
""",
(inst_id,),
).fetchone()
if exists:
return False
if rows: if rows:
# 同次平仓可能多腿:合并一条推送,逐条标记 # 同次平仓可能多腿:合并一条推送,逐条标记
@@ -259,6 +365,8 @@ def notify_options_close(
target_index=target_index, target_index=target_index,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=head.get("id") if len(rows) == 1 else None, trade_id=head.get("id") if len(rows) == 1 else None,
premium_ccy=premium_ccy or head.get("premium_ccy"),
margin_mode=margin_mode or head.get("margin_mode"),
) )
ok = notify_options_send(cfg, msg) ok = notify_options_send(cfg, msg)
if ok and conn is not None: if ok and conn is not None:
@@ -288,6 +396,8 @@ def notify_options_close(
target_index=target_index, target_index=target_index,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=trade_id, trade_id=trade_id,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
) )
return notify_options_send(cfg, msg) return notify_options_send(cfg, msg)
@@ -327,4 +437,6 @@ def notify_options_close_trade_ids(
premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None), premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None),
realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None), realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None),
close_quote=first.get("close_quote"), close_quote=first.get("close_quote"),
premium_ccy=first.get("premium_ccy"),
margin_mode=first.get("margin_mode"),
) )
+144
View File
@@ -0,0 +1,144 @@
"""OKX 期权持仓笔数上限(env: OKX_OPTIONS_MAX_ACTIVE_POSITIONS)."""
from __future__ import annotations
import os
from typing import Any, Optional, Sequence
def options_max_active_positions() -> int:
"""同时持有的期权合约笔数上限;0=不限制.热更读 env."""
raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "0")
try:
v = int(float(str(raw).strip()))
except (TypeError, ValueError):
return 0
return max(0, v)
def count_live_option_positions(rows: Optional[list[dict[str, Any]]]) -> int:
if not rows:
return 0
n = 0
for r in rows:
if not isinstance(r, dict):
continue
try:
pos = float(r.get("pos") or 0)
except (TypeError, ValueError):
continue
if abs(pos) >= 1e-12:
n += 1
return n
def _inst_already_open(rows: list[dict[str, Any]], inst_id: str) -> bool:
want = (inst_id or "").strip()
if not want:
return False
for r in rows:
if str(r.get("instId") or r.get("inst_id") or "").strip() == want:
return True
return False
def _normalize_inst_ids(
opening_inst_id: str = "",
opening_inst_ids: Optional[Sequence[str]] = None,
) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in list(opening_inst_ids or []) + ([opening_inst_id] if opening_inst_id else []):
iid = str(raw or "").strip()
if not iid or iid in seen:
continue
seen.add(iid)
out.append(iid)
return out
def option_position_limit_block_msg(
ex: Any,
*,
opening_inst_id: str = "",
opening_inst_ids: Optional[Sequence[str]] = None,
new_positions: Optional[int] = None,
max_active: Optional[int] = None,
fetch_positions=None,
) -> Optional[str]:
"""若禁止新开买期权则返回中文原因,否则 None.
- max_active<=0:不限制
- opening_inst_ids:本次要开的合约;已在持仓中的不占新笔数
- new_positions:显式指定还需新占几笔(默认按 opening_inst_ids 推算)
- 期期两腿应一次传入两个 inst_id,在开仓前预检,避免上限=1 时开出半边仓
- 拉持仓失败:拒绝开仓(避免绕过上限)
"""
try:
from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed
# 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限
if max_active is None and not standalone_options_open_allowed():
return None
except Exception:
pass
mx = options_max_active_positions() if max_active is None else int(max_active)
if mx <= 0:
return None
fetch = fetch_positions
if fetch is None:
from lib.exchange.okx_options_lib import fetch_option_positions
fetch = fetch_option_positions
try:
rows = fetch(ex)
except Exception:
rows = None
if rows is None:
return f"无法获取期权持仓,暂不可开仓(上限 {mx} 笔)"
active = count_live_option_positions(rows)
ids = _normalize_inst_ids(opening_inst_id, opening_inst_ids)
if new_positions is None:
if ids:
already = sum(1 for i in ids if _inst_already_open(rows, i))
need = max(0, len(ids) - already)
else:
need = 1
else:
need = max(0, int(new_positions))
if need <= 1 and len(ids) == 1 and _inst_already_open(rows, ids[0]):
return None
if need <= 0:
return None
if active + need <= mx:
return None
if need >= 2:
return (
f"期期对冲需新开 {need} 笔期权,当前已有 {active} 笔、上限 {mx};"
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
)
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
def compound_full_single_position_block_msg(
ex: Any,
*,
fetch_positions=None,
) -> Optional[str]:
"""全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔)."""
fetch = fetch_positions
if fetch is None:
from lib.exchange.okx_options_lib import fetch_option_positions
fetch = fetch_option_positions
try:
rows = fetch(ex)
except Exception:
rows = None
if rows is None:
return "无法获取期权持仓,全仓复利模式暂不可开仓"
active = count_live_option_positions(rows)
if active >= 1:
return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓"
return None

Some files were not shown because too many files have changed in this diff Show More