123 Commits

Author SHA1 Message Date
dekun 4e14c92385 fix期权链 Delta 改为从 opt-summary 拉取。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 22:20:08 +08:00
dekun 627c27a6f8 feat期权链与持仓展示 Delta。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 22:14:07 +08:00
dekun 48f544b1f5 feat快照上报期权标的白名单,供中控条件单按币种过滤。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 19:18:52 +08:00
dekun 1835a79698 feat期权标的白名单:可固定只显示 ETH 或 BTC。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 19:09:06 +08:00
dekun 8d846794a2 chore添加著作权声明(马建军,仅源码与 COPYRIGHT)。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:14:08 +08:00
dekun dae5ef1146 chore清理过时审计报告与无关更新文档。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:56:12 +08:00
dekun 67d087277a feat中控条件单可指令实例账户内划转,Bridge 暴露划转接口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 14:30:26 +08:00
dekun 3d3253247e fix期权复盘刷新改为软更新,避免整页闪动。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:55:25 +08:00
dekun b62107fbd5 fix期权复盘同步后无变化不重绘,避免进页闪动。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:52:35 +08:00
dekun 03acecf015 fix期权交易记录列名改为期权盈亏,并补充曲线审计报告。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:42:26 +08:00
dekun 117f595b54 feat中控桥接暴露已实现盈亏曲线接口,供中控数据统计拉取。
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:06:16 +08:00
dekun b31f861596 feat数据统计曲线改为累计已实现盈亏,划转提现不再影响。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:55:41 +08:00
dekun 11c67b3c44 feat数据统计曲线改为权益采样,现金加现货币权利金与浮盈。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:52:00 +08:00
dekun fc0d76d174 fix复盘真盈亏改为期权币盈亏×平仓价,不再绑卖回与初始权利金差。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:39:15 +08:00
dekun 839baea375 fix真盈亏收口为币本位×卖回价减初始权利金U,禁指数冒充平仓价。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:29:33 +08:00
dekun d42e1c7edf fix模拟盘流水重算卖回VWAP时同秒多笔配对去重,避免USDT重复计入。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:02:04 +08:00
dekun 57ca5ebc8f fix模拟盘流水重算VWAP按合约分段,避免两笔卖回混算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:56:47 +08:00
dekun 11a083470d fix卖回VWAP必须落库并覆盖指数占位价,禁止预算反推开仓价。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:55:42 +08:00
dekun dd979d9dd0 fix真盈亏只认历史币盈亏不再另扣费,刷新按固定量价重算;修模拟到期币盈亏。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 11:30:07 +08:00
dekun aa8b16ce27 fix开仓价卖回价与真盈亏一次冻结,复盘同步不再反复改写。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:56:53 +08:00
dekun 1aa279f191 fix卖回未完成禁止用指数提前结算,并纠正买卖VWAP回退。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:50:50 +08:00
dekun 53a624633d fix多笔买/卖回用VWAP计入真盈亏,卖完再结算并标记待卖回。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:38:46 +08:00
dekun 42a7ee9961 fix无法按新真盈亏公式更新的旧单标记为缺价未核。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:23:36 +08:00
dekun 3416996ae5 fix复盘真盈亏按权利金USDT波动公式计算,忽略现货多买。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:16:44 +08:00
dekun c2c69e580c fix真盈亏只认卖光收回减买币,禁止权利金指数冒充。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 10:03:06 +08:00
dekun 09989d2709 fix复盘交易记录改回真盈亏A,期权历史仅记期权腿不含现货。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 09:55:34 +08:00
dekun 78f18e731a fix模拟盘期权手续费按OKX币本位公式计费,并纠正复盘旧单展示。
费率作用在标的币数量上并以权利金7%封顶,不再误用权利金×费率。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 09:37:14 +08:00
dekun bdc9eb532a fix复盘交易记录盈亏与数据统计同口径,并展示期权手续费。
列表盈亏改为币盈亏×卖回价;手续费仅计期权开/平,不含现货。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 09:29:40 +08:00
dekun fe08fa5270 fix历史修复脚本:桥封顶记录不再反复清空已恢复的正盈亏。
当账户A已与统计B接近时接受A;否则用口径B回填复盘冻结。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 09:18:03 +08:00
dekun 9d77a430e5 fix期权复盘与数据统计双口径盈亏,并修复卖回封顶导致盈利单冻成亏损。
平仓卖光可卖币后再按账户差分结算复盘;数据统计改为历史币盈亏×卖回价;复盘展示初始权利金与盈利率,并附历史修复脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 09:10:13 +08:00
dekun d4de6a23ae docs新增系统架构与技术实现说明。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 08:50:03 +08:00
dekun 9037e80376 docs补齐期权主链条系统分析,并重写详细使用说明。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 01:09:55 +08:00
dekun 678833be45 fix币本位下单保留按可用余额打满,不再被归一成指定张数。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 00:48:20 +08:00
dekun ed4c650e4b fix期权下单默认按可用余额打满;持仓上限/缓冲/门控秒数默认值调整。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 00:46:54 +08:00
dekun 549253440a fix顶栏总交易/胜率/盈亏比与账户结算口径对齐,新单仅计已冻结。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 00:39:35 +08:00
dekun 260bfd1f29 feat期权盈亏按交易账户快照结算:一次一仓、持仓禁划转、卖回后冻结。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 00:29:47 +08:00
dekun e0e2a94322 revert期权历史仍按币展示盈亏,U 口径只用于统计结算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:58:02 +08:00
dekun 70ebfb10e8 fix模拟盘盈亏:收回×卖回价 − 开仓U权利金 − 手续费,历史按U展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:56:23 +08:00
dekun a0ee1fa8a0 fix模拟盘盈亏:按权利金净额×平仓指数,不再把买币预算差额当盈利。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:49:02 +08:00
dekun a16dd4f9cc fix模拟盘复盘盈亏跟盘跳动:优先用冻结现金 U,不再每次用实时指数重算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:43:46 +08:00
dekun 3f621835a9 fix实盘统计超时:先拉交易所/指数再短事务写冻结,避免握库锁拖死中控。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:35:39 +08:00
dekun bb9d14dd73 模拟盘统计改为现货买币/卖回的真实 USDT 净盈亏,对齐钱包。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:24:18 +08:00
dekun 7943cd2167 实盘数据统计改用期权历史交易所盈亏;模拟盘仍走本地结算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:20:16 +08:00
dekun d05629c362 修复币本位统计:开仓成本按预算/开仓指数计,不再用平仓价重估。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:11:45 +08:00
dekun 2369aae362 修复已实现盈亏重复扣手续费,并自动纠正偏亏冻结值。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:03:31 +08:00
dekun 5e8b578f75 统计已实现盈亏扣手续费,并按平仓时指数冻结为 U。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 22:50:21 +08:00
dekun e8f8a76753 修复顶栏盈亏比:币本位已平盈亏先折 U 再统计。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 22:31:36 +08:00
dekun a45b5d1b0b 修复平仓后查仓失败仍卖币,并恢复期权说明页。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 16:19:18 +08:00
dekun a23fb7cbdc 同步实盘说明文档。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:29:30 +08:00
dekun 23bd59c87f 新增实例数据统计页:复盘 KPI 上、资金曲线下,系统设置可关。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:28:25 +08:00
dekun 48b919bb2b 中控快照增加初始资金字段,供统计页收益率分母使用。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:44:16 +08:00
dekun af4a501f02 复盘表单文案:上一个入场周期改为入场周期。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:55:02 +08:00
dekun d7b3f7a8e7 修复复盘列表先读缓存再后台同步,避免模拟盘握库卡死加载。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:50:04 +08:00
dekun 2158a30179 修复复盘列表与统计并行同步撞锁导致模拟盘加载失败。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:46:38 +08:00
dekun cfcc0596ff 修复复盘交易记录并发迁移 prev_entry_tf 导致加载失败。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:40:33 +08:00
dekun 3328b7fcc1 复盘增加左侧条件单与上一个入场周期,K线生成默认勾选。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:34:15 +08:00
dekun 03dde1853d 修复删除强平模板后 embed 因 TemplateNotFound 整页 500。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:21:48 +08:00
dekun ddacdbc1e8 修复页面握库拉余额导致 database is locked 整页 500。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:11:37 +08:00
dekun c021c38e2d 删除永续下单监控与划转强平,保留关键位监控并完成回归审计。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:04:21 +08:00
dekun 7c5a6e25b2 下线永续监控与 USDC 期权,仅保留币本位期权路径。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:32:01 +08:00
dekun 1a4eb15109 恢复中控快照买一/流动性,与实例同步;未附带 preview 时不再误标残档。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:06:43 +08:00
dekun 77fe0fa679 修复期权币本位高危缺陷:开仓估量、卖回封顶、握库超时与条件单嵌套锁。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:00:08 +08:00
dekun ea3e029eb9 修复粉尘桥拦开仓,并继续消除模拟盘嵌套握锁导致的 database is locked。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 11:28:29 +08:00
dekun 030b237981 粉尘低于现货最小可卖量时忽略且不展示、不再重试卖回。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 11:16:13 +08:00
dekun 80ef5ceccb 修复模拟盘卖币握锁导致 database is locked 与中控超时离线。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 11:04:06 +08:00
dekun 066c90fd96 修复监控握锁卖币导致中控快照超时、实例全部离线。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 10:56:40 +08:00
dekun 265317672c 修复币本位平仓/到期后未自动卖回,并消除模拟盘卖币时数据库锁定。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 10:47:25 +08:00
dekun 474e0742f7 条件单未触发至周四23:00自动取消,下发/取消/开仓均推企业微信。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:45:19 +08:00
dekun a957946cfd 条件单开仓时写入 opened_at,供中控展示开仓时间。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:20:33 +08:00
dekun 95e713e9e6 修复期权到期结算漏推企业微信:页面同步与模拟盘结算一并通知。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:08:49 +08:00
dekun d743185f05 持仓卡与中控快照增加期权杠杆倍数展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 06:47:49 +08:00
dekun 7b432bc964 账户快照缺失后改为 10 秒补打一次,避免短间隔 force 撞频。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 21:50:50 +08:00
dekun 4cad929886 中控内嵌页内容宽度上限 1800 并居中,外框仍铺满工作台。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 21:08:57 +08:00
dekun 30d7b0c872 中控快照附带 BTC/ETH 报价,供总览从客户端回传展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 19:52:55 +08:00
dekun bab26c7205 条件单占用时左侧期权「选择」按钮置灰禁用,避免手动开仓。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:45:23 +08:00
dekun e11651d729 实例期权页增加中控条件单卡片,接单后显示状态与触发参数。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:39:47 +08:00
dekun 8b584a0870 中控版本探测响应增加 trading mode,供实例列表按 sim/live 排序展示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 10:17:04 +08:00
dekun 4bea770f5c 实例增加实盘说明页(API/账户模式/env),系统设置可关闭导航显示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 10:07:05 +08:00
dekun 740c99e910 实例顶栏增加退出登录按钮,便于清会话回到登录页。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 09:44:21 +08:00
dekun 131bb36930 修复实盘顶栏误显示模拟盘会话资金:无交易所余额时显示 —,切模式清缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 09:16:45 +08:00
dekun e33d289e39 带鱼屏客户端内容锁 1800 居中,普通屏全宽;内嵌与独立打开一致。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 07:20:04 +08:00
dekun 07db158656 恢复币本位「重试卖回」常显,带鱼屏内容区改回 1800 居中留白。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 07:15:16 +08:00
dekun 2ab46a6ac3 中控 Bridge 增加远程快速更新:/api/hub/version 与 /api/hub/update。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 07:11:15 +08:00
dekun b79f309c5b 修复「重试卖回」按钮:有持仓时正确隐藏,标的按 BTC/ETH 残留与当前选择显示。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 07:05:13 +08:00
dekun 886ef82573 中控内嵌工作台带鱼屏铺满宽度,独立打开仍保持 1920 居中。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 06:58:51 +08:00
dekun f846f832de 系统设置增加备份恢复:凌晨3点自动备份,保留10天,含 env 与复盘图片。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 06:50:44 +08:00
dekun 9d3effd6fc 修复中控内嵌顶栏变形:整页导航包进 top-nav-links,embed=1 仍进壳。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 08:03:15 +08:00
dekun 2903a3ac7f 中控持仓简报带出场字段:目标指数与利润倍数。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 04:02:34 +08:00
dekun 91ff1cdecc 权利金折 U 优先用开仓 budget_usdt,避免随币价浮动。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 03:23:46 +08:00
dekun a71042c3e1 中控汇总:Bridge 增加窗口化期权统计接口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 02:59:19 +08:00
dekun 6d5b71eb3d 左侧条件单按方向绑定穿越:看跌上穿、看涨下穿。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 02:23:06 +08:00
dekun 2eb6b28a39 中控条件开仓单:客户端本地监控穿越/偏离区并按本机规则开平仓。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 02:13:16 +08:00
dekun 3577a4d2fd 资金占比分母改为余额加权利金,避免已付权利金被漏计。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 16:18:16 +08:00
dekun bb6e9e9041 中控快照增加开仓权利金折U及占账户资金比例。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 16:13:36 +08:00
dekun fcef3b3d37 中控可下发买一全平:客户端新增 /api/hub/options/close,由本地执行并拒绝无效买一。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 16:03:10 +08:00
dekun 34322d1731 修复 BTC 币本位期权净盈亏被 round4 抹成 0 的精度问题。
币本位权利金/回收/盈亏改用 8 位小数,避免小额 BTC 报价显示为 0。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 14:45:54 +08:00
dekun c1f783df9b 中控快照增加买一/流动性字段,便于总览标黄不足仓位。
含买一价、张数、未覆盖张数与残档标记。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 12:39:13 +08:00
dekun 193c02eda8 修复中控内嵌:跨站 Cookie 被拦时用 embed_sso 维持登录。
SSO 写入 sessionStorage,fetch 自动带 X-Hub-Embed-SSO,避免期权链 API 401。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 11:56:51 +08:00
dekun e7a4934068 修复未登录时 API 返回 HTML 导致期权链 JSON 解析失败。
API 统一返回 401 JSON,并改进前端非 JSON 错误提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 11:51:13 +08:00
dekun ed9fd05a84 env 配置为中控 Bridge Token 增加一键复制。
便于从客户端直接复制到中控登记实例。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 11:18:44 +08:00
dekun a4a9634ba8 中控 Bridge Token 为空时自动生成,Origin 改为选填说明。
打开 env 配置或首次部署即可写入 Token,便于复制到中控登记。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 11:16:20 +08:00
dekun 5806fe2f53 系统设置 env 配置增加中控 Bridge Token,便于复制到中控登记。
Token 改为明文可编辑并热更新,同步放开 HUB_EMBED_FRAME_ANCESTORS。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 11:10:35 +08:00
dekun c42529e3fd 对接中控:只读持仓快照 API、SSO 打开链与 iframe 祖先配置。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:54:05 +08:00
dekun e7cafd6dbc 关键位现价改与顶栏同源公开行情,前端无价时回退 nav tickers。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:56:40 +08:00
dekun ee635bcd55 修复关键位现价:模拟盘无密钥时回退 OKX 公开 ticker。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:52:53 +08:00
dekun 43951a701b 关键位监控默认币种改为 BTC/USDT。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:49:40 +08:00
dekun fad85ae710 关键位监控币种改为仅 BTC/USDT、ETH/USDT 下拉,对齐期权标的。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:49:07 +08:00
dekun 23ee7ea2e9 清理 env 保存路径中未使用的 import。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:42:28 +08:00
dekun 6f9e6e2a32 env 锁定币本位并清掉对冲/USDC 配置项。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:42:10 +08:00
dekun 2352a5fbdf env 配置去掉期权/对冲模式与对冲计划区块,仅保留单独期权。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:38:03 +08:00
dekun 2e09814fe5 修复删对冲后持仓接口 NameError:恢复 tgt_map/profit_exit_map 赋值。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:33:54 +08:00
dekun 56df9a596f 从导航显示与顶栏去掉「对冲计划」开关与入口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:28:01 +08:00
dekun 0acba5e351 移除全部对冲计划模块:crypto_okx 改为币本位单独期权执行机。
交易模式锁定 options;okx_trade_mode / 到期盈亏迁入 options;保留期权/桥/关键位/企微与中控预留快照口径。恢复点:backup/before-remove-hedge 与 tag backup-before-remove-hedge-20260930。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:20:21 +08:00
dekun 8a62cfbb48 修复比特币期权浮盈把币数量当成 U:看板/中控改为按指数折 USDT。
币本位 BTC/ETH 净盈亏须 × 指数后再标 U;并兜底从合约推断 premium_ccy,避免缺失时漏折算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 22:01:41 +08:00
dekun 41c85c36a9 澄清翻倍出场文案:按当前倍数显示需回收公式,避免误读成按1倍算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 21:59:25 +08:00
dekun 06f9b23f44 修复翻倍出场达标却不自动平:对冲异常不再整段跳过,并按买一价×全仓判定。
原先对冲表异常会直接 return 0 导致翻倍出场永不执行;可回收改为买一价×全部张数,买一深度不足时仍会触发分批平仓。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 21:54:00 +08:00
dekun a03c208a5f 修复模拟盘期权历史分批平仓盈亏:用卖平成交重算已平记录。
加载历史前按 sim_option_orders(或钱包流水)合计各批收回,纠正只记最后一笔的旧数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:25:31 +08:00
dekun a7015ec5c8 修复期权分批平仓只记最后一笔收回导致盈亏漏算。
每次部分成交累加权利金与张数,全平后再合计 realized_pnl,并优先匹配交易所全平历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:13:05 +08:00
dekun f5104fe68b 修复翻倍/目标位平仓把备注写进平仓时间:纠正 SQL 参数顺序并自动对调历史坏数据。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:48:42 +08:00
dekun c5cfbb34e4 README 增加新机纯实盘部署文档入口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:28:31 +08:00
dekun ea58a4778e 新增新机纯实盘部署文档,并与部署/使用说明互相链接。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:28:16 +08:00
dekun d16821c7b7 模拟盘无 API Key 时复盘K线改为优先走 OKX 公开行情接口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:16:06 +08:00
dekun d6bb99b5bb 移植期权复盘自动K线、时区修复与保存后轮询展示,与今日 crypto_monitor 改动对齐。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:51:41 +08:00
166 changed files with 18461 additions and 20138 deletions
+19 -45
View File
@@ -80,6 +80,8 @@ LIVE_TRADING_ENABLED=true
SIM_DEFAULT_MODE=sim
SIM_INITIAL_EQUITY_USDT=10000
SIM_INITIAL_USDC=0
# 实盘初始资金(U);中控收益率=已结算盈亏/该值。不填则中控回退最早余额采样
LIVE_INITIAL_EQUITY_USDT=
SIM_FEE_RATE=0.0005
# =============================================================================
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2)
@@ -102,11 +104,11 @@ OKX_POSITION_INST_TYPE=SWAP
EXCHANGE_DISPLAY_NAME=OKX
# 企业微信推送里展示的账户备注
# OKX_ACCOUNT_LABEL=
# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧
# 顶栏是否显示 USDT 资金/交易账户(热更);币本位买币仍会显示交易账户可用额
OKX_SHOW_PERP_FUNDS=true
# =============================================================================
# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
# 期权模块(币本位专用;与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
# 详见 docs/期权方案.md 与 docs/期权用法.md
# =============================================================================
OKX_OPTIONS_ENABLED=true
@@ -116,7 +118,7 @@ OKX_OPTIONS_ENABLED=true
# OKX_OPTIONS_API_PASSPHRASE=
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
# 仅币本位期权(USDT买币桥);USDC 期权已下线,此项恒视为 coin
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10
@@ -125,19 +127,14 @@ OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
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=期期对冲
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
# 交易模式固定单独期权(历史 perp_options / options_options 读取时归一为 options)
OKX_TRADE_MODE=options
# 永期对冲改为: OKX_TRADE_MODE=perp_options
# 期期对冲改为: OKX_TRADE_MODE=options_options
# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
# 期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0
# 期权标的白名单(逗号分隔,仅 ETH/BTC)。只配一个则交易页只显示该标的;开仓/链/报价/条件单均校验。资金页不受影响。
OKX_OPTIONS_UNDERLYINGS=ETH,BTC
# 默认选中标的;须落在白名单内,否则取白名单第一项
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
@@ -148,46 +145,15 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
# 开仓成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# 目标平仓门控(目标位触发后按买一平仓前需满足)
OKX_OPTIONS_CLOSE_GATE_MODE=premium
# premium:可回收≥权利金×倍数;net_pnl:净盈亏(买一可回收−权利金)折USDT≥下限
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT=0
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
# =============================================================================
# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md)
# =============================================================================
# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读)
HEDGE_PLAN_ENABLED=false
HEDGE_PLAN_SHOW_PERP_OPTIONS=false
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=false
HEDGE_PLAN_LIVE_ORDER=false
# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换)
HEDGE_PLAN_OPTION_PRIMARY=true
HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
# 期期「做多/做空」拆分口径:budget=按权利金预算(默认);sheets=先算同张数总张数(2n)再按比例拆
HEDGE_PLAN_OO_BIAS_SPLIT_BY=budget
# 期期「做多/做空」主腿占比(0~1,默认 0.7=7:3);做多主腿=Call,做空主腿=Put
HEDGE_PLAN_OO_BIAS_RATIO=0.7
# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更
MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false)
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=false
# =============================================================================
# =============================================================================
#
@@ -313,3 +279,11 @@ WECHAT_TIMEOUT_SECONDS=10
APP_TIMEZONE=Asia/Shanghai
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
# =============================================================================
# 中控对接(crypto_okx_hub;也可在「系统设置 → env 配置 → 中控对接」查看)
# =============================================================================
# 首次部署/打开 env 配置时若为空会自动生成;复制到中控「添加实例」的 Bridge Token
HUB_BRIDGE_TOKEN=
# 选填。允许嵌入本站的中控 Origin(空格分隔);一般留空即可;iframe 被挡时再填,调试可 *
HUB_EMBED_FRAME_ANCESTORS=
+9
View File
@@ -0,0 +1,9 @@
Copyright (c) 2026 马建军. All rights reserved.
本仓库源代码及相关文档的著作权归马建军所有。
未经著作权人书面许可,不得复制、修改、分发、销售或以其他方式使用本软件源码。
Author: 马建军
QQ: 253921841
Email: 253921841@qq.com
Website: https://hyf2.cc
+1 -1
View File
@@ -20,7 +20,7 @@ curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.
安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**.
详见 **[部署文档.md](./部署文档.md)**、**[deploy/README.md](./deploy/README.md)**.
详见 **[部署文档.md](./部署文档.md)**、**[新机纯实盘部署.md](./新机纯实盘部署.md)**、**[deploy/README.md](./deploy/README.md)**.
## 环境要求
+401 -2073
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
# 交易所实盘说明(OKX)
本页说明如何把本实例从模拟盘切到真实交易:API 权限、OKX 账户模式、以及实例 `.env` 关键项。币本位期权开仓会先用 USDT 买 ETH/BTC,再开期权,因此 **现货交易权限** 与 **合约模式** 缺一不可。
## 一、创建 OKX API Key
1. 登录 OKX → **个人中心 → API** → 创建 API Key。
2. 权限务必勾选 **交易(Trade)**。币本位开仓会走现货买币,只开合约/期权权限不够。
3. 若有细分权限,请同时打开 **现货(Spot)**。
4. 建议开启 **IP 白名单**,填入本实例服务器出口 IP。
5. 保存后得到:`API Key` / `Secret Key` / `Passphrase`,填入本实例 env(见下文)。
常见报错:
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `50123` This API Key does not have trading permission for the Crypto | Key 无现货/交易权限 | 编辑 Key,勾选交易与现货后重试 |
| `未授权` / Bridge 探测失败 | 中控 Token 或客户端未部署接口 | 核对实例 Bridge Token;客户端需已部署 `/api/hub/version` |
## 二、OKX 账户模式
交易页 → **账户模式**(首次切换须在网页/App 手动确认)。
| 账户模式 | 是否推荐 | 说明 |
| --- | --- | --- |
| 仅现货 / 简单模式 | 否 | 无法正常开期权 |
| **合约模式(现货和合约)** | **推荐** | 现货用 `cash`,与当前程序一致 |
| 跨币种保证金 | 可用但不推荐 | 现货须 `cross`,程序目前按合约模式写 `cash`,会报 `tdMode` 错 |
| 组合保证金 | 可用但不推荐 | 同上,现货也须 `cross` |
推荐流程:先切到 **合约模式** → 再在本实例点「买币并开仓」。
相关报错:
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| You can't complete this request under your current account mode (`51010`) | 账户模式不支持本次下单 | 改为合约模式 |
| Parameter tdMode error | 跨币种/组合下仍用 `cash` 下现货 | 改回合约模式,或等待程序支持按模式自动选 `tdMode` |
## 三、实例 env 配置
在顶栏 **env配置**(或改服务器 `.env` 后重启)中设置:
### 1. 实盘开关与密钥
- `LIVE_TRADING_ENABLED=true`:允许实盘下单。
- `OKX_API_KEY` / `OKX_API_SECRET` / `OKX_API_PASSPHRASE`:上一步创建的密钥(期权与永续共用,不必单独配期权 Key)。
- `SIM_DEFAULT_MODE=live`:运行模式切到实盘(顶栏显示 `okx_live`)。也可在运行时由系统把 `trading.mode` 写成 `live`。
切到实盘后:
- 顶栏「交易账户 / 总资金」只显示交易所真实余额;未充值时应为 `—` 或 `0`,不会再残留模拟盘数字。
- 模拟钱包余额不会带到实盘。
### 2. 期权相关(币本位)
- `OKX_OPTIONS_ENABLED=true`:启用期权模块。
- `OKX_OPTIONS_MARGIN_MODE=coin`:币本位(用 ETH/BTC 付权利金)。
- `OKX_OPTIONS_MAX_ACTIVE_POSITIONS`:期权持仓上限(笔);`0`=不限制;`1`=同时最多 1 个合约(同合约加仓不占新笔数)。
- `OKX_OPTIONS_SPOT_BUY_BUFFER`:现货买入相对权利金的缓冲倍数(如 `1.05`)。
- 目标平仓门控:`OKX_OPTIONS_CLOSE_GATE_MODE`、`OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN`、`OKX_OPTIONS_CLOSE_HOLD_SECONDS` 等(见 env 页「期权账户」分组)。
保存后按提示 **保存并重启**(或 PM2 重启),再刷新页面确认顶栏为 `okx_live`。
## 四、开仓前自检清单
1. OKX 账户模式 = **合约模式**。
2. API Key 有 **交易 + 现货**,IP 白名单正确。
3. `.env`:`LIVE_TRADING_ENABLED=true`,密钥已填,模式为 `live`。
4. 交易账户有足够 **USDT**(买币)以及开期权所需的标的币/保证金。
5. 期权持仓上限、全仓复利等规则符合预期。
## 五、翻倍离场与指数离场(实盘同样生效)
后台监控轮询实盘持仓,达标后按买一限价平仓:
- **翻倍离场**:买一可回收 ≥ 开仓权利金 × (1 + 倍数) 时平仓。
- **指数离场**:标的指数到位后触发,但仍须通过「目标平仓门控」(权利金倍数持续若干秒等)。
开仓时勾选/填写对应出场条件,并保证实例进程在跑。
## 六、中控远程更新注意
中控「系统更新 → 客户端远程更新」依赖实例的 `/api/hub/version` 与 `/api/hub/update`。若版本列报「非 JSON / 404」,请先在该实例服务器手动 `git pull` 并重启一次,再在中控刷新版本。
+400
View File
@@ -0,0 +1,400 @@
# 系统分析:期权主链条
> 范围:币本位期权(`OKX_OPTIONS_MARGIN_MODE=coin`)
> 配套规格:[规格-期权交易账户盈亏结算.md](./规格-期权交易账户盈亏结算.md)、[实盘说明.md](./实盘说明.md)
> 目的:从架构到开仓 / 平仓 / 展示 / 复盘 / 统计 / 对账 / 条件单,说明数据流、关键模块与边界
---
## 0. 总览
### 0.1 三条控制面
| 控制面 | 入口 | 职责 |
|--------|------|------|
| 面板 API | `/options` → `/api/options/*` | 人工开平仓、链/报价、持仓、划转、统计 |
| 监控线程 | `options_monitor_lib`(安装时拉起) | 目标位平仓、止盈平仓、条件单、持仓同步、卖回扫尾 |
| 中控 Hub | `/api/hub/options/*` | 快照、统计、远程平仓、条件单 CRUD |
### 0.2 资金主链(双口径)
```text
USDT(交易户)
│ 开仓前快照 trading_usdt_before;initial_premium = 开仓指数×权利金币量
▼
买 ETH/BTC(现货桥)
▼
买期权(IOC @ 卖一)
▼
持仓中(禁划转;浮盈跟盘)
▼
卖期权(可多次部分平;以全部平完为准)
▼
卖光可卖标的币(禁止按开仓权利金封顶)→ 快照 trading_usdt_after
▼
口径 A(复盘真盈亏): 币盈亏×卖回VWAP − 权利金×(买VWAP−卖回VWAP);卖完再结算
口径 B(期权历史/数据统计): 仅期权腿币盈亏(×卖回价折 U);不含现货/权利金U波动修正
```
### 0.3 启动装配
```text
app.py
→ install_options_trading # options_register
→ install_options_review # options_review_register
→ register_hub_bridge_routes # hub_bridge_register
→ 监控守护线程 # options_monitor_lib
```
核心配置包:`_build_cfg`(交易所、DB、预算、报价/下单回调)。
UI:`lib/options/templates/options_panel.html` + `lib/common/static/options_panel.js`。
### 0.4 关键表(SQLite)
| 表 | 用途 |
|----|------|
| `options_trades` | 本地开平流水;含 `trading_usdt_before/after`、`realized_pnl_usdt`、`pnl_settled` |
| `options_spot_bridge` | 买币/卖回桥状态 |
| `options_target_monitors` | 目标位监控 |
| `options_cond_orders` | 条件单 |
| `options_exchange_pnl_freeze` | 实盘历史盈亏冻结辅助 |
| `options_review_*` | 复盘成交 / 笔记 / 隐藏 / 图 |
| `options_transfer_log` 等 | 划转等资金侧日志 |
---
## 1. 主链条(架构)
### 1.1 模块分层
| 层 | 代表文件 | 说明 |
|----|----------|------|
| 路由装配 | `lib/options/options_register.py` | HTTP、尺寸归一、同步持仓 |
| 开仓编排 | `options_coin_open_lib.py` | 预算→买币→开期权→写库 |
| 平仓执行 | `options_close_exec_lib.py` | 买一/深度卖出 |
| 账户结算 | `options_account_pnl_lib.py` | USDT 快照、卖回后冻结 |
| 公式结算 | `options_pnl_settle_lib.py` | 权利金侧辅助结算(有账户快照时 U 以账户为准) |
| 交易所 | `lib/exchange/okx_options_lib.py` | 链、报价、下单、持仓 |
| 模式/预算 | `options_margin_mode_lib.py` | 币本位、预算、张数规划 |
| 展示 enrichment | `options_positions_lib.py` / `options_history_lib.py` | 浮盈、平仓预览 |
| Env UI | `lib/env/env_ui_manifest.py` | 期权相关默认与文案 |
### 1.2 运行模式
- `OKX_TRADE_MODE=options`:主站偏期权。
- `OKX_OPTIONS_ENABLED`:模块开关。
- 模拟 / 实盘由顶栏 `trading.mode` + `LIVE_TRADING_ENABLED` 等控制;实盘要求现货+期权权限(见实盘说明)。
### 1.3 与盈亏规格的关系
规格文档定义**产品规则**;本分析描述**代码落点**。
凡「已实现盈亏」对外展示(复盘 / 统计 / 顶栏已实现),在新规则下均以 `pnl_settled=1` 的账户差分为准;旧单无快照字段时走遗留口径,互不回写。
---
## 2. 开仓
### 2.1 入口
| 来源 | 路径 / 调用 |
|------|-------------|
| 面板 | `POST /api/options/open` |
| 条件单触发 | `options_cond_order_lib` → 同一套 `open_coin_option_buy_full` |
前端默认尺寸模式:**按可用余额打满**(`budget_full`);复利开时 UI 切 `compound_full`。
后端 `_normalize_size_mode`:币本位保留 `budget_full` / `compound_full`;USDC 遗留路径才落到 `sheets`。
### 2.2 尺寸模式
| mode | 含义 |
|------|------|
| `budget_full` | min(可用 USDT, 单笔预算) × 预算缓冲 → 张数(默认) |
| `compound_full` | 交易户可用 × 缓冲(受全仓上限 env 约束) |
| `sheets` | 指定张数 |
| `eth` / `eth_amount` | 指定币数量反推张数 |
预算计算:`compute_coin_budget_usdt` / `plan_coin_open_by_budget`(`options_margin_mode_lib.py`)。
### 2.3 流程
```text
请求 {inst_id, mode, sheets?, eth?, target_index?, profit_exit_*}
→ 交易模式门禁 / 条件单占用锁 / 持仓笔数上限
→ 有可卖标的币 → 拒开(须先卖回)
→ coin_budget_preview → 规划张数与买币量
→ fetch_trading_usdt → 写入 trading_usdt_before
→ 现货市价买币(× SPOT_BUY_BUFFER)→ options_spot_bridge=bought
→ 期权限价 IOC @ 卖一 → 等待成交
失败 → 回滚卖币
成功 → bridge=holding,_insert_coin_trade(open, pnl_settled=0)
→ 可选:目标位 / 止盈倍数 / 微信通知
```
### 2.4 关键 env
| 变量 | 默认(代码/清单) | 作用 |
|------|-------------------|------|
| `OKX_OPTIONS_COIN_COMPOUND` | true | 是否按交易户复利 |
| `OKX_OPTIONS_COIN_BUDGET_USDT` | (单笔预算) | 打满时的封顶 |
| `OKX_OPTIONS_BUDGET_BUFFER` | 1 | 预算缓冲 |
| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 1.05 | 现货多买缓冲 |
| `OKX_OPTIONS_MAX_ACTIVE_POSITIONS` | 1 | 同时持仓笔数;0=不限 |
| `OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC` | — | 开仓等待成交超时 |
### 2.5 边界
- 开仓**只快照 before**,不结算盈亏。
- 持仓或桥未完成期间:`transfer_blocked_by_position_msg` 禁止划转。
- 一次一仓是对账规格前提;上限默认 1。
---
## 3. 平仓
### 3.1 三条平仓通道
| 通道 | 触发 | 门控 | 执行 |
|------|------|------|------|
| 手动 / Hub | 用户或中控 | 一般只校验流动性 | `close_option_by_bid1(require_recycle_gate=False)` |
| 目标位 | 指数触达目标 | 回收门控(权利金倍数 / 净盈亏 + 持续秒数) | `close_option_by_bid_depth` |
| 止盈倍数 | 买一回收 ≥ 权利金×(1+mult) | 自有回收条件,不走 close_gate | `close_option_by_bid_profit_exit` |
监控循环:`run_options_target_closes` / `run_options_profit_exits`。
### 3.2 流程
```text
卖期权(买一或深度,reduce-only)
→ 部分成交:累计平仓张数
→ 全平:
settle_closed_pnl(币侧/费用;有账户快照时 U 不以公式定稿)
→ maybe_sell_spot_after_close / sell_residual
→ try_settle_after_sellback
after − before → realized_pnl_usdt,pnl_settled=1
```
卖回失败:本单「待结算」,已实现不进统计;可 `POST /api/options/spot-bridge/retry-sell` 或监控扫尾。
### 3.3 回收门控 env
| 变量 | 默认 | 作用 |
|------|------|------|
| `OKX_OPTIONS_CLOSE_GATE_MODE` | premium | `premium` / `net_pnl` |
| `OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN` | 1.05 | 币本位回收倍数 |
| `OKX_OPTIONS_CLOSE_HOLD_SECONDS` | 20 | 条件持续秒数 |
| `OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT` | 0 | net_pnl 模式下限 |
### 3.4 边界
- 手动平仓可绕过回收门控;自动目标位不可。
- 冻结后任何划转、指数、复盘同步**不得改写** `realized_pnl_usdt`。
---
## 4. 期权显示
### 4.1 页面与 API
| UI | API | 数据源 |
|----|-----|--------|
| 期权链 / 到期 / 虚实度过滤 | `GET /api/options/chain` | `build_option_chain` |
| 下单弹窗报价与估张 | `GET /api/options/quote` | `quote_option_contract` + 币本位规划 |
| 持仓卡片 | `GET /api/options/positions` | 交易所持仓 + 本地 trade enrichment |
| 余额 / 顶栏 | `GET /api/options/balances` + header pack | 交易户 / 资金户 |
| 挂单 / 目标位 | `.../orders/pending`、`.../targets` | 本地 + 交易所 |
| 中控快照 | `GET /api/hub/options/snapshot` | `build_options_hub_snapshot` |
前端主文件:`options_panel.js`、`options_position_cards.js`、`options_expiry_countdown.js`。
### 4.2 展示口径(勿与冻结混淆)
| 指标 | 口径 |
|------|------|
| 持仓浮盈 | 买一回收估算(`display_pnl_*`),跟盘变 |
| 合计盈亏 | Σ已冻结 + 当前浮盈 |
| 顶栏钱包 | 交易所余额,不是盈亏账本 |
| 下单估权利金 / 杠杆 / 打满提示 | 报价瞬间估算,非成交结果 |
### 4.3 链过滤 env
`OKX_OPTIONS_CHAIN_MAX_DTE_DAYS`、`OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED`、`OKX_OPTIONS_ITM_MAX_DIST_USD`、`OKX_OPTIONS_DEFAULT_UNDERLY`。
---
## 5. 复盘
### 5.1 入口
- 页:`/options/review`
- 注册:`options_review_register.py`
- 逻辑:`options_review_lib.py` + `options_review_db.py`
- 前端:`options_review_panel.html`、`options_review.js`
### 5.2 数据流
```text
options_trades(已平)
→ sync_options_from_local_trades / ensure_local_review_synced
→ options_review_trades(history_key ≈ local_opt:{id})
→ 笔记 options_review_entries + 上传图 / 异步图表
→ 隐藏 options_review_hidden
```
主要 API:`POST /api/options/review/sync`,`GET/DELETE .../trades`,`POST .../entry`,`POST .../upload_slot`,`GET .../stats`。
### 5.3 盈亏读取规则
| 本地单状态 | 复盘展示 |
|------------|----------|
| 有 `trading_usdt_before` 且未 `pnl_settled` | 已实现 U 待结算(不展示假冻结) |
| `pnl_settled=1` | 只读冻结 `realized_pnl_usdt` |
| 无账户快照的历史单 | 遗留口径,不强制改写成账户差分 |
复盘是**消费者**,不负责改写结算真源。
---
## 6. 统计
### 6.1 入口
| 场景 | API / 函数 |
|------|------------|
| 期权面板 KPI | `GET /api/options/stats` → `compute_options_stats_auto` |
| 复盘页统计 | `GET /api/options/review/stats` |
| 顶栏窗口统计 | `header_options_stats_for_window` |
| 中控 | `GET /api/hub/options/stats` |
核心库:`options_stats_lib.py`。
### 6.2 窗口与构成
- 窗口:`today` / `7d` / `30d` / `all`(`normalize_stats_window`)。
- 模拟:本地已平且已结算样本。
- 实盘:交易所历史 + `options_exchange_pnl_freeze` 对齐后同一套 KPI。
- 面板合计:`净已实现(仅 settled) + 持仓浮盈`。
### 6.3 边界
- 待卖回、未冻结单**不计入**已实现。
- 统计不重新推导账户差分;最多做冻结补齐 / 修复类运维函数(如 `freeze_missing_realized_pnl_usdt`),与规格「冻结后不改」一致的运维路径需谨慎使用。
---
## 7. 对账
此处「对账」= **交易账户快照结算 + 本地/交易所持仓对齐**,不是已删除的永续 `reconcile_flat`。
### 7.1 结算真源(账户账)
| 步骤 | 函数 | 结果 |
|------|------|------|
| 开仓前 | `fetch_trading_usdt` | `trading_usdt_before` |
| 卖回后 | `try_settle_after_sellback` / `settle_closed_trades_from_trading_account` | `after`、`realized_pnl_usdt`、`pnl_settled=1` |
| 持仓禁划转 | `transfer_blocked_by_position_msg` | 前端灰 + API 拒 |
公式身份(规格 §6 思路):
```text
Σ 冻结已实现 + 当前浮盈 ≈ 面板合计盈亏
Σ 冻结已实现 + 区间净划转 ≈ 钱包 USDT 变动(在一次一仓、无并行污染前提下)
```
### 7.2 持仓对齐(仓位账)
监控 / `_sync_options_trades`:
| 动作 | 含义 |
|------|------|
| `reconcile_live_open_trades` / `sync_open_options_trades` | 交易所仍有仓而本地已关 → 纠偏重开等 |
| `backfill_closed_options_realized_pnl_from_history` | 历史回填;**跳过已冻结 U** |
| `sell_spot_after_options_sync` / `sweep_spot_after_options_flat` | 平仓后残留币扫尾,推动账户结算 |
### 7.3 双账清晰
| 账本 | 看见什么 |
|------|----------|
| 钱包(顶栏) | 交易所余额,随划转变 |
| 盈亏账 | 冻结历史 + 浮盈;划转不改已冻结单 |
---
## 8. 条件单
### 8.1 定位
中控下发「价格带触发 → 自动开仓 → 挂目标/止盈 → 等平仓结束」的自动化;与面板互斥:有活跃条件单时**锁定手动开仓**(`cond_order_blocks_manual_open_msg`)。
### 8.2 入口
| API | 作用 |
|-----|------|
| `GET/POST /api/hub/options/cond_orders` | 列表 / 创建 |
| `POST .../<hub_order_id>/cancel` | 取消 |
| `POST .../exit` | 更新出场参数 |
库:`options_cond_order_lib.py`。
Tick:监控循环 `run_cond_orders_tick` → `_tick_one`。
### 8.3 生命周期
```text
Hub 创建(trigger_px, deviation_pct, 认购/认沽, 出场)
→ options_cond_orders = armed
→ 指数落入 band_bounds
→ pick_nearest_itm_contract
→ 走 open_coin_option_buy_full(同人工开仓:before 快照、买币、IOC)
→ 挂目标位 / 止盈
→ 等待仓位平坦 → _end_order
→ 周四 23:00 等策略可 expire 未触发单
```
### 8.4 与主链关系
- **不另建盈亏账本**;触发开仓后的卖回冻结与人工单相同。
- 占用期间面板开仓按钮应不可用,避免双开破坏「一次一仓」。
---
## 9. 端到端对照表
| 阶段 | 写库 / 状态 | 用户可见 | 消费方 |
|------|-------------|----------|--------|
| 开仓成功 | trade open + before + bridge holding | 持仓卡、浮盈 | 监控、禁划转 |
| 期权已平、币未卖回 | 待结算 | 提示待卖回 | 统计不含已实现 |
| 卖回成功 | after、realized、settled=1 | 复盘/统计定格 | 对账完成 |
| 条件单 armed | cond_orders | 横幅锁定手动开仓 | 监控 tick |
| 目标位/止盈触发 | 平仓执行 | 持仓消失→卖回→冻结 | 同平仓链 |
---
## 10. 关键文件索引
| 主题 | 路径 |
|------|------|
| 路由 / 开平仓 API | `lib/options/options_register.py` |
| 币本位开仓 | `lib/options/options_coin_open_lib.py` |
| 现货桥 | `lib/options/options_spot_bridge_lib.py` |
| 平仓执行 | `lib/options/options_close_exec_lib.py` |
| 回收门控 | `lib/options/options_close_gate_lib.py` |
| 目标位 | `lib/options/options_target_lib.py` |
| 止盈 | `lib/options/options_profit_exit_lib.py` |
| 账户盈亏 | `lib/options/options_account_pnl_lib.py` |
| 统计 | `lib/options/options_stats_lib.py` |
| 复盘 | `lib/options/options_review_*.py` |
| 条件单 | `lib/options/options_cond_order_lib.py` |
| 监控 | `lib/options/options_monitor_lib.py` |
| Hub 快照 | `lib/options/options_hub_lib.py` |
| Hub 路由 | `lib/hub/hub_bridge_register.py` |
| Schema | `lib/options/options_db.py` |
| 面板 | `lib/options/templates/options_panel.html`、`lib/common/static/options_panel.js` |
---
## 11. 维护时注意
1. **改盈亏口径**:先改规格文档,再改 `options_account_pnl_lib` 与复盘/统计读取,避免三处漂移。
2. **改开仓默认尺寸**:同时看前端 `currentSizeMode`、HTML `checked`、后端 `_normalize_size_mode`(币本位勿再压成 `sheets`)。
3. **改门控秒数/缓冲**:代码默认、`env_ui_manifest` 文案与 `_RUNTIME_ENV_DEFAULTS` 一并对齐;已有 `.env` 旧值不会自动覆盖。
4. **条件单与手动开仓**:任何新开仓入口都要接 `cond_order_blocks_manual_open_msg`(或等价锁)。
5. **对账前提**:一次一仓 + 持仓禁划转 + 卖回后冻结;破坏任一前提,钱包差分将无法与单笔盈亏对齐。
+478
View File
@@ -0,0 +1,478 @@
# 系统架构与技术实现
> 定位:描述本仓库(OKX 币本位期权实例)的整体架构、运行时、模块划分与关键技术实现。
> 配套文档:
> - 业务主链:[系统分析-期权主链条.md](./系统分析-期权主链条.md)
> - 盈亏规格:[规格-期权交易账户盈亏结算.md](./规格-期权交易账户盈亏结算.md)
> - 操作说明:[../使用说明.md](../使用说明.md)
> - 部署:[../部署文档.md](../部署文档.md) · 实盘:[实盘说明.md](./实盘说明.md)
---
## 1. 系统定位
| 项 | 说明 |
|----|------|
| 角色 | 单交易所实例客户端(OKX),可独立使用,也可挂中控 `crypto_okx_hub` |
| 主业务 | **币本位期权**:USDT → 买 ETH/BTC → 开期权 → 平仓 → 卖回 → 按交易户 USDT 差分冻结盈亏 |
| 非目标 | 不再以永续实盘下单 / USDC 期权开仓 / 对冲计划为主路径(相关代码多已下线或残留) |
三条控制面:
```text
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 浏览器面板 │ │ 本机监控守护线程 │ │ 中控 Hub │
│ /options │ │ options-monitor │ │ /api/hub/* │
│ /api/options│ │ 目标/翻倍/条件单 │ │ 快照/条件单/全平 │
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
└─────────────────────┼────────────────────────┘
▼
Flask app.py(单进程)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
SQLite ccxt.okx 模拟 SimBroker
(crypto.db) (实盘/公开行情) (本地撮合)
```
---
## 2. 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 语言 / 运行时 | Python **3.10+** | 见 `README.md` / `requirements.txt` |
| Web 框架 | Flask ≥3、<4 | `threaded=True`;Jinja2 模板 |
| WSGI | Werkzeug | 开发/PM2 直跑 `app.py` |
| 交易所 SDK | **ccxt** ≥4.2(`ccxt.okx`) | 期权 `defaultType=option`;可配 SOCKS |
| HTTP / 代理 | requests、PySocks | `OKX_SOCKS_PROXY` |
| 持久化 | **SQLite** 单文件 | 默认 `DB_PATH=crypto.db` |
| 进程管理 | **PM2** | `ecosystem.config.cjs`:单实例、内存上限约 800M |
| 前端 | **原生 JS/CSS** | 无 React/Vue/`package.json`;静态资源在 `lib/common/static/` |
| 图像 | Pillow | 复盘/日记配图等 |
部署约定(见部署文档):常见路径 `/opt/crypto_okx`,端口 **`APP_PORT=5004`**(代码未配时 fallback 5000)。
---
## 3. 仓库与目录结构
```text
crypto_okx/
├── app.py # 应用入口:装配路由、线程、交易所句柄
├── ecosystem.config.cjs # PM2
├── requirements.txt
├── .env / .env.example
├── crypto.db # 运行时 SQLite(勿提交)
├── deploy/ # 安装 / 更新 / doctor 脚本
├── docs/ # 规格、架构、实盘说明
├── templates/ # Flask 默认模板目录(少量)
├── static/ # 上传等(如 images)
└── lib/
├── paths.py # 仓库根、静态/模板路径
├── options/ # 期权交易、监控、复盘、结算(核心)
├── exchange/ # OKX 期权 API 封装、账本适配
├── sim/ # 模拟钱包与 API hook
├── hub/ # 中控 Bridge 鉴权与路由
├── env/ # .env 读写、schema、配置页白名单
├── instance/ # 壳层 UI、设置、embed、备份、仪表盘
├── account_ledger/ # 资金流水同步
├── market/ # 行情、K 线、精度
├── trade/ # 入场模型/风控等(关键位等周边)
├── key_monitor/ # 关键位监控
└── common/ # 公共工具 + static 前端资源
```
原则:**业务按包 install/register 挂到 Flask**,避免全部逻辑堆在 `app.py`;`app.py` 仍保留登录、部分关键位与全局交易所对象。
---
## 4. 启动与装配顺序
### 4.1 引导流程
```text
load_env_file(.env) → os.environ
→ Flask(__name__) + Session(含中控 iframe SameSite=None/Secure)
→ attach_embed_templates / install_common_static
→ 构建 ccxt.okx(永续句柄 + options 句柄,可带代理)
→ 初始化 SQLite 核心表
→ register_trade_records_api
→ register_instance_dashboard_routes # 仪表盘缓存线程
→ install_account_ledger # 账本同步线程
→ register_instance_settings_routes
→ start_backup_scheduler # 定时备份线程
→ install_sim_trading # 模拟模式 + hooks
→ install_options_trading # 期权路由 + options-monitor
→ install_options_review
→ install_instance_data_stats # 资金采样线程
→ register_hub_bridge_routes
→ register_embed_routes # /embed + live push
→ __main__: key-monitor 后台线程 + app.run(threaded=True)
```
### 4.2 期权模块装配(要点)
`install_options_trading`(`lib/options/options_register.py`):
1. `_build_cfg(app_module)`:注入 DB、交易所、预算、报价/下单回调等。
2. `register_options_routes`:页面与 `/api/options/*`。
3. `_register_options_hub_bridge`:把 `options_snapshot_fn` 等写入 `app.config["HUB_CTX"]`。
4. `_start_monitor_thread`:若 `OKX_OPTIONS_ENABLED`,拉起 `options-monitor`。
---
## 5. 运行时架构
### 5.1 进程模型
| 单元 | 数量 | 说明 |
|------|------|------|
| PM2 应用 `crypto_okx` | 1 | `python app.py`,`PYTHONPATH` 指向仓库根 |
| Flask 请求线程 | 多 | `threaded=True` |
| 守护线程 | 多个 | 见下表(均为 daemon) |
### 5.2 守护线程一览
| 线程(逻辑名) | 启动位置 | 职责 |
|----------------|----------|------|
| `background_task` / 关键位监控 | `app.py` `__main__` | 按 `MONITOR_POLL_SECONDS` 轮询关键位规则 |
| `options-monitor` | `options_register` | 目标位平仓、翻倍出场、条件单 tick、持仓同步、卖回扫尾 |
| `okx-backup-scheduler` | `backup_lib` | 定时备份 |
| `account-ledger-okx` | `account_ledger` | 交易所资金流水同步 |
| `instance-dashboard-poll` | dashboard cache | 仪表盘数据缓存 |
| `instance-cash-sample` | data stats | 资金样本 |
| `instance-live-push` | embed live | SSE/推送类 |
短生命周期线程:日记/复盘异步出图(`journal-chart-*`、`options-review-chart-*`)。
### 5.3 配置三层
```text
① .env 文件(启动 load + env 页写入)
② os.environ(进程环境)
③ SQLite app_runtime_settings
· trading.mode = sim | live
· display.* 导航显示偏好
· env.<KEY> 热更新覆盖(runtime_config_lib)
```
读取顺序(热更场景):运行时覆盖 → 环境变量 → 代码默认(`env_ui_manifest._RUNTIME_ENV_DEFAULTS`)。
部分键改后需 **保存并重启**(见 `env_schema` 的 restart 标记)。
---
## 6. 分层架构
```text
┌──────────────────────────────────────────────────────────┐
│ 表现层 Jinja 模板 + vanilla JS(options_panel.js 等) │
├──────────────────────────────────────────────────────────┤
│ 接入层 Flask routes(options_register / hub / settings) │
├──────────────────────────────────────────────────────────┤
│ 领域编排 coin_open / close_exec / cond_order / monitor │
│ account_pnl / stats / review │
├──────────────────────────────────────────────────────────┤
│ 基础设施 okx_options_lib(ccxt) · SQLite · sim broker │
│ env_file · wechat_notify · backup │
└──────────────────────────────────────────────────────────┘
```
| 层 | 代表路径 | 职责边界 |
|----|----------|----------|
| 表现 | `lib/options/templates/*`、`lib/common/static/options_*.js` | 交互、轮询、展示口径 |
| 接入 | `options_register.py`、`hub_bridge_register.py`、`instance_settings_register.py` | HTTP、鉴权、参数归一 |
| 编排 | `options_coin_open_lib.py`、`options_close_exec_lib.py`、`options_monitor_lib.py` | 多步业务流程、状态机 |
| 领域规则 | `options_margin_mode_lib.py`、`options_close_gate_lib.py`、`options_account_pnl_lib.py` | 预算、门控、结算真源 |
| 基础设施 | `okx_options_lib.py`、`options_db.py`、`sim/*` | IO 与持久化 |
---
## 7. 核心业务技术实现
### 7.1 币本位开仓流水线
**入口:** `POST /api/options/open` → `open_coin_option_buy_full`
**尺寸归一:** `_normalize_size_mode`(币本位保留 `budget_full` / `compound_full`;USDC 遗留才落到 `sheets`)
```text
门禁(模式 / 条件单锁 / 持仓上限 / 可卖币拒开)
→ compute_coin_budget_usdt + plan_coin_open_by_budget
→ fetch_trading_usdt → trading_usdt_before
→ spot_market_buy_coin_with_usdt(× SPOT_BUY_BUFFER)
→ options_spot_bridge = bought*
→ place_option_limit_order(IOC @ 卖一)+ wait fill
失败 → 回滚卖币
成功 → bridge=holding,写入 options_trades(open, pnl_settled=0)
→ 可选 upsert 目标位 / 翻倍出场 / 企微通知
```
关键文件:
- `lib/options/options_coin_open_lib.py`
- `lib/options/options_spot_bridge_lib.py`
- `lib/options/options_margin_mode_lib.py`
- `lib/options/options_position_limit_lib.py`
- `lib/options/options_account_pnl_lib.py`
### 7.2 平仓与自动出场
| 通道 | 实现 | 门控 |
|------|------|------|
| 手动 / Hub 全平 | `close_option_by_bid1`(`require_recycle_gate=False`) | 流动性 |
| 目标位 | `run_options_target_closes` → `close_option_by_bid_depth` | `options_close_gate_lib`(倍数 + 持续秒数) |
| 翻倍出场 | `run_options_profit_exits` → `close_option_by_bid_profit_exit` | 回收 ≥ 权利金×(1+mult) |
平仓后:`settle_closed_pnl`(币/费用侧)→ `maybe_sell_spot_after_close` → `try_settle_after_sellback`:
```text
realized_pnl_usdt = trading_usdt_after − trading_usdt_before
pnl_settled = 1 # 冻结,业务路径不再改写
```
### 7.3 监控循环
`options_monitor_loop`(`options_monitor_lib.py`)周期性执行,大致包括:
- 同步交易所持仓 ↔ 本地 `options_trades`(纠偏误关仓等)
- 跑目标位 / 翻倍 / 条件单 tick
- 平仓后卖回扫尾、推动账户结算
- 历史盈亏回填(**跳过已冻结 U**)
轮询间隔受期权相关 poll env / 内部 sleep 控制;与关键位监控线程相互独立。
### 7.4 模拟盘技术实现
| 组件 | 文件 | 作用 |
|------|------|------|
| 模式 | `sim/mode_lib.py` | `trading.mode` ∈ {sim, live};启动默认 `SIM_DEFAULT_MODE` |
| 撮合 | `sim/broker_lib.py` | `SimBroker`:公开买卖一进本地钱包/仓位 |
| 注入 | `sim/hooks.py` | `apply_sim_hooks` / `patch_options_cfg`:把下单、划转等切到模拟实现 |
| 表 | `sim/db_lib.py` | `sim_wallets`、`sim_option_positions`、`sim_ledger_entries` 等 |
实盘闸门:`LIVE_TRADING_ENABLED`;模拟下即使有密钥也不应向交易所发真实期权单(由 hook 拦截)。
### 7.5 复盘与统计(只读结算真源)
```text
options_trades(closed + settled)
→ review sync → options_review_*
→ stats:compute_options_stats_auto / header_options_stats_for_window
```
- 复盘:`options_review_lib.py` + `options_review_register.py`
- 统计:`options_stats_lib.py`
- 规则:未 `pnl_settled` 不计已实现;有账户快照的新单只读冻结 U
---
## 8. 交易所集成
### 8.1 ccxt 句柄
`app.py` 构建:
- `exchange`:通用 OKX(行情/部分账户)
- `exchange_options`:`defaultType=option`
期权细节封装:`lib/exchange/okx_options_lib.py`
| 能力 | 示例 |
|------|------|
| 期权链 | `build_option_chain` |
| 报价 | `quote_option_contract` |
| 下单/撤单/等成交 | `place_option_limit_order`、`wait_option_order_full_fill` |
| 持仓/余额 | `fetch_option_positions`、`fetch_options_balances` |
| 划转 | `transfer_ccy` |
| 流动性 | `option_buy_liquidity_ok`、买一深度裁剪 |
凭证:`OKX_API_KEY` / `SECRET` / `PASSPHRASE`(遗留 `OKX_OPTIONS_API_*` 可回填)。
代理:`build_okx_ccxt_proxies()` ← `OKX_SOCKS_PROXY`。
### 8.2 账户模式约束
程序现货侧按 **合约模式 + cash** 假设编写;跨币种/组合保证金易触发 `tdMode` / `51010`。详见实盘说明。
---
## 9. 数据模型(SQLite)
单库文件,多模块 `CREATE TABLE IF NOT EXISTS`。
### 9.1 期权核心
| 表 | 用途 |
|----|------|
| `options_trades` | 开平流水;`trading_usdt_before/after`、`realized_pnl_usdt`、`pnl_settled` |
| `options_spot_bridge` | 买币/持币/待卖回/关闭 |
| `options_target_monitors` | 指数目标位 |
| `options_cond_orders` | 中控条件单状态机 |
| `options_exchange_pnl_freeze` | 实盘历史盈亏冻结辅助 |
| `options_transfer_log` / `options_convert_log` | 划转等日志 |
| `options_history_hidden` | 历史隐藏 |
### 9.2 复盘
`options_review_trades`、`options_review_entries`、`options_review_sync_state`、`options_review_hidden`
### 9.3 模拟
`sim_wallets`、`sim_option_positions`、`sim_option_orders`、`sim_ledger_entries` 等
### 9.4 实例壳层
`app_runtime_settings`、`key_monitors`、`trade_records`、`account_ledger_*`、`instance_cash_samples` 等
连接约定:业务路径多处 `sqlite3.connect(DB_PATH, timeout=…)` + `Row` factory;长事务与网络 IO 应拆分,避免「持锁等交易所」导致面板超时(历史踩坑点)。
---
## 10. 鉴权与中控 Bridge
### 10.1 站点登录
| 项 | 实现 |
|----|------|
| 账号 | `APP_USERNAME` / `APP_PASSWORD` |
| Session | `session["logged_in"]`;`APP_SESSION_DAYS`;`FLASK_SECRET_KEY` |
| 装饰器 | `login_required`;`/api/*` 未登录返回 JSON 401 |
| 可关闭 | `APP_AUTH_DISABLED`(仅受信环境) |
为支持中控 iframe:Cookie `SameSite=None` + `Secure`(按配置)。
### 10.2 Hub 鉴权(`lib/hub/hub_bridge_auth.py`)
| 机制 | 用途 |
|------|------|
| `HUB_BRIDGE_TOKEN` | M2M:`X-Hub-Token` 或 `Bearer`;`hmac.compare_digest` |
| 短时 SSO | `/hub_sso?token=` → 建站内 session(TTL ~120s) |
| 长时 embed | `embed_sso`(约 7 天)应对 iframe 第三方 Cookie 限制 |
| 自动生成 | env 页首次打开可 `ensure_hub_bridge_token` 写入 `.env` |
| CSP | `HUB_EMBED_FRAME_ANCESTORS` → `frame-ancestors` |
### 10.3 Hub API 面(`hub_bridge_register.py`)
| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/api/hub/options/snapshot` | 持仓/资金/条件单/流动性摘要 |
| GET | `/api/hub/options/stats` | 窗口统计 |
| POST | `/api/hub/options/close` | 买一限价**全平** |
| GET/POST | `/api/hub/options/cond_orders` | 条件单 |
| POST | `.../cancel` · `.../exit` | 取消 / 改出场 |
| GET | `/api/hub/version` | 在线与版本 |
| POST | `/api/hub/update` | 远程 pull + PM2 重启 |
| GET | `/api/hub/backup/*` | 备份列表/下载 |
快照构建:`options_hub_lib.build_options_hub_snapshot`。
---
## 11. 前端技术实现
### 11.1 资源加载
`install_common_static` 将 `lib/common/static/` 下白名单文件挂到 `/static/<name>`。
模板通过 `ChoiceLoader` 合并:`lib/instance/templates`、`lib/options/templates`、`lib/sim/templates` 等。
### 11.2 期权前端模块
| 文件 | 职责 |
|------|------|
| `options_panel.js` | 链、报价、下单尺寸模式、开平仓、轮询 |
| `options_position_cards.js` | 持仓卡 UI |
| `options_expiry_countdown.js` | 到期倒计时 |
| `options_settings.js` | 设置内期权相关 |
| `options_review.js` | 复盘页 |
| `options_data_stats.js` | 统计展示 |
| `hub_embed_auth.js` | 嵌入鉴权辅助 |
| `instance_settings_prefs.js` | 导航显示等偏好 |
交互模式:REST 轮询为主(链/持仓/报价);无独立 Node 构建步骤。
### 11.3 关键 API 前缀
| 前缀 | 模块 |
|------|------|
| `/api/options/*` | 交易面板 |
| `/api/options/review/*` | 复盘 |
| `/api/sim/*` | 模拟资金 |
| `/api/settings/*` | env/显示/密码等 |
| `/api/hub/*` | 中控 |
| `/api/admin/*` | 重启等 |
| `/embed`、`/hub_sso` | 嵌入与 SSO |
---
## 12. 部署与运维技术点
| 项 | 实现 |
|----|------|
| 进程 | PM2 `ecosystem.config.cjs`,`instances: 1` |
| 更新 | 本机脚本 / 中控 `POST /api/hub/update`(git pull + restart) |
| 备份 | `backup_lib` 定时 + 设置页手动;中控可下载 |
| 代理 | `OKX_SOCKS_PROXY`;SSH 动态转发不由 PM2 托管 |
| 日志 | PM2 日志 + Werkzeug;可静音 access log |
| 健康 | `deploy/lib/doctor.sh`;中控看 `/api/hub/version` |
---
## 13. 关键设计决策(实现层)
| 决策 | 原因 | 落点 |
|------|------|------|
| 单进程 + 多守护线程 | 部署简单、与 SQLite 单写者模型匹配 | PM2 instances=1 |
| 盈亏真源=交易户 USDT 差分 | 与钱包可对账、避免指数重算漂移 | `options_account_pnl_lib` |
| 一次一仓默认 | 差分对账前提 | `OKX_OPTIONS_MAX_ACTIVE_POSITIONS=1` |
| 现货桥表 | 买币/卖回与期权生命周期解耦可重试 | `options_spot_bridge` |
| Sim hook 而非第二套 UI | 同一面板切模拟/实盘 | `sim/hooks.py` |
| Hub Token + SSO 双通道 | 机器调用与 iframe 人机登录分离 | `hub_bridge_auth` |
| 前端无框架 | 运维机交付简单、无构建链 | `lib/common/static` |
| USDC 开仓下线 | 主路径币本位 | margin mode 固定 coin |
---
## 14. 扩展与修改指引
1. **新开仓入口**:必须接条件单锁、持仓上限、可卖币检查、before 快照。
2. **新统计展示**:只消费 `pnl_settled` 冻结值;勿在展示层重算账户差分。
3. **新自动平仓**:明确是否走 close_gate;与手动通道权限分开。
4. **改 env 默认**:同步代码默认、`env_ui_manifest` 文案与 `_RUNTIME_ENV_DEFAULTS`。
5. **避免在持 DB 锁时调交易所**:网络与写库分阶段,防止面板 API 超时。
6. **中控协议变更**:同步 `hub_bridge_register`、快照 `version` 与中控仓库解析逻辑。
---
## 15. 关键文件索引
| 主题 | 路径 |
|------|------|
| 入口 | `app.py` |
| 路径常量 | `lib/paths.py` |
| 期权路由/监控启动 | `lib/options/options_register.py` |
| 监控循环 | `lib/options/options_monitor_lib.py` |
| 开仓 | `lib/options/options_coin_open_lib.py` |
| 现货桥 | `lib/options/options_spot_bridge_lib.py` |
| 平仓执行 | `lib/options/options_close_exec_lib.py` |
| 账户结算 | `lib/options/options_account_pnl_lib.py` |
| 条件单 | `lib/options/options_cond_order_lib.py` |
| Hub 快照 | `lib/options/options_hub_lib.py` |
| Hub 路由/鉴权 | `lib/hub/hub_bridge_register.py`、`hub_bridge_auth.py` |
| 交易所 | `lib/exchange/okx_options_lib.py` |
| 模拟 | `lib/sim/register.py`、`hooks.py`、`broker_lib.py` |
| Env UI | `lib/env/env_ui_manifest.py`、`env_schema.py` |
| 静态资源注册 | `lib/instance/instance_static_lib.py` |
| PM2 | `ecosystem.config.cjs` |
---
## 16. 文档关系
```text
使用说明.md ← 操作员怎么用
实盘说明.md ← 交易所前置与实盘开关
规格-…盈亏结算.md ← 盈亏产品规则(真源)
系统分析-期权主链条.md ← 业务功能链与数据流
本文 ← 软件架构与技术实现
部署文档.md ← 安装运维
```
@@ -0,0 +1,289 @@
# 规格:期权盈亏双口径结算(一次一仓)
> 状态:已定稿(2026-10-13 修订)
> 适用范围:币本位期权(模拟盘优先;实盘同规则)
> 目标:复盘可对账、数据统计跟期权历史一致;初始权利金 / 盈利率可展示;错账可脚本修复
---
## 1. 目标与原则
### 1.1 要解决什么
此前单笔盈亏混用多套口径,且卖回按开仓权利金封顶,导致:
- 盈利单(如约 1.5 倍)在复盘被冻成亏损(账户差分在盈利币未卖回时结算)
- 复盘与数据统计共用账户冻结,与「历史币盈亏 × 卖回价」不一致
- 缺少初始权利金(U)、盈利率展示
### 1.2 原则
| 原则 | 说明 |
|------|------|
| 双口径 | **A 复盘** = 交易账户 USDT 快照差分;**B 数据统计** = 期权历史币盈亏 × 平仓卖回价 |
| 一次一仓 | 同一账户同一时间只允许一笔期权持仓 |
| 卖光再结算 | A 口径:未卖光可卖标的币前,本单不冻结 |
| 冻结 | A 结算完成后永不修改;B 按平仓卖回价冻结后不跟盘 |
| 禁划转 | 持仓 / 待卖回期间禁止资金划转 |
| 权利金不含多买 | 初始权利金 = 开仓指数 × 权利金币量(不含现货缓冲多买) |
---
## 2. 双口径定义
### 2.1 口径 A — 真盈亏(复盘交易记录)
```
真盈亏(U) = 期权历史盈亏(币) × 平仓卖回价
− (权利金 × 开仓价 − 权利金 × 平仓卖回价)
```
- **币量**只用期权腿:`realized_pnl`(历史币盈亏,**已含**开/平手续费)、`premium_paid`(权利金);**不含**现货多买/多卖数量
- **价格**只用开仓买币 VWAP、平仓卖回 VWAP;现货多余仓位只参与均价,**数量不进公式**
- 真盈亏**不再另扣**手续费(费已在历史币盈亏里);复盘「期权手续费」列仅展示
- 无手续费时:`期权收回权利金×卖回价 − 权利金×开仓价`(收回=`premium_received`,不是账户全部现货)
**固定量 vs 一次冻结价:**
| 字段 | 性质 |
|------|------|
| `premium_paid`(权利金币) | 开仓后**固定**,不再改 |
| `realized_pnl`(期权腿币盈亏) | 平仓后**固定**,不再改 |
| 开仓价 | 买币完成时算一次 VWAP 写入 `open_index_px`,**冻住** |
| 卖回价 | 平仓卖回全部完成时算一次 VWAP 写入 `close_index_px`,**冻住** |
| `realized_pnl_usdt` | 按上式计算;**刷新时用固定量+价重算**(可纠正旧错账) |
- 卖回未完成 → 「待卖回」,不算真盈亏
- 缺价 → 「缺价未核」
- 复盘刷新:**不得**改权利金币量/币盈亏/已冻开仓价/卖回价;**要**按公式重算真盈亏 U
- **消费者**:期权复盘「期权交易记录」盈亏列、复盘 KPI、`realized_pnl_usdt` 冻结
- **不含**:现货多买差分当盈亏 / 账户 after−before
- 账户 `trading_usdt_before/after` 仅审计用,**不**作复盘真盈亏
### 2.2 口径 B — 期权历史 / 数据统计(仅期权腿)
```
期权腿盈亏(币) = 收回权利金 − 已付权利金 − 期权开/平手续费
统计用 U ≈ 期权腿盈亏(币) × 平仓卖回价
```
- **消费者**:期权面板「期权历史」(币)、数据统计 / 顶栏窗口统计(折 U)
- **不含**现货;**不**做「开仓价 vs 卖回价」权利金 USDT 波动修正(那是口径 A)
- 与复盘真盈亏 A **独立**:`A = B − 权利金×(开仓价−卖回价)`
### 2.3 初始权利金、盈利率、期权手续费
| 字段 | 定义 |
|------|------|
| 初始权利金(U) | `open_index_px × premium_paid`(不含 `budget_usdt` 多买) |
| 盈利率(复盘) | `真盈亏A / initial_premium_usdt` |
| 期权手续费(U) | 仅期权开/平:`open_fee×开仓指数 + close_fee×卖回价`(不含现货) |
复盘「期权交易记录」须展示:初始权利金(U)、真盈亏(A)、盈利率、期权手续费(U)。
---
## 3. 产品规则
### 3.1 开仓前
1. 有可卖标的币(ETH/BTC)→ 拒开仓(粉尘低于现货最小可卖可忽略)。
2. 拍交易账户 USDT → `trading_usdt_before`。
3. 开仓前若有多次划转:以**开仓前最后一次划转完成后**的余额为准(即开仓瞬间余额)。
4. 开仓失败:回滚卖币,并拍**最新**资金快照(不留下脏 before / 半成品桥阻挡后续)。
5. 写入 `initial_premium_usdt = open_index_px × premium_paid`(开仓成功后)。
### 3.2 持仓中
1. 划转按钮灰 + API 拒绝。
2. 面板合计盈亏(动态)= `Σ口径B已冻结` + `当前持仓浮盈`。
3. 本单未完成 A 结算前:复盘显示「待结算」,不写假 U。
### 3.3 平仓与卖回
1. 系统可多次部分平仓;**以最后一次全部平仓完成**为准进入卖回。
2. 期权空仓后:**卖光**交易账户该标的可卖币(**禁止**用开仓权利金 `coin_bought` 封顶)。
3. `coin_bought` 仅用于开仓阶段清理过量买币,不作平仓卖回上限。
4. 卖回未完成(仍有可卖币):桥保持待卖回,**不**进入 A 结算。
5. 真粉尘(低于现货最小):可关桥并进入 A 结算(差额含粉尘损失)。
### 3.4 结算与冻结(A)
1. `realized_pnl_usdt = after − before`,`pnl_settled=1`。
2. 此后不得因划转、指数、复盘同步、监控公式而修改。
3. 仅复盘读此冻结值。
### 3.5 划转
| 时机 | 行为 |
|------|------|
| 无持仓、无待卖回 | 允许划转 |
| 持仓中或待卖回 | 禁止划转 |
| 划转后 | 只影响下一笔开仓前的 `usdt_before`;不重算已冻结 A/B |
---
## 4. 状态机
```text
IDLE
│ 有可卖币? ──是──> 拒开仓
│ 快照 trading_usdt_before(划转取最后一次后余额)
▼
OPENING ──失败──> 回滚卖币 → 最新资金快照 → IDLE
│ 成功(一次一仓);写 initial_premium_usdt
▼
OPEN(禁划转)
│ 可多次部分平仓
▼
CLOSING(交易所仓位未清零则停留)
│ 仓位 = 0
▼
SELLING_SPOT(卖光可卖标的币;禁止权利金封顶)
│ 可卖币 < minSz(真粉尘)或已空
▼
SETTLE_A(after − before → 冻结;仅此态可写 pnl_settled=1)
▼
IDLE
```
**硬门禁**
| 条件 | 行为 |
|------|------|
| OPEN / CLOSING / SELLING_SPOT | 禁止划转 |
| 未进 SETTLE_A | 复盘 U 为待结算 |
| pnl_settled=1 | 禁止覆盖 A |
| SETTLE_A | 要求 open_positions=0 且无可卖标的币 |
实现上可为显式状态字段,或由 `options_trades.status` + 桥状态 + `pnl_settled` 等价表达,但**结算入口必须统一校验门禁**。
---
## 5. 数据字段
### 5.1 `options_trades`
| 字段 | 含义 |
|------|------|
| `trading_usdt_before` | 开仓前交易账户 USDT |
| `trading_usdt_after` | 卖光后交易账户 USDT |
| `realized_pnl_usdt` | **口径 A** 冻结值 |
| `pnl_settled` | 0/1,A 是否已冻结 |
| `realized_pnl` | 币数量净盈亏(期权历史展示) |
| `close_index_px` | 平仓/卖回价(口径 B 用) |
| `open_index_px` | 开仓指数 |
| `premium_paid` | 开仓权利金币量 |
| `initial_premium_usdt` | 初始权利金 U = open_index × premium_paid |
| `budget_usdt` | 买币花费(含缓冲),**不得**当权利金或 before |
### 5.2 `options_review_trades`
| 字段 | 含义 |
|------|------|
| `realized_pnl_total` | **仅口径 A**(或待结算空) |
| `initial_premium_usdt` | 初始权利金 U |
| `profit_rate` | A / 初始权利金(小数,如 1.5 = 1.5 倍) |
### 5.3 `options_exchange_pnl_freeze`
实盘统计辅助:历史 key → 口径 B 的 U 冻结。
---
## 6. 模块行为
### 6.1 开仓 `open_coin_option_buy_full`
1. 一次一仓 / 未完成桥检查;可卖币拒开。
2. `trading_usdt_before`;买币、下单、写库(`pnl_settled=0`,`initial_premium_usdt`)。
3. 桥 `coin_bought` 可记为权利金币量(供开仓清过量),**平仓卖回不得用其封顶**。
### 6.2 划转 `POST /api/options/transfer`
持仓或桥 ∈ {bought, holding, pending_sell} → 拒绝。
### 6.3 卖回与 A 结算
`sell_residual_after_option_flat`(及 retry / sweep):
1. 空仓后卖光可用标的币(`coin_amount=None`)。
2. 仍有可卖 → 桥 `pending_sell`,返回未完成,**不结算**。
3. 仅粉尘/已空 → 关桥 → `try_settle_after_sellback`(内再校验无可卖币)→ A 冻结。
### 6.4 数据统计(口径 B)
- `net_realized_pnl` / 胜率 / 盈亏比:历史币盈亏 × 卖回价。
- **不读** `pnl_settled` 账户差分。
- 数据统计页不得再打复盘 stats API 作为盈亏真源。
### 6.5 期权复盘(口径 A)
- 同步本地已平:优先 `pnl_settled=1` 的 `realized_pnl_usdt`。
- 未结算:显示待结算,不导入假 U。
- 同步 `initial_premium_usdt`、`profit_rate`。
### 6.6 期权历史列表
仍按**币**展示权利金/币盈亏;U 只用于复盘(A)与数据统计(B)。
---
## 7. 对账关系
```
口径 A: Σ 已冻结单笔 A + 期间净划转 ≈ 交易账户相对首笔 before 的累计变化
口径 B: Σ (历史币盈亏 × 卖回价) ≈ 面板数据统计已实现
```
差异应可归因:粉尘、滑点、手续费(A 内已含);B 不含多买缓冲。
---
## 8. 非目标 / 不做
- 不用 `budget_usdt` 当单笔盈利或初始权利金。
- 不用口径 A 覆盖数据统计,也不用口径 B 覆盖复盘。
- 持仓中不因行情重算已冻结 A/B。
- 不把期权历史列表强制改为 U。
---
## 9. 历史数据修复
对已错误冻结的账户差分(典型:币盈亏>0 且 A 为负,且 `coin_sold≈premium_paid≪premium_received`):
1. 回填 `initial_premium_usdt = open_index_px × premium_paid`。
2. 口径 B:按币盈亏 × 卖回价重算统计侧(含 `options_exchange_pnl_freeze`)。
3. 口径 A:卖回完整可重建则重算;否则 `pnl_settled=0` 标 `needs_resettle` / 异常,勿盲信错账。
4. 重刷 `options_review_trades.realized_pnl_total` / `profit_rate`。
脚本:`scripts/repair_options_dual_pnl.py`(dry-run → CSV → apply)。
---
## 10. 验收清单
- [ ] 盈利约 1.5 倍单:卖回含盈利币;复盘 A 为正,约等于账户差分
- [ ] 卖回不得按开仓权利金封顶
- [ ] 仍有可卖币时不 `pnl_settled=1`
- [ ] 复盘展示初始权利金、盈利率
- [ ] 数据统计 = 历史币盈亏 × 卖回价,与复盘 A 可不同
- [ ] 开仓写入 before + initial_premium;失败有最新快照/回滚
- [ ] 持仓禁划转;空仓划转不影响已冻结
- [ ] 历史错账可用脚本修复
---
## 11. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-10-09 | 初版:交易账户快照差分 + 一次一仓 + 持仓禁划转 + 卖回后冻结 |
| 2026-10-13 | 修订:双口径 A/B;卖光再结算;初始权利金/盈利率;状态机;历史修复脚本 |
| 2026-10-13 | 复盘「期权交易记录」盈亏与数据统计同口径 B;增加仅期权手续费展示 |
| 2026-10-13 | 交易记录改回真盈亏 A;期权历史/统计仅期权腿 B,不含现货 |
| 2026-10-13 | 真盈亏明确为卖光收回−买币;复盘禁止指数折 U 冒充;桥可回退 |
| 2026-10-13 | 真盈亏改回权利金 USDT 波动式:币盈亏×卖回价−权利金×(开−平);忽略多买 |
| 2026-10-13 | 多笔买/卖回用 VWAP;卖完再结算;待卖回/缺价未核标记 |
+4 -1
View File
@@ -1 +1,4 @@
"""crypto_monitor shared libraries."""
# Copyright (c) 2026 马建军. All rights reserved.
# Author: 马建军 | QQ: 253921841 | Email: 253921841@qq.com | Website: https://hyf2.cc
# See COPYRIGHT in the repository root.
"""crypto_okx shared libraries."""
-130
View File
@@ -1,130 +0,0 @@
"""
每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额.
- 交易账户 < 目标:从资金账户划入差额
- 交易账户 > 目标:将多余划回资金账户
- 有 active 持仓:不划转,写账簿并企业微信说明
"""
from __future__ import annotations
from typing import Any, Callable
def run_auto_transfer_once_per_day(
*,
enabled: bool,
bj_hour: int,
target_amount: float,
from_account: str,
to_account: str,
funds_decimals: int,
get_db: Callable[[], Any],
get_active_position_count: Callable[[Any], int],
get_account_usdt_total: Callable[[str], float | None],
execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]],
send_wechat_msg: Callable[[str], None],
utc_now_dt: Callable[[], Any],
app_tz: Any,
utc_calendar_date_str: Callable[[], str],
app_now_str: Callable[[], str],
min_transfer: float = 0.01,
) -> None:
if not enabled:
return
utc_dt = utc_now_dt()
bj = utc_dt.astimezone(app_tz)
if bj.hour != bj_hour:
return
transfer_day = utc_calendar_date_str()
conn = get_db()
exists = conn.execute(
"SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?",
("auto_daily", transfer_day),
).fetchone()
if exists:
conn.close()
return
def _log(
amount: float,
fr: str,
to: str,
status: str,
message: str,
*,
commit_close: bool = True,
) -> None:
conn.execute(
"INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)",
("auto_daily", transfer_day, amount, fr, to, status, message[:500]),
)
conn.commit()
if commit_close:
conn.close()
active = get_active_position_count(conn)
if active > 0:
msg = f"持仓中({active}笔),本次资金无划转"
_log(0, from_account, to_account, "skipped", msg)
send_wechat_msg(
f"自动划转:{msg}\n"
f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
return
target = round(float(target_amount), funds_decimals)
trade_bal = get_account_usdt_total(to_account)
if trade_bal is None:
_log(
0,
from_account,
to_account,
"failed",
f"读取{to_account}账户USDT失败",
)
return
trade = round(float(trade_bal), funds_decimals)
diff = round(target - trade, funds_decimals)
if abs(diff) < min_transfer:
_log(
0,
from_account,
to_account,
"skipped",
f"{to_account}账户已为{trade}U(目标{target}U)",
)
return
if diff > 0:
fr, to, amount = from_account, to_account, diff
action = "划入"
else:
fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals)
action = "划出"
from_bal = get_account_usdt_total(fr)
if from_bal is not None and round(float(from_bal), funds_decimals) < amount:
cur = round(float(from_bal), funds_decimals)
_log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U")
send_wechat_msg(
f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}至{to_account}目标{target}U)\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
return
ok, msg, _ = execute_transfer_usdt(amount, fr, to)
_log(amount, fr, to, "success" if ok else "failed", msg)
if ok:
send_wechat_msg(
f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
else:
send_wechat_msg(
f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n"
f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}"
)
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
/**
* 中控 iframe 嵌入鉴权:跨站时第三方 Cookie 常被拦截,改用 embed_sso 头维持登录.
* 须在其它业务脚本之前加载.
*/
(function () {
"use strict";
var KEY = "hub_embed_sso";
try {
var q = new URLSearchParams(location.search || "");
var fromUrl = (q.get("embed_sso") || "").trim();
if (fromUrl) {
sessionStorage.setItem(KEY, fromUrl);
q.delete("embed_sso");
var next = location.pathname;
var rest = q.toString();
if (rest) next += "?" + rest;
next += location.hash || "";
history.replaceState(null, "", next);
}
} catch (_) {}
function currentToken() {
try {
return (sessionStorage.getItem(KEY) || "").trim();
} catch (_) {
return "";
}
}
function withAuthHeaders(init) {
var tok = currentToken();
if (!tok) return init;
init = init ? Object.assign({}, init) : {};
var headers;
try {
headers = new Headers(init.headers || {});
} catch (_) {
headers = new Headers();
}
if (!headers.has("X-Hub-Embed-SSO")) {
headers.set("X-Hub-Embed-SSO", tok);
}
init.headers = headers;
if (!init.credentials) init.credentials = "same-origin";
return init;
}
if (typeof window.fetch === "function") {
var origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
return origFetch(input, withAuthHeaders(init));
};
}
window.HubEmbedAuth = {
token: currentToken,
clear: function () {
try {
sessionStorage.removeItem(KEY);
} catch (_) {}
},
};
})();
+133
View File
@@ -0,0 +1,133 @@
/**
* 系统设置 · 备份恢复
*/
(function (global) {
function statusEl() {
return document.getElementById("backup-status");
}
function setStatus(msg, isErr) {
const el = statusEl();
if (!el) return;
el.textContent = msg || "";
el.style.color = isErr ? "#fb7185" : "";
}
function fmtSize(n) {
const v = Number(n) || 0;
if (v < 1024) return v + " B";
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
return (v / (1024 * 1024)).toFixed(2) + " MB";
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function loadList() {
const body = document.getElementById("backup-list-body");
if (!body) return;
body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
try {
const r = await fetch("/api/settings/backup/list", { credentials: "same-origin" });
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载失败");
const dir = document.getElementById("backup-dir-label");
if (dir && j.backup_dir) dir.textContent = j.backup_dir;
const items = j.items || [];
if (!items.length) {
body.innerHTML = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
return;
}
body.innerHTML = items
.map(function (it) {
const name = escapeHtml(it.name || "");
return (
"<tr>" +
"<td><code style=\"font-size:.74rem\">" +
name +
"</code></td>" +
"<td>" +
escapeHtml(it.mtime_bj || "—") +
"</td>" +
"<td>" +
escapeHtml(fmtSize(it.size)) +
"</td>" +
"<td style=\"white-space:nowrap\">" +
'<a class="btn-secondary" style="padding:3px 8px;font-size:.74rem;text-decoration:none" href="/api/settings/backup/download/' +
encodeURIComponent(it.name) +
'">下载</a> ' +
'<button type="button" class="btn-del" style="padding:3px 8px;font-size:.74rem" data-backup-restore="' +
encodeURIComponent(it.name) +
'">恢复</button>' +
"</td>" +
"</tr>"
);
})
.join("");
} catch (e) {
body.innerHTML =
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
}
}
async function createBackup() {
setStatus("备份中…");
try {
const r = await fetch("/api/settings/backup/create", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "备份失败");
setStatus("已生成 " + (j.name || "备份"));
await loadList();
} catch (e) {
setStatus(e.message || "备份失败", true);
}
}
async function restoreBackup(name) {
if (!name) return;
if (!confirm("确定用该备份恢复?\n将覆盖 .env、数据库与复盘图片,建议恢复后重启。")) return;
setStatus("恢复中…");
try {
const r = await fetch("/api/settings/backup/restore", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "恢复失败");
setStatus(j.msg || "恢复完成");
} catch (e) {
setStatus(e.message || "恢复失败", true);
}
}
function boot() {
const root = document.querySelector("[data-backup-root]");
if (!root || root.dataset.bound === "1") return;
root.dataset.bound = "1";
const createBtn = document.getElementById("backup-create-btn");
const refreshBtn = document.getElementById("backup-refresh-btn");
if (createBtn) createBtn.addEventListener("click", createBackup);
if (refreshBtn) refreshBtn.addEventListener("click", loadList);
root.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-backup-restore]");
if (!btn) return;
const name = decodeURIComponent(btn.getAttribute("data-backup-restore") || "");
void restoreBackup(name);
});
void loadList();
}
global.InstanceBackup = { boot: boot, loadList: loadList };
})(typeof window !== "undefined" ? window : globalThis);
+5
View File
@@ -16,6 +16,7 @@
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
live_guide: "/live_guide",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
@@ -154,6 +155,9 @@
}
}
}
if (tab === "settings" && global.InstanceBackup && typeof global.InstanceBackup.boot === "function") {
global.InstanceBackup.boot();
}
}
if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") {
@@ -358,6 +362,7 @@
const hideTopBar =
tab === "settings" ||
tab === "risk_policy" ||
tab === "live_guide" ||
tab === "system_guide" ||
tab === "env_config" ||
tab === "options_review";
+1
View File
@@ -102,6 +102,7 @@
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
live_guide: "/live_guide",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
+17 -6
View File
@@ -4,14 +4,24 @@
.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem}
.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 12px}
/* 1920 左右用满:约 96vw / 1840,两侧留白更小 */
.container{width:100%;max-width:min(1840px,96vw);margin:0 auto;padding:0 clamp(6px,1vw,14px)}
/* 普通屏:铺满可用宽度;带鱼屏再锁 1800(见下方 media) */
.container{width:100%;max-width:none;margin:0 auto;padding:0 clamp(6px,1vw,14px)}
body[data-embed-shell="1"] .container{
width:100%;
max-width:min(1800px, 100%);
margin-left:auto;
margin-right:auto;
padding:0 clamp(6px,1vw,14px);
}
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
.top-nav{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:10px 12px;margin-bottom:12px}
.top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;min-width:0}
.top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;align-items:center;min-width:0}
.top-nav-links > a{flex:0 0 auto;white-space:nowrap}
/* 兜底:若链接未包在 .top-nav-links 内,勿被 1fr|auto|1fr 拉成三列变形 */
.top-nav > a{justify-self:center;align-self:center;width:max-content;max-width:100%}
.top-nav-spot-tickers{grid-column:3;justify-self:end;display:flex;flex-direction:column;gap:3px;min-width:0}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
@@ -165,11 +175,12 @@
.panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
/* 带鱼屏(宽高比≥21:9):整页固定 1920 居中;不依赖视口宽度(避免 Win 缩放后失效) */
/* 带鱼屏:独立打开与中控内嵌内容均锁 1800 居中留白;工作台外壳由中控全屏 */
@media (min-aspect-ratio: 21/9) {
.container{
.container,
body[data-embed-shell="1"] .container{
width:100%;
max-width:min(1920px, calc(100vw - 24px));
max-width:min(1800px, calc(100vw - 24px));
margin-left:auto;
margin-right:auto;
}
+73 -25
View File
@@ -46,9 +46,8 @@
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
live_guide: "show_nav_live_guide",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
@@ -59,6 +58,11 @@
.forEach((a) => {
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
if (tab === "more") return;
if (tab === "hedge_plan" || tab === "hedge-plan") {
a.classList.add("nav-hidden");
a.style.display = "none";
return;
}
const key = map[tab];
if (!key) return;
const show = navPrefShow(display, key);
@@ -70,6 +74,7 @@
function pageNavAllowed(tab) {
const d = DISPLAY();
if (tab === "hedge_plan" || tab === "hedge-plan") return false;
const map = {
dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
@@ -82,9 +87,8 @@
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
live_guide: "show_nav_live_guide",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
@@ -295,7 +299,21 @@
}
input.dataset.envKey = field.key;
input.className = "env-field-input";
if (field.key === "HUB_BRIDGE_TOKEN" && !field.sensitive && field.type !== "bool" && field.type !== "select") {
const wrap = document.createElement("div");
wrap.className = "env-field-input-wrap env-field-input-wrap--copy";
wrap.appendChild(input);
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "btn-secondary btn-sm env-copy-btn";
copyBtn.dataset.envCopy = "HUB_BRIDGE_TOKEN";
copyBtn.title = "复制 Token";
copyBtn.textContent = "复制";
wrap.appendChild(copyBtn);
row.appendChild(wrap);
} else {
row.appendChild(input);
}
if (field.hidden) {
row.hidden = true;
row.style.display = "none";
@@ -397,42 +415,26 @@
function syncOptionsSectionVisibility(body) {
if (!body) return;
var margin = (envFieldValue(body, "OKX_OPTIONS_MARGIN_MODE", "coin") || "coin").toLowerCase();
var isCoin = margin !== "usdc";
// 仅币本位期权:USDC/永续相关 env 始终隐藏
var isCoin = true;
var gateMode = (envFieldValue(body, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") || "premium").toLowerCase();
var compoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"));
var coinCompoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_COMPOUND", "true"));
var coinMaxOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"));
var compoundCapOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"));
COIN_ONLY_ENV_KEYS.forEach(function (key) {
setEnvRowHidden(envFieldRowByKey(body, key), !isCoin);
});
USDC_ONLY_ENV_KEYS.forEach(function (key) {
setEnvRowHidden(envFieldRowByKey(body, key), isCoin);
setEnvRowHidden(envFieldRowByKey(body, key), true);
});
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_MARGIN_MODE"), true);
setEnvRowHidden(envFieldRowByKey(body, "OKX_SHOW_PERP_FUNDS"), true);
// 门控子项:按模式显隐(仍受本位过滤)
if (isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium");
} else {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT"), gateMode !== "premium");
}
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT"), gateMode !== "net_pnl");
if (!isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"), compoundOn);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED"), !compoundOn);
setEnvRowHidden(
envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC"),
!(compoundOn && compoundCapOn)
);
}
if (isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
}
}
function bindOptionsSectionVisibility(body) {
if (!body) return;
@@ -635,12 +637,58 @@
}
}
async function copyTextToClipboard(text) {
const value = String(text || "");
if (!value) throw new Error("内容为空");
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const ta = document.createElement("textarea");
ta.value = value;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
if (!ok) throw new Error("复制失败");
}
async function copyEnvFieldValue(key, btn) {
const status = document.getElementById("env-config-status");
const input = document.querySelector('.env-field-input[data-env-key="' + key + '"]');
const value = input ? String(input.value || "").trim() : "";
try {
await copyTextToClipboard(value);
if (btn) {
const old = btn.textContent;
btn.textContent = "已复制";
btn.disabled = true;
setTimeout(() => {
btn.textContent = old || "复制";
btn.disabled = false;
}, 1200);
}
setStatus(status, "已复制到剪贴板");
} catch (e) {
setStatus(status, (e && e.message) || "复制失败", true);
}
}
function installDelegatedHandlers() {
if (document.documentElement.dataset.prefsDelegateBound === "1") return;
document.documentElement.dataset.prefsDelegateBound = "1";
document.addEventListener("click", (ev) => {
const target = ev.target;
if (!(target instanceof Element)) return;
const copyBtn = target.closest("[data-env-copy]");
if (copyBtn) {
ev.preventDefault();
void copyEnvFieldValue(copyBtn.getAttribute("data-env-copy") || "", copyBtn);
return;
}
if (target.closest("#display-prefs-save")) {
ev.preventDefault();
void saveDisplayPrefs();
+158
View File
@@ -626,6 +626,39 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
margin-left: auto;
}
.instance-logout-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 12px;
font-size: 0.82rem;
line-height: 1.2;
color: var(--inst-muted, #9aa3b2);
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.instance-logout-btn:hover {
color: var(--inst-text, #e8ecf4);
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.04);
}
html[data-theme="light"] .instance-logout-btn {
color: #5b6472;
border-color: rgba(0, 0, 0, 0.12);
}
html[data-theme="light"] .instance-logout-btn:hover {
color: #1f2937;
border-color: rgba(0, 0, 0, 0.22);
background: rgba(0, 0, 0, 0.03);
}
.instance-header-toolbar-filter label {
display: inline-flex;
align-items: center;
@@ -661,9 +694,15 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
gap: 8px;
flex-wrap: wrap;
justify-content: center;
align-items: center;
min-width: 0;
}
.top-nav-links > a {
flex: 0 0 auto;
white-space: nowrap;
}
.top-nav-spot-tickers {
display: flex;
flex-direction: column;
@@ -2684,6 +2723,24 @@ html[data-theme="light"] .journal-detail-img-thumb {
box-sizing: border-box;
}
.env-field-input-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.env-field-input-wrap .env-field-input {
flex: 1 1 auto;
min-width: 0;
}
.env-field-input-wrap--copy .env-copy-btn {
flex: 0 0 auto;
margin-top: 0;
white-space: nowrap;
}
.env-config-loading-wrap {
padding: 24px;
text-align: center;
@@ -3315,6 +3372,15 @@ html[data-theme="light"] .settings-side-export-label {
html[data-theme="light"] .opt-chain-lev {
color: #1a4a8a;
}
.opt-chain-delta {
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 600;
color: #9ad5ff;
}
html[data-theme="light"] .opt-chain-delta {
color: #0b5cad;
}
.opt-be-dist-up {
color: #5ee89a;
}
@@ -3344,6 +3410,21 @@ html[data-theme="light"] .opt-be-dist-down {
background: rgba(74, 124, 255, 0.45);
box-shadow: inset 0 0 0 1px rgba(140, 175, 255, 0.6);
}
.options-page-wrap .opt-pick-btn:disabled,
.options-page-wrap .opt-pick-btn.is-locked {
opacity: 0.42;
cursor: not-allowed;
color: #8b95a8;
background: rgba(20, 28, 44, 0.55);
border-color: rgba(80, 90, 110, 0.5);
box-shadow: none;
}
html[data-theme="light"] .options-page-wrap .opt-pick-btn:disabled,
html[data-theme="light"] .options-page-wrap .opt-pick-btn.is-locked {
color: #7a8799;
background: rgba(148, 163, 184, 0.28);
border-color: rgba(148, 163, 184, 0.55);
}
.opt-strike-row.opt-row-selected td {
background: rgba(74, 124, 255, 0.1);
}
@@ -5483,12 +5564,89 @@ html[data-theme="light"] .opt-source-badge--oo {
flex-wrap: wrap;
justify-content: flex-end;
}
.opt-cond-order-card {
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(14, 42, 68, 0.72);
}
.opt-cond-order-card[hidden] {
display: none !important;
}
.opt-cond-order-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.opt-cond-order-head strong {
font-size: 0.9rem;
}
.opt-cond-lock-note {
margin-left: auto;
font-size: 0.72rem;
color: #9ec0ff;
}
.opt-cond-st {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
}
.opt-cond-st.is-armed {
color: #7dd3fc;
background: rgba(56, 189, 248, 0.16);
}
.opt-cond-st.is-trig {
color: #fbbf24;
background: rgba(251, 191, 36, 0.16);
}
.opt-cond-st.is-open {
color: #6ee7b7;
background: rgba(52, 211, 153, 0.16);
}
.opt-cond-order-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px 12px;
}
.opt-cond-kv .k {
display: block;
font-size: 0.68rem;
color: #8ea0c0;
}
.opt-cond-kv .v {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.opt-cond-order-msg {
margin-top: 8px;
font-size: 0.72rem;
color: #c9d6f5;
}
html[data-theme="light"] .opt-cond-order-card {
border-color: rgba(14, 116, 144, 0.28);
background: rgba(224, 242, 254, 0.7);
}
html[data-theme="light"] .opt-cond-lock-note,
html[data-theme="light"] .opt-cond-kv .k,
html[data-theme="light"] .opt-cond-order-msg {
color: #35506e;
}
.opt-spot-sell-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* display:flex 会盖掉 UA [hidden] */
.opt-spot-sell-head[hidden] {
display: none !important;
}
.opt-spot-sell-avail {
font-size: 0.78rem;
color: #c9d6f5;
+2
View File
@@ -171,6 +171,8 @@
function patchHubNavLinks(theme) {
if (!isHubLinked()) return;
// embed 壳由 InstanceEmbed 软切 tab,勿改写为 /xxx?embed=1(会绕开壳落到 index 顶栏变形)
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
const t = normalize(theme || get());
document
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
+342
View File
@@ -0,0 +1,342 @@
/**
* 实例数据统计:复盘 KPI + 已实现盈亏曲线.
*/
(function (global) {
"use strict";
var currentRange = "7d";
var booted = false;
function $(id) {
return document.getElementById(id);
}
function root() {
return $("options-data-stats");
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function fmtHold(sec) {
if (sec == null) return "—";
var s = Math.max(0, Number(sec) || 0);
if (s < 3600) return Math.round(s / 60) + "m";
if (s < 86400) return (s / 3600).toFixed(1) + "h";
return (s / 86400).toFixed(1) + "d";
}
function pnlClass(v) {
var n = Number(v);
if (Number.isNaN(n) || n === 0) return "";
return n > 0 ? "ods-pnl-pos" : "ods-pnl-neg";
}
function closeReasonLabel(r) {
var map = {
perp_tp: "永续止盈",
perp_sl: "永续止损",
oo_expiry_loss: "期期到期亏损",
oo_expiry_win: "期期到期盈利",
target_win_leg: "期期平盈利腿",
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
};
return map[r] || String(r || "未填");
}
function sourceType() {
var el = root();
return (el && el.getAttribute("data-source-type")) || "option_spot";
}
function setStatus(text) {
var el = $("ods-status");
if (el) el.textContent = text || "";
}
function renderGroup(title, items) {
if (!items || !items.length) return "";
var lines = items
.slice(0, 8)
.map(function (g) {
var keyLabel =
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
return (
'<div class="ods-stat-row">' +
'<span class="ods-stat-key">' +
escapeHtml(keyLabel) +
" · " +
g.count +
"笔</span>" +
'<span class="ods-stat-val">' +
fmtPnl(g.pnl_sum) +
" / 胜" +
(g.win_rate || 0) +
"%</span>" +
"</div>"
);
})
.join("");
return (
'<div class="ods-stat-card"><div class="ods-stat-card-title">' +
title +
"</div>" +
lines +
"</div>"
);
}
function loadReviewStats() {
var kpi = $("ods-kpi");
var groups = $("ods-stats-groups");
if (!kpi || !groups) return Promise.resolve();
// 口径 B:期权历史币盈亏×卖回价(/api/options/stats),与复盘账户差分独立
return fetch("/api/options/stats", { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.ok) {
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
groups.innerHTML = "";
return;
}
var unit = data.pnl_unit || "U";
var net = data.net_realized_pnl;
var avg =
data.total_closed > 0 && net != null
? Number(net) / Number(data.total_closed)
: null;
kpi.innerHTML = [
["已平笔数", data.total_closed || 0],
["胜率", (data.win_rate || 0) + "%"],
["盈亏比", data.profit_loss_ratio != null ? data.profit_loss_ratio : "—"],
["累计盈亏", fmtPnl(net)],
["平均盈利", data.avg_win != null ? fmtPnl(data.avg_win) : "—"],
["平均亏损", data.avg_loss != null ? fmtPnl(-Math.abs(Number(data.avg_loss))) : "—"],
["平均持有", fmtHold(data.avg_hold_sec)],
]
.map(function (pair) {
var cls = "";
if (pair[0] === "累计盈亏") cls = pnlClass(net);
if (pair[0] === "平均盈利") cls = "pnl-pos";
if (pair[0] === "平均亏损") cls = "pnl-neg";
return (
'<div class="ods-kpi-tile"><div class="ods-kpi-label">' +
pair[0] +
'</div><div class="ods-kpi-value' +
(cls ? " " + cls : "") +
'">' +
pair[1] +
"</div></div>"
);
})
.join("");
groups.innerHTML =
'<div class="muted" style="font-size:.76rem">数据统计口径:期权历史盈亏×平仓卖回价(与复盘账户差分独立)。单位 ' +
unit +
(avg != null ? " · 笔均 " + fmtPnl(avg) : "") +
"</div>";
})
.catch(function () {
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
groups.innerHTML = "";
});
}
function fmtCash(v) {
if (v == null || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(2) + "U";
}
function drawCurve(points) {
var svg = $("ods-curve-svg");
if (!svg) return;
var W = 640;
var H = 240;
var padL = 48;
var padR = 12;
var padT = 16;
var padB = 28;
var stroke = getComputedStyle(document.body).getPropertyValue("--accent") || "#5aa0ff";
stroke = (stroke || "").trim() || "#5aa0ff";
var grid = "rgba(127,127,127,.22)";
var muted = "rgba(160,160,180,.75)";
if (!points || !points.length) {
svg.innerHTML =
'<text x="320" y="120" text-anchor="middle" fill="' +
muted +
'" font-size="13">暂无已实现盈亏数据</text>';
return;
}
var vals = points.map(function (p) {
return Number(p.v);
});
var ts = points.map(function (p) {
return Number(p.t);
});
var minV = Math.min.apply(null, vals);
var maxV = Math.max.apply(null, vals);
if (maxV - minV < 1e-6) {
minV -= 1;
maxV += 1;
}
var minT = ts[0];
var maxT = ts[ts.length - 1];
if (maxT <= minT) maxT = minT + 1;
function xOf(t) {
return padL + ((t - minT) / (maxT - minT)) * (W - padL - padR);
}
function yOf(v) {
return padT + (1 - (v - minV) / (maxV - minV)) * (H - padT - padB);
}
var path = points
.map(function (p, i) {
var x = xOf(p.t);
var y = yOf(p.v);
return (i ? "L" : "M") + x.toFixed(1) + "," + y.toFixed(1);
})
.join(" ");
var yTicks = [minV, (minV + maxV) / 2, maxV];
var gridHtml = yTicks
.map(function (v) {
var y = yOf(v);
return (
'<line x1="' +
padL +
'" y1="' +
y.toFixed(1) +
'" x2="' +
(W - padR) +
'" y2="' +
y.toFixed(1) +
'" stroke="' +
grid +
'" stroke-width="1"/>' +
'<text x="' +
(padL - 6) +
'" y="' +
(y + 4).toFixed(1) +
'" text-anchor="end" fill="' +
muted +
'" font-size="10">' +
Number(v).toFixed(0) +
"</text>"
);
})
.join("");
var last = points[points.length - 1];
svg.innerHTML =
gridHtml +
'<path d="' +
path +
'" fill="none" stroke="' +
stroke +
'" stroke-width="2" vector-effect="non-scaling-stroke"/>' +
'<circle cx="' +
xOf(last.t).toFixed(1) +
'" cy="' +
yOf(last.v).toFixed(1) +
'" r="3.5" fill="' +
stroke +
'"/>';
}
function loadCashCurve() {
var meta = $("ods-curve-meta");
return fetch("/api/instance/cash_curve?range=" + encodeURIComponent(currentRange), {
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.ok) {
if (meta) meta.textContent = "曲线加载失败";
drawCurve([]);
return;
}
var pts = data.points || [];
drawCurve(pts);
if (meta) {
meta.textContent =
"已实现 " +
(data.trade_count != null ? data.trade_count + " 笔 · " : "") +
pts.length +
" 点 · 累计 " +
fmtCash(data.latest != null ? data.latest : pts.length ? pts[pts.length - 1].v : null);
}
})
.catch(function () {
if (meta) meta.textContent = "曲线加载失败";
drawCurve([]);
});
}
function reloadAll() {
setStatus("加载中…");
return Promise.all([loadReviewStats(), loadCashCurve()]).then(function () {
setStatus("");
});
}
function bindOnce() {
if (booted || !root()) return;
booted = true;
var btn = $("ods-reload-btn");
if (btn) {
btn.addEventListener("click", function () {
reloadAll();
});
}
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (b) {
b.addEventListener("click", function () {
currentRange = b.getAttribute("data-range") || "7d";
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
x.classList.toggle("active", x === b);
});
loadCashCurve();
});
});
// 默认 7 日更有曲线感;若用户点今日再切
var def7 = document.querySelector('#options-data-stats .ods-range-btn[data-range="7d"]');
if (def7) {
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
x.classList.toggle("active", x === def7);
});
currentRange = "7d";
}
reloadAll();
}
function bootIfPresent() {
if (root()) bindOnce();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootIfPresent);
} else {
bootIfPresent();
}
// embed 软切 tab 时脚本可能再次插入
global.__OPTIONS_DATA_STATS_BOOT__ = bootIfPresent;
})(typeof window !== "undefined" ? window : this);
+275 -27
View File
@@ -8,8 +8,30 @@
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {});
const allowedUnderlys = (function () {
const raw = String(root.dataset.allowedUnderlys || "ETH,BTC");
const list = raw
.split(",")
.map(function (s) {
return String(s || "")
.trim()
.toUpperCase();
})
.filter(function (u) {
return u === "ETH" || u === "BTC";
});
return list.length ? list : ["ETH", "BTC"];
})();
const bootUnderly = (function () {
const d = String(root.dataset.defaultUnderly || "ETH")
.trim()
.toUpperCase();
if (allowedUnderlys.indexOf(d) >= 0) return d;
return allowedUnderlys[0] || "ETH";
})();
const state = {
underlying: root.dataset.defaultUnderly || "ETH",
underlying: bootUnderly,
optType: "C",
moneyFilter: "all",
chainView: "list",
@@ -19,7 +41,7 @@
budgetBuffer: (function () {
const raw = root.dataset.budgetBuffer;
const n = raw != null && raw !== "" ? Number(raw) : NaN;
return !Number.isNaN(n) && n > 0 ? n : 0.95;
return !Number.isNaN(n) && n > 0 ? n : 1;
})(),
chain: panelCache.chain || null,
selectedInst: null,
@@ -30,6 +52,7 @@
targetDraftByInst: {},
/** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */
profitExitDraftByInst: {},
condOrderLocked: false,
};
let lastGoodPositions = null;
@@ -78,7 +101,21 @@
async function apiJson(url, opts) {
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
return r.json();
const text = await r.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
if (/\/login/i.test(r.url || "") || /^\s*<!doctype/i.test(text) || /^\s*<html/i.test(text)) {
throw new Error("未登录或会话过期,请从中控重新打开工作台");
}
const snip = String(text || "").replace(/\s+/g, " ").slice(0, 100);
throw new Error("接口返回非 JSON(HTTP " + r.status + ")" + (snip ? ": " + snip : ""));
}
if (r.status === 401) {
throw new Error((data && data.msg) || "未登录或会话过期,请从中控重新打开工作台");
}
return data;
}
function orderPanel() {
@@ -89,14 +126,35 @@
return document.getElementById("opt-order-panel-host");
}
function applyCondPickLock() {
const locked = !!state.condOrderLocked;
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
btn.disabled = locked;
btn.classList.toggle("is-locked", locked);
if (locked) {
btn.classList.remove("active");
btn.title = "中控条件单占用中,禁止手动开仓";
if (!btn.dataset.origText) btn.dataset.origText = "选择";
btn.textContent = btn.dataset.origText;
} else {
btn.removeAttribute("title");
}
});
const openBtn = document.getElementById("opt-open-btn");
if (openBtn && locked) {
openBtn.disabled = true;
openBtn.title = "中控条件单占用中,禁止手动开仓";
}
}
function syncPickButtons(instId) {
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
const on = !!instId && btn.getAttribute("data-inst") === instId;
const on = !state.condOrderLocked && !!instId && btn.getAttribute("data-inst") === instId;
btn.classList.toggle("active", on);
btn.disabled = false;
if (!btn.dataset.origText) btn.dataset.origText = "选择";
btn.textContent = on ? "已选" : btn.dataset.origText;
});
applyCondPickLock();
}
function parkOrderPanel() {
@@ -282,9 +340,10 @@
const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
if (el) return el.value;
const any = document.querySelector('input[name="opt-size-mode"]:checked');
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "sheets";
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "budget_full";
if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full";
return "sheets";
// 默认按可用余额打满(复利开时等价全仓复利)
return compoundFullEnabled() ? "compound_full" : "budget_full";
}
function applyCompoundModeUi(compoundOn) {
@@ -336,13 +395,13 @@
compoundRadio.checked = false;
compoundRadio.disabled = true;
}
// currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓
// 无有效选中时默认「按可用余额打满」
const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
if (!checkedOk) {
const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]');
if (sheetsRadio) {
sheetsRadio.disabled = false;
sheetsRadio.checked = true;
const fullRadio = document.querySelector('input[name="opt-size-mode"][value="budget_full"]');
if (fullRadio) {
fullRadio.disabled = false;
fullRadio.checked = true;
}
}
}
@@ -418,7 +477,15 @@
}
function strikeTableColspan() {
return 9;
return state.chainView === "t" ? 9 : 10;
}
function fmtDelta(v) {
if (v === null || v === undefined || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const sign = n > 0 ? "+" : "";
return sign + n.toFixed(3);
}
function syncChainViewUI() {
@@ -637,7 +704,16 @@
function pickBtnHtml(instId) {
if (!instId) return "—";
return '<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + instId + '">选择</button>';
const locked = !!state.condOrderLocked;
return (
'<button type="button" class="btn-secondary opt-pick-btn' +
(locked ? " is-locked" : "") +
'" data-inst="' +
instId +
'"' +
(locked ? ' disabled title="中控条件单占用中,禁止手动开仓"' : "") +
">选择</button>"
);
}
function syncMoneyFilterButtons() {
@@ -1031,6 +1107,25 @@
return "约 " + Number(v).toFixed(1) + "×";
}
function calcPosLeverage(p) {
const direct = Number(p && (p.contract_leverage != null ? p.contract_leverage : p.leverage));
if (Number.isFinite(direct) && direct > 0) return direct;
const idx = Number(p && p.idx_px);
const amt = Number(p && p.eth_amount);
const prem = Number(p && p.premium_paid);
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
return null;
}
if (isCoinPos(p)) {
return Math.round((amt / prem) * 10) / 10;
}
return Math.round((idx * amt) / prem * 10) / 10;
}
function fmtPosLeverage(p) {
return fmtLeverage(calcPosLeverage(p));
}
/** 链上展示:USDC=指数÷卖一(美元);币本位卖一为币报价 → 1÷卖一. */
function calcAskLeverage(indexPx, askPx) {
if (indexPx == null || askPx == null) return null;
@@ -1153,6 +1248,11 @@
function finishStrikeRender(tbody, prevSelected, matchedSelected) {
bindStrikePickButtons(tbody);
applyCondPickLock();
if (state.condOrderLocked) {
state.selectedInst = null;
return;
}
if (matchedSelected && prevSelected) {
selectContract(prevSelected, null, true);
} else if (!matchedSelected) {
@@ -1216,6 +1316,7 @@
"<td><code>" + c.inst_id + "</code></td>" +
"<td class=\"opt-px-sz\">" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "</td>" +
'<td class="opt-chain-lev">' + fmtChainLeverage(chainLev) + "</td>" +
'<td class="opt-chain-delta" title="Delta">' + fmtDelta(c.delta) + "</td>" +
"<td class=\"opt-px-sz\">" + fmtPxSz(c.bid, c.bid_sz) + "</td>" +
"<td>" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "</td>" +
'<td class="' + distBeClass(c.dist_expiry_be) + '">' + fmtDist(c.dist_expiry_be) + "</td>" +
@@ -1393,6 +1494,10 @@
}
async function selectContract(instId, pickBtn, silent) {
if (state.condOrderLocked) {
applyCondPickLock();
return null;
}
const seq = ++selectSeq;
state.selectedInst = instId;
placeOrderPanelAfter(instId);
@@ -1637,6 +1742,10 @@
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
'<span class="pos-meta-item" title="名义价值÷权利金,测算用">杠杆倍数: ' + fmtPosLeverage(p) + "</span>" +
'<span class="pos-meta-item" title="OKX deltaBS(单位 Delta)">Delta: ' +
fmtDelta(p.delta != null ? p.delta : p.delta_bs) +
"</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
: "") +
@@ -1645,6 +1754,9 @@
'<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">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">Delta</span><span class="pos-value" title="OKX deltaBS">' +
fmtDelta(p.delta != null ? p.delta : p.delta_bs) +
"</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.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
@@ -1674,6 +1786,21 @@
return fmt(n, 2) + "倍";
}
function profitExitHint(mult, req, premCcy) {
const n = Number(mult);
const m = Number.isFinite(n) && n > 0 ? n : 1;
const needFactor = 1 + m;
const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9
? String(Math.round(needFactor))
: fmt(needFactor, 2);
let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2)
+ " → 需回收≥权利金×" + factorTxt;
if (req != null) {
hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy;
}
return hint;
}
function renderProfitExitRow(p) {
const inst = p.inst_id || "";
if (p.hedge_plan_target) {
@@ -1694,6 +1821,7 @@
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
const req = p.profit_exit_required_recycle;
const premCcy = posPremiumCcy(p);
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
return (
@@ -1710,7 +1838,7 @@
'<span class="opt-target-armed">' + statusTxt + "</span>" +
'<span class="muted opt-target-row-hint">' +
(enabled
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
? profitExitHint(hintMult, req, premCcy)
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
"</span>" +
"</div>"
@@ -2227,26 +2355,53 @@
return n.toFixed(8).replace(/\.?0+$/, "");
}
function spotSellMinSz(ss, uly) {
const u = String(uly || "ETH").toUpperCase();
const fromApi = u === "BTC" ? Number(ss && ss.min_sz_btc) : Number(ss && ss.min_sz_eth);
if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
return u === "BTC" ? 0.00001 : 0.0001;
}
function shouldShowSpotSellBanner(d, positions) {
if (!isCoinMarginMode()) return false;
const ss = d && d.spot_sell;
if (!ss) return false;
const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); });
if (hasCoinPos) return false;
if (ss.bridge_status === "pending_sell_spot") return true;
// 仅当交易户有「可卖」(>= 现货 minSz)残留时展示;粉尘不展示、不提示重试
const ss = (d && d.spot_sell) || {};
const eth = Number(ss.trading_eth || 0);
const btc = Number(ss.trading_btc || 0);
return eth > 1e-12 || btc > 1e-12;
const ethOk = eth >= spotSellMinSz(ss, "ETH");
const btcOk = btc >= spotSellMinSz(ss, "BTC");
if (ethOk || btcOk) return true;
const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); });
const coinMode =
isCoinMarginMode() ||
hasCoinPos ||
(d && d.options_margin_mode === "coin");
// 待卖回桥且仍有可卖数量时才露按钮(粉尘桥后端会关掉)
if (coinMode && ss.bridge_status === "pending_sell_spot") {
const bu = String(ss.bridge_underlying || "").toUpperCase();
const amt = bu === "BTC" ? btc : eth;
return amt >= spotSellMinSz(ss, bu || "ETH");
}
return false;
}
function spotSellUnderlying(ss) {
if (!ss) return (state.underlying || "ETH").toUpperCase();
if (ss.bridge_underlying) return String(ss.bridge_underlying).toUpperCase();
const selected = (state.underlying || "ETH").toUpperCase();
if (!ss) return selected;
const bridgeUly = String(ss.bridge_underlying || "").trim().toUpperCase();
const eth = Number(ss.trading_eth || 0);
const btc = Number(ss.trading_btc || 0);
if (btc > eth && btc > 1e-12) return "BTC";
if (eth > 1e-12) return "ETH";
return (ss.default_underly || state.underlying || "ETH").toUpperCase();
const ethMin = spotSellMinSz(ss, "ETH");
const btcMin = spotSellMinSz(ss, "BTC");
// 待卖回桥优先;否则优先当前选中标的上的可卖残留
if (bridgeUly === "BTC" || bridgeUly === "ETH") {
if (ss.bridge_status === "pending_sell_spot") return bridgeUly;
}
if (selected === "BTC" && btc >= btcMin) return "BTC";
if (selected === "ETH" && eth >= ethMin) return "ETH";
if (btc >= btcMin && btc >= eth) return "BTC";
if (eth >= ethMin) return "ETH";
if (bridgeUly === "BTC" || bridgeUly === "ETH") return bridgeUly;
return (ss.default_underly || selected || "ETH").toUpperCase();
}
function paintSpotSellBanner(d, positions) {
@@ -2271,6 +2426,90 @@
head.hidden = false;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function condStatusLabel(st) {
const map = { armed: "待命", triggering: "触发中", open: "持仓中" };
return map[st] || st || "已接收";
}
function condStatusClass(st) {
if (st === "open") return "is-open";
if (st === "triggering") return "is-trig";
return "is-armed";
}
function paintCondOrderLock(d) {
const card = document.getElementById("opt-cond-order-card");
if (!card) return;
const order = d && d.cond_order;
const locked = !!(d && d.cond_order_locked);
const wasLocked = !!state.condOrderLocked;
state.condOrderLocked = locked || !!order;
if (state.condOrderLocked && !wasLocked) closeOrderDialog();
applyCondPickLock();
if (!order && !locked) {
card.hidden = true;
return;
}
const st = order ? String(order.status || "") : "";
const stEl = document.getElementById("opt-cond-order-st");
if (stEl) {
stEl.textContent = condStatusLabel(st);
stEl.className = "opt-cond-st " + condStatusClass(st);
}
const grid = document.getElementById("opt-cond-order-grid");
if (grid) {
if (!order) {
grid.innerHTML = "";
} else {
const put = String(order.opt_type || "").toUpperCase() !== "C";
const side = put ? "上穿 ≥" : "下穿 ≤";
const exitParts = [];
if (order.profit_exit_enabled) {
exitParts.push("翻倍×" + (order.profit_exit_mult != null ? order.profit_exit_mult : ""));
}
if (order.target_index != null && order.target_index !== "") {
exitParts.push("目标 " + order.target_index);
}
const items = [
["标的", (order.underlying || "") + " " + (put ? "Put" : "Call")],
["触发", side + " " + (order.trigger_px != null ? order.trigger_px : "—")],
["偏离", order.deviation_pct != null ? order.deviation_pct + "%" : "—"],
["最短到期", order.min_expiry_hours != null ? order.min_expiry_hours + "h" : "—"],
["出场", exitParts.join(" · ") || "—"],
["当前指数", order.last_index != null && order.last_index !== "" ? String(order.last_index) : "—"],
];
if (order.opened_inst_id) {
items.push(["已开合约", String(order.opened_inst_id)]);
}
grid.innerHTML = items
.map(function (it) {
return (
'<div class="opt-cond-kv"><span class="k">' +
escHtml(it[0]) +
'</span><span class="v">' +
escHtml(it[1]) +
"</span></div>"
);
})
.join("");
}
}
const msgEl = document.getElementById("opt-cond-order-msg");
if (msgEl) {
const msg = (order && order.message) || (d && d.cond_order_msg) || "";
msgEl.textContent = msg ? String(msg) : "";
}
card.hidden = false;
}
async function retrySpotSell() {
const btn = document.getElementById("opt-spot-sell-retry-btn");
const uly = (state.spotSellUnderlying || state.underlying || "ETH").toUpperCase();
@@ -2396,6 +2635,13 @@
const list = resolvePositionsList(d);
paintPositions(list);
paintSpotSellBanner(d, list);
paintCondOrderLock(d);
if (typeof window.OptionsFundsSetTransferBlocked === "function") {
window.OptionsFundsSetTransferBlocked(
!!(d && d.transfer_blocked),
(d && d.transfer_block_msg) || "持仓或待卖回期间不可划转"
);
}
const fromPos = list.reduce(function (targets, p) {
if (!p) return targets;
if (p.target_index != null) {
@@ -2741,6 +2987,8 @@
btn.classList.add("active");
state.underlying = btn.getAttribute("data-uly");
loadChain();
// 切换 ETH/BTC 时重刷持仓区,以便「重试卖回」标的与当前选择同步
refreshAllPositions();
});
});
@@ -88,6 +88,38 @@
return "";
}
function isCoinPos(p) {
const mode = String((p && p.margin_mode) || "").toLowerCase();
if (mode === "coin") return true;
if (mode === "usdc") return false;
const ccy = String((p && p.premium_ccy) || "").toUpperCase();
if (ccy === "USDC") return false;
if (ccy === "BTC" || ccy === "ETH") return true;
const inst = String((p && p.inst_id) || "");
return inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0;
}
function calcPosLeverage(p) {
const direct = Number(p && (p.contract_leverage != null ? p.contract_leverage : p.leverage));
if (Number.isFinite(direct) && direct > 0) return direct;
const idx = Number(p && p.idx_px);
const amt = Number(p && p.eth_amount);
const prem = Number(p && p.premium_paid);
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
return null;
}
if (isCoinPos(p)) {
return Math.round((amt / prem) * 10) / 10;
}
return Math.round((idx * amt) / prem * 10) / 10;
}
function fmtPosLeverage(p) {
const lev = calcPosLeverage(p);
if (lev == null || Number.isNaN(Number(lev))) return "—";
return "约 " + Number(lev).toFixed(1) + "×";
}
function fmtPxSz(px, sz, tickSz) {
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
let price = fmtOptionPx(px, tickSz);
@@ -224,6 +256,7 @@
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
'<span class="pos-meta-item" title="名义价值÷权利金,测算用">杠杆倍数: ' + fmtPosLeverage(p) + "</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
: "") +
+347 -40
View File
@@ -14,7 +14,7 @@
option_spot: {
strategy: ["顺势", "反转"],
direction: ["多", "空"],
entry: ["假突破", "结构突破"],
entry: ["假突破", "结构突破", "左侧条件单"],
},
hedge: {
strategy: ["横盘", "趋势"],
@@ -32,11 +32,68 @@
var tradesPages = 1;
var reviewedPage = 0;
var reviewedPages = 1;
var tradesListFp = "";
var reviewedListFp = "";
var statsFp = "";
function $(id) {
return document.getElementById(id);
}
function listFingerprint(kind, data, rows) {
var parts = [
kind,
activeSource,
String((data && data.total) || 0),
String((data && data.page) || 0),
String((data && data.pages) || 0),
];
(rows || []).forEach(function (t) {
var e = t.entry || {};
parts.push(
[
t.id,
t.realized_pnl_total,
t.profit_rate,
t.initial_premium_usdt,
t.option_fee_usdt,
t.status_raw,
t.reviewed ? 1 : 0,
t.opened_at,
t.closed_at,
t.hold_seconds,
t.reviewed_at,
t.strategy_tag || e.strategy_tag,
t.result_tag || e.result_tag,
t.direction_view || e.direction_view,
t.entry_logic || e.entry_logic,
t.source_label || t.source_type,
t.inst_id,
t.underlying,
t.direction,
t.plan_close_reason,
].join("\u001f")
);
});
return parts.join("\u001e");
}
function statsFingerprint(data) {
try {
return JSON.stringify({
kpi: data.kpi || {},
by_source_type: data.by_source_type || [],
by_underlying: data.by_underlying || [],
by_strategy: data.by_strategy || [],
by_close_reason: data.by_close_reason || [],
by_hold_bucket: data.by_hold_bucket || [],
by_opt_type: data.by_opt_type || [],
});
} catch (_) {
return String(Date.now());
}
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
@@ -52,6 +109,37 @@
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function fmtPremiumU(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
return n.toFixed(2) + "U";
}
function fmtOptionFee(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
return n.toFixed(2) + "U";
}
function fmtProfitRate(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
// 存小数倍率(1.5=1.5倍);展示为百分比
return (n >= 0 ? "+" : "") + (n * 100).toFixed(1) + "%";
}
function resolveProfitRate(t) {
if (!t) return null;
if (t.profit_rate != null && t.profit_rate !== "") return Number(t.profit_rate);
var pnl = Number(t.realized_pnl_total);
var prem = Number(t.initial_premium_usdt);
if (!Number.isFinite(pnl) || !Number.isFinite(prem) || Math.abs(prem) < 1e-12) return null;
return pnl / prem;
}
function fmtHold(sec) {
if (sec == null) return "—";
var s = Math.max(0, Number(sec) || 0);
@@ -168,10 +256,33 @@
}
function reloadAll() {
setSyncStatus("读取本地记录…");
loadTrades({ sync: true });
loadReviewed({ sync: false });
loadStats();
// 软刷新:保留现有表格/KPI,数据变了才换 DOM,避免整页闪动
setSyncStatus("刷新中…");
loadTrades({ sync: false, soft: true });
loadReviewed({ sync: false, soft: true });
loadStats({ soft: true });
backgroundSync();
}
function backgroundSync() {
setSyncStatus("同步本地记录…");
fetch("/api/options/review/sync", { credentials: "same-origin", method: "POST" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (data && data.ok === false) {
setSyncStatus("同步跳过,已显示本地缓存");
return;
}
setSyncStatus("本地记录已加载");
loadTrades({ sync: false, soft: true });
loadReviewed({ sync: false, soft: true });
loadStats({ soft: true });
})
.catch(function () {
setSyncStatus("同步跳过,已显示本地缓存");
});
}
function isHedgeSource(sourceType) {
@@ -325,12 +436,8 @@
function beginListLoad(wrapId, soft) {
var wrap = $(wrapId);
if (!wrap) return null;
if (soft) {
if (!wrap.style.minHeight) {
wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px";
}
wrap.classList.add("or-list-loading");
} else {
// soft 刷新不打半透明:无变化时也不应闪;有变化时静默换表
if (!soft) {
wrap.classList.remove("or-list-loading");
wrap.style.minHeight = "";
}
@@ -351,7 +458,8 @@
if (!tbody) return;
var wrap = beginListLoad("or-trades-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载中…</td></tr>';
tradesListFp = "";
tbody.innerHTML = '<tr><td colspan="10" class="muted">加载中…</td></tr>';
}
var p = baseQs();
// 交易记录保留已复盘条目,不再只显示待复盘
@@ -365,7 +473,8 @@
.then(function (data) {
if (doSync) setSyncStatus("本地记录已加载");
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
tradesListFp = "";
tbody.innerHTML = '<tr><td colspan="10" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
@@ -374,10 +483,16 @@
return;
}
var rows = data.trades || [];
var fp = listFingerprint("trades", data, rows);
if (soft && fp === tradesListFp) {
endListLoad(wrap);
return;
}
tradesListFp = fp;
tradesCache = {};
if (!rows.length) {
tbody.innerHTML =
'<tr><td colspan="7" class="muted">暂无交易记录</td></tr>';
'<tr><td colspan="10" class="muted">暂无交易记录</td></tr>';
endListLoad(wrap);
return;
}
@@ -386,6 +501,7 @@
tradesCache[t.id] = t;
var active = currentTradeId === t.id ? " or-row-active" : "";
var reviewed = !!t.reviewed;
var pr = resolveProfitRate(t);
var actionBtn = reviewed
? '<button type="button" class="btn or-review-btn" data-id="' +
t.id +
@@ -396,6 +512,17 @@
var badgeExtra = reviewed
? ' <span class="or-badge" style="background:rgba(61,214,140,.2)">已复盘</span>'
: "";
var stRaw = String(t.status_raw || "");
var needsMark = stRaw === "缺价未核";
var pendingSell = stRaw === "待卖回";
if (needsMark) {
badgeExtra +=
' <span class="or-badge" style="background:rgba(255,170,60,.28)" title="缺开仓价或卖回价,未能按新公式更新期权盈亏">缺价未核</span>';
}
if (pendingSell) {
badgeExtra +=
' <span class="or-badge" style="background:rgba(120,180,255,.28)" title="平仓卖回未完成(可能多笔),完成后按卖回VWAP结算期权盈亏">待卖回</span>';
}
return (
'<tr class="or-trade-row' +
active +
@@ -410,10 +537,25 @@
"<td>" +
escapeHtml(tradeTitle(t)) +
"</td>" +
"<td>" +
fmtPremiumU(t.initial_premium_usdt) +
"</td>" +
'<td class="' +
pnlClass(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
(needsMark || pendingSell
? '<span class="muted" title="' +
(pendingSell ? "卖回未完成" : "未能按新公式更新") +
'">—</span>'
: fmtPnl(t.realized_pnl_total)) +
"</td>" +
'<td class="' +
pnlClass(pr) +
'">' +
fmtProfitRate(pr) +
"</td>" +
"<td>" +
fmtOptionFee(t.option_fee_usdt) +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
@@ -451,7 +593,8 @@
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
if (doSync) setSyncStatus("加载失败");
tbody.innerHTML = '<tr><td colspan="10" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
@@ -464,7 +607,8 @@
if (!tbody) return;
var wrap = beginListLoad("or-reviewed-wrap", soft);
if (!soft) {
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载中…</td></tr>';
reviewedListFp = "";
tbody.innerHTML = '<tr><td colspan="13" class="muted">加载中…</td></tr>';
}
var p = baseQs();
p.set("reviewed", "1");
@@ -477,7 +621,8 @@
})
.then(function (data) {
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>';
reviewedListFp = "";
tbody.innerHTML = '<tr><td colspan="13" class="muted">加载失败</td></tr>';
endListLoad(wrap);
return;
}
@@ -486,9 +631,15 @@
return;
}
var rows = data.trades || [];
var fp = listFingerprint("reviewed", data, rows);
if (soft && fp === reviewedListFp) {
endListLoad(wrap);
return;
}
reviewedListFp = fp;
reviewedCache = {};
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="11" class="muted">暂无复盘记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="13" class="muted">暂无复盘记录</td></tr>';
endListLoad(wrap);
return;
}
@@ -498,23 +649,36 @@
var entry = t.entry || {};
var direction = t.direction_view || entry.direction_view || "";
var entryLogic = t.entry_logic || entry.entry_logic || "";
var needsMarkR = String(t.status_raw || "") === "缺价未核";
return (
'<tr class="or-reviewed-row" data-id="' +
t.id +
'">' +
"<td><span class=\"or-badge\">" +
escapeHtml(t.source_label || t.source_type) +
"</span></td>" +
"</span>" +
(needsMarkR
? ' <span class="or-badge" style="background:rgba(255,170,60,.28)" title="缺开仓价或卖回价,未能按新公式更新期权盈亏">缺价未核</span>'
: "") +
"</td>" +
"<td>" +
escapeHtml(tradeContractLabel(t)) +
"</td>" +
"<td>" +
escapeHtml(direction || "—") +
"</td>" +
"<td>" +
fmtPremiumU(t.initial_premium_usdt) +
"</td>" +
'<td class="' +
pnlClass(t.realized_pnl_total) +
'">' +
fmtPnl(t.realized_pnl_total) +
(needsMarkR ? '<span class="muted" title="未能按新公式更新">—</span>' : fmtPnl(t.realized_pnl_total)) +
"</td>" +
'<td class="' +
pnlClass(resolveProfitRate(t)) +
'">' +
fmtProfitRate(resolveProfitRate(t)) +
"</td>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") +
@@ -551,7 +715,7 @@
endListLoad(wrap);
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="13" class="muted">加载失败</td></tr>';
endListLoad(wrap);
});
}
@@ -582,31 +746,103 @@
}
}
var detailChartPollTimer = null;
var detailChartPollTradeId = 0;
function hideDetail() {
hideLightbox();
stopDetailChartPoll();
var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true;
}
function openDetail(tradeId) {
function stopDetailChartPoll() {
if (detailChartPollTimer) {
clearTimeout(detailChartPollTimer);
detailChartPollTimer = null;
}
detailChartPollTradeId = 0;
}
function tradeHasAutoChart(t) {
var images = ((t && t.entry) || {}).images || [];
for (var i = 0; i < images.length; i++) {
var tf = String((images[i] && images[i].tf) || "").trim();
var file = String((images[i] && images[i].file) || "").trim();
if (tf === "auto" && file) return true;
}
return false;
}
function openDetail(tradeId, opts) {
opts = opts || {};
var waitAuto = !!opts.waitAutoChart;
var backdrop = $("or-detail-backdrop");
if (!backdrop) return;
stopDetailChartPoll();
backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = waitAuto
? '<div class="or-detail-img-cell"><span class="or-detail-img-label">自动K线</span><div class="or-detail-img-miss">生成中…</div></div>'
: "";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) {
function applyTrade(trade, pendingAuto) {
renderDetail(trade, { pendingAuto: !!pendingAuto });
}
function fetchOnce() {
return fetch("/api/options/review/trades/" + tradeId, {
credentials: "same-origin",
}).then(function (r) {
return r.json();
})
});
}
fetchOnce()
.then(function (data) {
if (!data.ok || !data.trade) {
($("or-detail-title") || {}).textContent = "加载失败";
return;
}
renderDetail(data.trade);
if (!waitAuto || tradeHasAutoChart(data.trade)) {
applyTrade(data.trade, false);
return;
}
applyTrade(data.trade, true);
detailChartPollTradeId = tradeId;
var tries = 0;
var maxTries = 20;
function poll() {
if (detailChartPollTradeId !== tradeId) return;
tries += 1;
fetchOnce()
.then(function (again) {
if (detailChartPollTradeId !== tradeId) return;
if (again.ok && again.trade && tradeHasAutoChart(again.trade)) {
stopDetailChartPoll();
applyTrade(again.trade, false);
loadReviewed({ sync: false, soft: true });
return;
}
if (tries >= maxTries) {
stopDetailChartPoll();
applyTrade((again && again.trade) || data.trade, false);
return;
}
detailChartPollTimer = setTimeout(poll, 1500);
})
.catch(function () {
if (detailChartPollTradeId !== tradeId) return;
if (tries >= maxTries) {
stopDetailChartPoll();
return;
}
detailChartPollTimer = setTimeout(poll, 1500);
});
}
detailChartPollTimer = setTimeout(poll, 1200);
})
.catch(function () {
($("or-detail-title") || {}).textContent = "加载失败";
@@ -626,9 +862,30 @@
return base + encodeURIComponent(name);
}
function renderDetailImages(images) {
function chartTfLabel(tf) {
if (tf === "auto") return "自动K线";
if (tf === "_") return "截图";
return tf;
}
function setChartOptionsVisible(visible) {
var box = $("or-chart-options");
var hint = $("or-chart-hint");
if (box) box.classList.toggle("hidden", !visible);
if (hint) hint.classList.toggle("hidden", !visible);
}
function resetChartOptions() {
var cb = $("or-exchange-chart");
// 期权复盘:默认勾选后台生成 K 线
if (cb) cb.checked = true;
}
function renderDetailImages(images, opts) {
var imagesHost = $("or-detail-images");
if (!imagesHost) return;
opts = opts || {};
var pendingAuto = !!opts.pendingAuto;
var byTf = {};
(images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
@@ -637,16 +894,29 @@
var key = tf || "_";
byTf[key] = file;
});
var order = ["5m", "15m", "1h", "4h"];
// 互斥:有自动K线只显示自动;否则只显示手动四周期
var hasAuto = !!byTf.auto;
if (pendingAuto && !hasAuto) {
imagesHost.innerHTML =
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">自动K线</span>' +
'<div class="or-detail-img-miss">生成中…</div>' +
"</div>";
return;
}
var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"];
var keys = order.slice();
if (!hasAuto) {
Object.keys(byTf).forEach(function (k) {
if (k === "auto") return;
if (keys.indexOf(k) < 0) keys.push(k);
});
}
var cells = keys
.map(function (tf) {
var file = byTf[tf];
if (!file) {
if (order.indexOf(tf) < 0) return "";
if (hasAuto || order.indexOf(tf) < 0) return "";
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
@@ -657,7 +927,7 @@
);
}
var src = optionsJournalImgSrc(file);
var label = escapeHtml(tf === "_" ? "截图" : tf);
var label = escapeHtml(chartTfLabel(tf));
return (
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' +
@@ -698,7 +968,7 @@
});
}
function renderDetail(t) {
function renderDetail(t, opts) {
var e = t.entry || {};
reviewedCache[t.id] = t;
($("or-detail-title") || {}).textContent =
@@ -708,10 +978,14 @@
var meta = $("or-detail-meta");
if (meta) {
var pnlLabel = t.is_hedge ? "盈亏" : "期权盈亏";
var cells = [
["标的", t.underlying || "—"],
["合约/计划", tradeTitle(t)],
["盈亏", fmtPnl(t.realized_pnl_total)],
["初始权利金", fmtPremiumU(t.initial_premium_usdt)],
[pnlLabel, fmtPnl(t.realized_pnl_total)],
["盈利率", fmtProfitRate(resolveProfitRate(t))],
["期权手续费", fmtOptionFee(t.option_fee_usdt)],
["持有", fmtHold(t.hold_seconds)],
["开仓时间", t.opened_at || "—"],
["平仓时间", t.closed_at || "—"],
@@ -721,6 +995,7 @@
["离场", e.exit_reason || "—"],
["按计划", e.followed_plan || "—"],
["入场逻辑", e.entry_logic || "—"],
["入场周期", e.prev_entry_tf || "—"],
];
if (t.is_hedge) {
cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]);
@@ -732,7 +1007,7 @@
if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") {
cls = pnlClass(t.realized_pnl_total);
if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp);
if (pair[0] === "期权盈亏") cls = pnlClass(t.realized_pnl_options);
if (pair[0] === "期权盈亏" && t.is_hedge) cls = pnlClass(t.realized_pnl_options);
} else if (pair[0] === "结果") {
cls = resultClass(e.result_tag);
}
@@ -782,7 +1057,7 @@
var imagesHost = $("or-detail-images");
if (imagesHost) {
renderDetailImages(e.images || []);
renderDetailImages(e.images || [], opts || {});
}
}
@@ -818,16 +1093,25 @@
);
}
function loadStats() {
function loadStats(opts) {
opts = opts || {};
var soft = !!opts.soft;
var kpi = $("or-kpi");
var groups = $("or-stats-groups");
if (!kpi || !groups) return;
fetch("/api/options/review/stats?" + baseQs().toString(), { credentials: "same-origin" })
if (!soft) statsFp = "";
// 与 trades?sync=1 并行时不再二次同步,避免 SQLite database is locked
var qs = baseQs();
qs.set("sync", "0");
fetch("/api/options/review/stats?" + qs.toString(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) return;
var fp = statsFingerprint(data);
if (soft && fp === statsFp) return;
statsFp = fp;
var k = data.kpi || {};
kpi.innerHTML = [
["笔数", k.total],
@@ -989,9 +1273,12 @@
($("or-f-followed") || {}).value = "";
($("or-f-result") || {}).value = "";
($("or-f-entry") || {}).value = "";
($("or-f-prev-tf") || {}).value = "";
($("or-f-note") || {}).value = "";
setMoodTags("");
resetUploadSlots();
resetChartOptions();
setChartOptionsVisible(false);
var summary = $("or-journal-summary");
if (summary) {
summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h).";
@@ -1044,6 +1331,9 @@
: (t.source_label || "") +
(t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "");
($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total);
($("or-f-initial-prem") || {}).value = fmtPremiumU(t.initial_premium_usdt);
($("or-f-profit-rate") || {}).value = fmtProfitRate(resolveProfitRate(t));
($("or-f-option-fee") || {}).value = fmtOptionFee(t.option_fee_usdt);
($("or-f-hold") || {}).value = fmtHold(t.hold_seconds);
setSelectValue($("or-f-strategy"), e.strategy_tag || "");
setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t));
@@ -1051,8 +1341,11 @@
($("or-f-followed") || {}).value = e.followed_plan || "";
setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total));
setSelectValue($("or-f-entry"), e.entry_logic || "");
setSelectValue($("or-f-prev-tf"), e.prev_entry_tf || "");
($("or-f-note") || {}).value = e.note || "";
setMoodTags(e.mistake_tags);
resetChartOptions();
setChartOptionsVisible(t.source_type === "option_spot");
var summary = $("or-journal-summary");
if (summary) {
@@ -1068,6 +1361,8 @@
}
(e.images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
if (tf === "auto") return;
var hidden = document.querySelector(
'#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]'
);
@@ -1164,9 +1459,19 @@
result_tag: ($("or-f-result") || {}).value || "",
mistake_tags: collectMoodTags(),
entry_logic: ($("or-f-entry") || {}).value || "",
prev_entry_tf: ($("or-f-prev-tf") || {}).value || "",
note: ($("or-f-note") || {}).value || "",
images: collectImages(),
};
var chartBox = $("or-chart-options");
var chartVisible = chartBox && !chartBox.classList.contains("hidden");
if (chartVisible && ($("or-exchange-chart") || {}).checked) {
payload.journal_exchange_chart = true;
payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || "";
payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || "";
payload.journal_chart_limit = ($("or-chart-limit") || {}).value || "";
payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close";
}
($("or-save-status") || {}).textContent = "保存中…";
fetch("/api/options/review/entry", {
method: "POST",
@@ -1182,10 +1487,12 @@
($("or-save-status") || {}).textContent = data.msg || "保存失败";
return;
}
($("or-save-status") || {}).textContent = "已保存";
($("or-save-status") || {}).textContent = data.chart_queued
? "已保存,K线图后台生成中"
: "已保存";
hideJournalForm();
reloadAll();
openDetail(tradeId);
openDetail(tradeId, { waitAutoChart: !!data.chart_queued });
})
.catch(function () {
($("or-save-status") || {}).textContent = "保存失败";
+28
View File
@@ -353,4 +353,32 @@
intMsg: "opt-page-int-msg",
});
}
/** 持仓/待卖回时划转按钮置灰(规格). */
window.OptionsFundsSetTransferBlocked = function (blocked, msg) {
const idsList = [
["opt-page-int-btn", "opt-page-int-all-btn", "opt-page-int-amount", "opt-page-int-msg"],
["opt-set-int-btn", "opt-set-int-all-btn", "opt-set-int-amount", "opt-set-int-msg"],
];
idsList.forEach(function (ids) {
const btn = document.getElementById(ids[0]);
const allBtn = document.getElementById(ids[1]);
const amount = document.getElementById(ids[2]);
const msgEl = document.getElementById(ids[3]);
[btn, allBtn].forEach(function (el) {
if (!el) return;
el.disabled = !!blocked;
el.style.opacity = blocked ? "0.45" : "";
el.title = blocked ? String(msg || "持仓中不可划转") : "";
});
if (amount) amount.disabled = !!blocked;
if (blocked && msgEl && msg) {
msgEl.textContent = String(msg);
msgEl.classList.add("text-warn");
} else if (!blocked && msgEl && /持仓|划转|卖回/.test(String(msgEl.textContent || ""))) {
msgEl.textContent = "";
msgEl.classList.remove("text-warn");
}
});
};
})();
+64 -5
View File
@@ -1,5 +1,6 @@
/**
* 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults).
* BTC/ETH 在 order_defaults 无价时回退 /api/nav_spot_tickers(与顶栏同源).
*/
(function (global) {
"use strict";
@@ -65,6 +66,41 @@
el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)";
}
function symbolBase(sym) {
return String(sym || "")
.trim()
.toUpperCase()
.split("/")[0]
.split(":")[0];
}
function fetchNavLast(sym) {
const base = symbolBase(sym);
if (base !== "BTC" && base !== "ETH") {
return Promise.resolve(null);
}
return fetch("/api/nav_spot_tickers")
.then(function (r) {
return r.json().catch(function () {
return null;
});
})
.then(function (d) {
if (!d || !d.ok || !Array.isArray(d.tickers)) return null;
for (let i = 0; i < d.tickers.length; i++) {
const t = d.tickers[i] || {};
if (String(t.base || "").toUpperCase() === base && t.last != null) {
const n = Number(t.last);
return Number.isFinite(n) && n > 0 ? n : null;
}
}
return null;
})
.catch(function () {
return null;
});
}
function bindOne(el) {
if (!el || bound.has(el)) return;
bound.add(el);
@@ -87,6 +123,15 @@
pollTimer = setInterval(refresh, pollMs());
}
function applyPrice(sym, px) {
if (px === null || typeof px === "undefined") {
paint(el, sym, null, "无法读取交易所价格");
return;
}
paint(el, sym, px, "");
if (!pollTimer) startPoll();
}
function refresh() {
const symEl = $(symId);
const sym = symbolValue(symEl);
@@ -116,22 +161,36 @@
el.classList.remove("symbol-live-price--loading");
const data = res.data || {};
if (res.status >= 400 || !data || !data.ok) {
paint(el, sym, null, (data && data.msg) || "读取失败");
return fetchNavLast(sym).then(function (navPx) {
if (seq !== fetchSeq) return;
if (navPx != null) {
applyPrice(sym, navPx);
return;
}
paint(el, sym, null, (data && data.msg) || "读取失败");
});
}
const px = data.last_price != null ? data.last_price : data.price;
if (px === null || typeof px === "undefined") {
paint(el, data.symbol || sym, null, "无法读取交易所价格");
return;
return fetchNavLast(sym).then(function (navPx) {
if (seq !== fetchSeq) return;
applyPrice(data.symbol || sym, navPx);
});
}
paint(el, data.symbol || sym, px, "");
if (!pollTimer) startPoll();
applyPrice(data.symbol || sym, px);
})
.catch(function () {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
fetchNavLast(sym).then(function (navPx) {
if (seq !== fetchSeq) return;
if (navPx != null) {
applyPrice(sym, navPx);
return;
}
paint(el, sym, null, "网络错误");
});
});
}
function schedule() {
+2 -40
View File
@@ -88,18 +88,13 @@ HOT_RELOAD_EXACT = frozenset({
"APP_AUTH_DISABLED",
"APP_SESSION_DAYS",
"WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_SHOW_PERP_FUNDS",
"HUB_BRIDGE_TOKEN",
"HUB_EMBED_FRAME_ANCESTORS",
"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",
@@ -107,33 +102,15 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_OPTIONS_CLOSE_GATE_MODE",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPTION_PRIMARY",
"HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"HEDGE_PLAN_OO_BIAS_RATIO",
"HEDGE_PLAN_BUDGET_BUFFER",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
})
SENSITIVE_EXACT = frozenset({
"APP_PASSWORD",
"FLASK_SECRET_KEY",
"HUB_BRIDGE_TOKEN",
"OPENAI_API_KEY",
})
@@ -164,26 +141,13 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("spot", "spot 现货"),
),
"TRANSFER_CCY": (("USDT", "USDT"),),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
"OKX_TRADE_MODE": (
("options", "单独期权"),
("options_options", "期期对冲"),
),
"OKX_OPTIONS_MARGIN_MODE": (
("coin", "币本位(USDT买币桥)"),
("usdc", "USDC(USDⓈ权利金)"),
),
"OKX_OPTIONS_CLOSE_GATE_MODE": (
("premium", "权利金倍数"),
("net_pnl", "净盈亏(USDT)"),
),
"HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
("false", "保险模式"),
),
}
_SELECT_ALIASES: dict[str, dict[str, str]] = {
@@ -248,8 +212,6 @@ def _field_type(key: str, value: str) -> str:
return "bool"
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"
try:
+74 -289
View File
@@ -20,15 +20,15 @@ from lib.env.env_schema import (
# 合约计仓/杠杆/自动单等已从 env UI 移除;永续划转等见系统设置开关(默认关)
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权/对冲共用)"),
("OKX_API_KEY", "API Key", "账户 API(永续腿+期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(永续腿+期权共用)"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权共用)"),
("OKX_API_KEY", "API Key", "账户 API(期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
(
"OKX_SHOW_PERP_FUNDS",
"显示永续资金",
"默认关闭;开启后顶栏显示 USDT 资金/交易账户。币本位期权仍会显示交易账户以便买币",
"显示USDT资金/交易账户",
"默认关闭;开启后顶栏显示 USDT 资金与交易账户。币本位期权买币仍会显示交易账户可用额",
),
],
"binance": [
@@ -49,7 +49,7 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
],
}
# 仅保留与期权/对冲产品相关的共享区块(永续执行/风控/冷静期/划转/当日资金已从 UI 移除)
# 共享区块(企业微信等);对冲配置已从 UI 移除
_SHARED_SECTIONS: list[dict[str, Any]] = [
{
"title": "企业微信",
@@ -58,83 +58,66 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
],
},
]
_MODE_SECTION: dict[str, Any] = {
"title": "期权/对冲模式",
"exchanges": frozenset({"okx"}),
{
"title": "中控对接",
"fields": [
(
"OKX_TRADE_MODE",
"交易模式",
"二选一:单独期权 / 期期对冲。选单独期权时隐藏对冲导航与对冲配置;选期期对冲时不可单独开期权。永期对冲(含合约腿)已从配置页移除",
"HUB_BRIDGE_TOKEN",
"中控 Bridge Token",
"首次打开本页自动生成;复制到 crypto_okx_hub「添加实例」即可;也可手动改后保存",
),
(
"HUB_EMBED_FRAME_ANCESTORS",
"允许嵌入的中控 Origin",
"选填。一般可留空;仅当中控 iframe 打不开时再填中控地址(空格分隔),或调试填 *",
),
],
}
},
]
_OPTIONS_SECTION: dict[str, Any] = {
"title": "期权账户",
"exchanges": frozenset({"okx"}),
"fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ENABLED", "启用期权模块", "与上方 OKX_API_* 同源;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
(
"OKX_OPTIONS_MARGIN_MODE",
"单笔期权本位",
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "默认 1;币本位复利/固定预算共用"),
(
"OKX_OPTIONS_COIN_COMPOUND",
"币本位按交易户USDT复利",
"按交易户USDT复利",
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
),
(
"OKX_OPTIONS_COIN_BUDGET_USDT",
"币本位固定预算(USDT)",
"仅币本位且复利关闭时生效",
"固定预算(USDT)",
"仅复利关闭时生效",
),
(
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"币本位单笔上限开关",
"单笔上限开关",
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
),
(
"OKX_OPTIONS_COIN_MAX_USDT",
"币本位单笔上限(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",
"现货买入缓冲",
"相对权利金倍数,默认 1.05(=多买5%);也可写 0.05 表示+5%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)",
"仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
"默认 1=一次一仓;0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
(
"OKX_OPTIONS_UNDERLYINGS",
"标的白名单",
"逗号分隔,仅 ETH/BTC;只配一个则交易页只显示该标的(开仓/链/报价/条件单校验);资金页不受影响;默认 ETH,BTC",
),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH;须在白名单内,否则取白名单第一项"),
(
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"期权链展示天数",
@@ -143,7 +126,7 @@ _OPTIONS_SECTION: dict[str, Any] = {
(
"OKX_OPTIONS_MAX_DTE_DAYS",
"开仓最大剩余天数",
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
"默认 2;开仓时拒绝更远到期(与链展示天数独立)",
),
(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
@@ -153,17 +136,12 @@ _OPTIONS_SECTION: dict[str, Any] = {
(
"OKX_OPTIONS_CLOSE_GATE_MODE",
"目标平仓门控模式",
"premium=可回收≥权利金×倍数(币本位默认×1.05);net_pnl=净盈亏折USDT≥阈值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"门控权利金倍数(USDC)",
"仅 premium 模式;USDC 权利金仓默认 2",
"premium=可回收≥权利金×倍数(默认×1.05);net_pnl=净盈亏折USDT≥阈值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"门控权利金倍数(币本位)",
"仅 premium 模式;ETH/BTC 权利金仓默认 1.05",
"门控权利金倍数",
"仅 premium 模式;默认 1.05",
),
(
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
@@ -173,98 +151,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
(
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"门控持续秒数",
"条件达标后需持续满足的秒数,默认 120",
"条件达标后需持续满足的秒数,默认 20",
),
],
}
# 对冲公共字段(不含已由 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] = {
"title": "对冲计划",
"exchanges": frozenset({"okx"}),
"fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
*_HEDGE_COMMON_FIELDS,
*_HEDGE_PO_FIELDS,
*_HEDGE_OO_FIELDS,
],
}
# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_CONTROL_ENABLED": "true",
@@ -276,46 +167,31 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"AUTO_TRANSFER_FROM": "funding",
"AUTO_TRANSFER_TO": "swap",
"TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "false",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false",
"OKX_SHOW_PERP_FUNDS": "false",
"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_OPTIONS_MARGIN_MODE": "coin",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "1",
"OKX_OPTIONS_BUDGET_BUFFER": "1",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER": "1.05",
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT": "2",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT": "0",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "20",
"OKX_TRADE_MODE": "options",
"HEDGE_PLAN_ENABLED": "false",
"MAX_ACTIVE_HEDGE_PLANS": "1",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
"HEDGE_PLAN_OPTION_PRIMARY": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
}
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
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
pass
return "options"
if key == "OKX_OPTIONS_MARGIN_MODE":
return "coin"
if key in file_values:
file_val = str(file_values.get(key) or "").strip()
if file_val:
@@ -350,13 +226,6 @@ def _build_field(
meta = schema.get(key) or {}
schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default)
# 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
)
if _env_truthy(manual):
val = "false"
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key)
@@ -381,86 +250,34 @@ def _build_field(
if options:
cur = (out["current"] or out["default"] or "").strip()
opt_vals = {o["value"] for o in options}
# 永期对冲不再作为可选模式;已选历史值也不塞回下拉,避免继续强化合约 UI
if key == "OKX_TRADE_MODE" and cur == "perp_options":
cur = ""
out["current"] = "options"
out["default"] = "options"
out["note"] = (
(out.get("note") or "")
+ "(当前 .env 仍为永期对冲:请改选「单独期权」或「期期对冲」后保存并重启)"
).strip()
if cur and cur not in opt_vals:
options = [{"value": cur, "label": cur}] + options
out["options"] = options
return out
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]]:
del mode # 对冲模式已移除;保留参数兼容旧调用
ex = (exchange_key or "").strip().lower()
sections: list[dict[str, Any]] = []
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
sections.append({"title": "交易所与实盘", "fields": live_fields})
sections.extend(_SHARED_SECTIONS)
if ex in _MODE_SECTION.get("exchanges", frozenset()):
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
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)})
# 永期对冲配置块不再展示(含合约腿);期期仍展示
if m != "perp_options":
hedge_fields = _hedge_fields_for_mode(m)
if hedge_fields:
title = "对冲计划·期期"
sections.append({"title": title, "fields": hedge_fields})
if ex == "okx":
sections.append({"title": "期权账户", "fields": list(_OPTIONS_SECTION["fields"])})
return sections
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
"""可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
"""可写键=当前可见字段。.env 里遗留对冲键仍保留在文件中,但 UI 不可再写入."""
keys: set[str] = set()
for sec in ui_sections_for_exchange(exchange_key):
for item in sec["fields"]:
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)
@@ -469,13 +286,18 @@ def build_env_ui_payload(
example_path: str,
env_path: str,
) -> list[dict[str, Any]]:
# 首次打开 env 配置时若 Token 为空则自动生成,便于复制到中控
try:
from lib.hub.hub_bridge_auth import ensure_hub_bridge_token
ensure_hub_bridge_token(env_path)
except Exception:
pass
schema = _schema_field_map(example_path)
env_lines = read_env_lines(env_path)
values = env_get_all(env_lines)
groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange(
exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
):
for sec in ui_sections_for_exchange(exchange_key):
fields = [
_build_field(key, label, note, schema, values)
for key, label, note in sec["fields"]
@@ -489,24 +311,7 @@ def build_env_ui_payload(
return groups
# 币本位专属 / USDC 专属(配置页互斥隐藏)
_COIN_ONLY_ENV_KEYS = frozenset({
"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_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
})
_USDC_ONLY_ENV_KEYS = frozenset({
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
})
# 币本位专用:按复利/上限/门控模式标记 hidden
def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") -> str:
for f in fields:
if f.get("key") == key:
@@ -515,37 +320,30 @@ def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") ->
def _mark_options_section_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按本位/门控模式/全仓复利标记 hidden(供 SSR/前端;切换开关仍可再显示)."""
margin = (_field_current(fields, "OKX_OPTIONS_MARGIN_MODE", "coin") or "coin").lower()
is_coin = margin != "usdc"
"""按门控模式/复利/单笔上限标记 hidden;USDC/永续相关项始终隐藏."""
gate_mode = (_field_current(fields, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") or "premium").lower()
compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"))
coin_compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_COMPOUND", "true"))
coin_max_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"))
compound_cap_on = _env_truthy(
_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false")
)
retired = {
"OKX_OPTIONS_MARGIN_MODE",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"OKX_SHOW_PERP_FUNDS",
}
out: list[dict[str, Any]] = []
for f in fields:
key = str(f.get("key") or "")
hide = False
if is_coin and key in _USDC_ONLY_ENV_KEYS:
hide = True
elif (not is_coin) and key in _COIN_ONLY_ENV_KEYS:
hide = True
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT" and gate_mode != "premium":
if key in retired:
hide = True
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium":
hide = True
elif key == "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT" and gate_mode != "net_pnl":
hide = True
elif key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
hide = True
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED" and not compound_on:
hide = True
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC" and not (compound_on and compound_cap_on):
hide = True
elif key == "OKX_OPTIONS_COIN_BUDGET_USDT" and coin_compound_on:
hide = True
elif key == "OKX_OPTIONS_COIN_MAX_USDT" and not coin_max_on:
@@ -609,19 +407,6 @@ def coerce_hedge_partial_close_with_manual(
*,
env_path: str = "",
) -> dict[str, str]:
"""手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
out = dict(clean or {})
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
if manual is None and env_path:
try:
from lib.env.env_file_lib import env_get_all, read_env_lines
file_vals = env_get_all(read_env_lines(env_path))
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
)
except Exception:
manual = "true"
if _env_truthy(str(manual or "")):
out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
return out
"""兼容旧调用名;对冲已移除,原样返回."""
del env_path
return dict(clean or {})
-56
View File
@@ -1,56 +0,0 @@
"""Gate.io 资金划转(crypto_monitor_gate 共用)."""
from __future__ import annotations
from typing import Any, Callable, Optional
INVALID_KEY_HINT = (
".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;"
"③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);"
"④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单."
)
def execute_transfer_usdt(
exchange,
amount: float,
from_account: str,
to_account: str,
*,
transfer_ccy: str = "USDT",
ensure_live_ready: Callable[[], tuple[bool, str]],
ensure_markets_loaded: Optional[Callable[[], None]] = None,
) -> tuple[bool, str, Any]:
if amount <= 0:
return False, "划转金额必须大于0", None
ccy = (transfer_ccy or "USDT").strip().upper() or "USDT"
ok_live, reason = ensure_live_ready()
if not ok_live:
return False, reason, None
if ensure_markets_loaded:
try:
ensure_markets_loaded()
except Exception:
pass
try:
resp = exchange.transfer(ccy, float(amount), from_account, to_account)
return True, "划转成功", resp
except Exception as e:
msg = str(e)
if "INVALID_KEY" in msg or "Invalid key" in msg:
msg += INVALID_KEY_HINT
return False, msg, None
def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int:
"""自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划."""
n = int(count_order_monitors(conn) or 0)
if n > 0:
return n
try:
row = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans "
"WHERE status='active' AND COALESCE(first_order_done, 0) != 0"
).fetchone()
return int(row[0] or 0) if row else 0
except Exception:
return n
+68 -11
View File
@@ -213,16 +213,10 @@ def option_history_row_key(
def _ms_to_iso(ms: Any) -> str | None:
val = _safe_float(ms)
if val is None or val <= 0:
return None
try:
from datetime import datetime, timezone
"""OKX ms → 业务时区墙钟(默认 Asia/Shanghai);勿用系统本地时区."""
from lib.options.options_time_lib import ms_to_options_app_str
dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone()
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError):
return None
return ms_to_options_app_str(ms)
def option_instrument_meta_cached(
@@ -716,6 +710,11 @@ def options_header_balance_pack(
margin_mode = normalize_options_margin_mode()
except Exception:
margin_mode = "usdc"
try:
from lib.options.options_underly_lib import resolve_default_underly
underly = resolve_default_underly()
except Exception:
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
coin_key = "btc" if underly == "BTC" else "eth"
return {
@@ -800,6 +799,37 @@ def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str,
return out
def fetch_option_summaries(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
"""GET /api/v5/public/opt-summary — 含 delta / deltaBS 等希腊字母.
市场 tickers 不含 Delta,期权链展示必须走本接口.
"""
out: dict[str, dict[str, Any]] = {}
try:
rows = ex.public_get_public_opt_summary({"instFamily": inst_family}).get("data") or []
for r in rows:
if isinstance(r, dict) and r.get("instId"):
out[str(r["instId"])] = r
except Exception:
pass
return out
def option_delta_from_summary(
summary: dict[str, Any] | None = None,
*,
ticker: dict[str, Any] | None = None,
) -> float | None:
"""优先 Black-Scholes 单位 Delta(deltaBS),其次 delta;兼容 ticker 偶发字段."""
for src in (summary, ticker):
if not isinstance(src, dict):
continue
for key in ("deltaBS", "delta", "deltaBs", "delta_bs"):
v = _safe_float(src.get(key))
if v is not None:
return v
return None
def build_option_chain(
ex: ccxt.okx,
underlying: str,
@@ -820,7 +850,7 @@ def build_option_chain(
family = inst_family_for_underlying(u, margin_mode=margin_mode)
except Exception:
family = f"{u}-USD_UM"
family = f"{u}-USD"
uly = f"{u}-USD"
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
now_ms = time.time() * 1000
@@ -848,6 +878,7 @@ def build_option_chain(
except Exception as e2:
instruments_err = str(e2) or e2.__class__.__name__
tickers = fetch_option_tickers(ex, family)
summaries = fetch_option_summaries(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0
for meta in instruments:
@@ -873,6 +904,7 @@ def build_option_chain(
continue
inst_id = str(meta.get("instId") or "")
t = tickers.get(inst_id) or {}
s = summaries.get(inst_id) or {}
q = _resolve_chain_quote(
ticker=t,
meta=meta,
@@ -885,6 +917,7 @@ def build_option_chain(
mark = q["mark_px"]
ask_sz = q["ask_sz"]
bid_sz = q["bid_sz"]
delta = option_delta_from_summary(s, ticker=t)
expiry_be = expiry_breakeven_from_ask(
opt_type=opt_type,
strike=strike,
@@ -905,6 +938,7 @@ def build_option_chain(
"ask_sz": ask_sz,
"bid_sz": bid_sz,
"mark_px": mark,
"delta": delta,
"ask_estimated": q["ask_estimated"],
"expiry_be_px": expiry_be,
"dist_expiry_be": strike_distance_to_be(strike, expiry_be),
@@ -1153,6 +1187,8 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
if avg is None or avg <= 0:
avg = fill_px
state = str(o.get("state") or "").strip().lower()
fee = _safe_float(o.get("fee"))
fee_ccy = str(o.get("feeCcy") or o.get("fee_ccy") or "").strip().upper()
return {
"ok": True,
"ord_id": str(o.get("ordId") or ord_id),
@@ -1163,6 +1199,9 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
"avg_px": avg,
"side": str(o.get("side") or "").lower(),
"ord_type": str(o.get("ordType") or ""),
# OKX fee 扣费多为负;此处保留原始值,结算侧取绝对值
"fee": fee,
"fee_ccy": fee_ccy,
"raw": o,
}
except Exception as e:
@@ -1508,6 +1547,7 @@ def format_option_history_row(
"premium_ccy": premium_ccy,
"margin_mode": row_mode,
"realized_pnl": realized,
"realized_pnl_usdt": _safe_float(raw.get("_sim_realized_pnl_usdt")),
"realized_pnl_fmt": format_premium_amount(realized, ccy=premium_ccy),
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
"status": "closed",
@@ -1601,6 +1641,9 @@ def resolve_option_close_from_history(
u_i, row = item
c_ms = _safe_float(row.get("cTime"))
parts: list[float] = []
# 全平/强平/ADL 优先于部分平仓(同合约多条时避免只吃到 type=1)
close_type = str(row.get("type") or "").strip()
parts.append(0.0 if close_type in ("2", "3", "6") else 1.0)
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
if want_sheets is not None:
hist_sheets = _safe_float(row.get("closeTotalPos"))
@@ -1615,7 +1658,7 @@ def resolve_option_close_from_history(
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:
if len(parts) <= 1:
parts.append(float(-u_i))
# 同距时偏向更晚平仓
parts.append(float(-u_i))
@@ -1809,6 +1852,7 @@ def format_position_row(
) -> dict[str, Any]:
from lib.options.options_pricing_lib import (
close_breakeven_idx,
equivalent_contract_leverage,
expiry_breakeven_px,
idx_distance_to_be,
strike_distance_to_be,
@@ -1834,6 +1878,9 @@ def format_position_row(
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
)
delta_pa = _safe_float(pos.get("deltaPA"))
delta_bs = _safe_float(pos.get("deltaBS"))
if delta_bs is None:
delta_bs = _safe_float(pos.get("delta"))
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
@@ -1894,6 +1941,16 @@ def format_position_row(
"close_be_px": close_be,
"dist_expiry_be": strike_distance_to_be(strike, expiry_be),
"dist_close_be": idx_distance_to_be(idx_px, close_be),
"contract_leverage": equivalent_contract_leverage(
index_px=idx_px,
eth_amount=eth_amount,
total_premium=premium_paid,
margin_mode=row_mode,
),
# 展示用单位 Delta(BS);delta_pa 为仓位美元敏感度
"delta": delta_bs,
"delta_bs": delta_bs,
"delta_pa": delta_pa,
"raw": pos,
}
-116
View File
@@ -1,116 +0,0 @@
"""
OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger).
交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到.
"""
from __future__ import annotations
from typing import Any
def _order_dedupe_key(order: dict) -> str:
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
return str(order.get("id") or info.get("algoId") or info.get("ordId") or "")
def _okx_algo_cancel_id(order_id: str) -> str:
oid = str(order_id or "")
if ":" in oid:
return oid.split(":", 1)[0]
return oid
def _okx_order_needs_stop_cancel_param(order: dict) -> bool:
"""OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败."""
if not isinstance(order, dict):
return False
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None:
return True
if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"):
return True
typ = str(order.get("type") or info.get("ordType") or "").lower()
for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"):
if token in typ:
return True
return False
def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]:
"""合并 OKX 普通挂单与算法挂单(去重)."""
if not exchange_symbol:
return []
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
seen: set[str] = set()
out: list[dict] = []
def add_batch(batch: list | None) -> None:
for o in batch or []:
if not isinstance(o, dict):
continue
k = _order_dedupe_key(o)
if not k or k in seen:
continue
seen.add(k)
out.append(o)
try:
add_batch(ex.fetch_open_orders(sym))
except Exception:
pass
for params in (
{"ordType": "conditional"},
{"ordType": "oco"},
{"trigger": True},
):
try:
add_batch(ex.fetch_open_orders(sym, params=dict(params)))
except Exception:
pass
return out
def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int:
"""
撤销某合约全部挂单(普通 + 条件/算法).
OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉.
"""
if not exchange_symbol:
return 0
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
n = 0
for o in fetch_okx_all_open_orders(ex, sym):
oid = _order_dedupe_key(o)
if not oid:
continue
cancel_id = _okx_algo_cancel_id(oid)
params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None
try:
ex.cancel_order(cancel_id, sym, params)
n += 1
continue
except Exception:
pass
try:
ex.cancel_order(oid, sym, params)
n += 1
except Exception:
pass
try:
ex.cancel_all_orders(sym)
except Exception:
pass
return n
-1
View File
@@ -1 +0,0 @@
# hedge_plan package
@@ -1,86 +0,0 @@
"""对冲计划与单独期权开仓互斥门控.
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
def _env_bool(key: str, default: bool = False) -> bool:
v = (os.getenv(key) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on")
def mutual_exclusive_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
"""若应拦截单独开期权,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
try:
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
init_hedge_plan_tables(conn)
if count_active_plans(conn) > 0:
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止单独开期权"
return None
def _pos_nonzero(raw: dict[str, Any]) -> bool:
try:
return abs(float(raw.get("pos") or 0)) > 1e-12
except (TypeError, ValueError):
return False
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
if not raw_positions:
return False
from lib.instance.instance_dashboard_lib import _resolve_options_source
for p in raw_positions:
if not isinstance(p, dict) or not _pos_nonzero(p):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
source, _, _ = _resolve_options_source(conn, inst)
if source == "option":
return True
return False
def block_hedge_plan_start_msg(
conn: Any,
*,
fetch_positions: Optional[Callable[[Any], Any]] = None,
exchange: Any = None,
raw_positions: list[dict[str, Any]] | None = None,
) -> Optional[str]:
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
rows = raw_positions
if rows is None:
if fetch_positions is None or exchange is None:
return None
try:
rows = fetch_positions(exchange) or []
except Exception:
return "获取期权持仓失败,暂禁止启动对冲计划"
try:
if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止启动对冲计划"
return None
-782
View File
@@ -1,782 +0,0 @@
"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO)."""
from __future__ import annotations
from typing import Any, Optional
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def perp_coin_amount(*, contracts: float, contract_size: float) -> float:
return float(contracts) * float(contract_size or 1.0)
def perp_pnl(
*,
direction: str,
entry: float,
exit_px: float,
contracts: float,
contract_size: float,
) -> float:
coins = perp_coin_amount(contracts=contracts, contract_size=contract_size)
d = (direction or "long").strip().lower()
if d == "short":
return (float(entry) - float(exit_px)) * coins
return (float(exit_px) - float(entry)) * coins
def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float:
"""卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult."""
return float(ask) * float(sheets) * float(ct_mult or 0.01)
def option_expiry_pnl(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
o = (opt_type or "").strip().upper()
intrinsic_per_coin = 0.0
if o in ("C", "CALL"):
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
elif o in ("P", "PUT"):
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
else:
return -float(premium_paid)
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
return value - float(premium_paid)
def 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(
*,
notional: float,
entry: float,
contract_size: float,
) -> float:
if entry <= 0 or contract_size <= 0 or notional <= 0:
return 0.0
return float(notional) / (float(entry) * float(contract_size))
def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
"""按交易所张数精度向下取整,避免建议张数超过可用保证金."""
import math
raw = float(contracts or 0.0)
if raw <= 0:
return 0.0
try:
d = int(decimals)
except (TypeError, ValueError):
d = 0
if d <= 0:
return float(math.floor(raw + 1e-12))
scale = 10**d
return math.floor(raw * scale + 1e-12) / scale
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
"""单张权利金(USDC) = 卖一价 × ct_mult."""
a = _f(ask)
if a is None or a <= 0:
return 0.0
return float(a) * float(ct_mult or 0.01)
def resolve_oo_budget_usdc(
*,
trading_usdc: Any,
trade_budget_usdc: Any,
buffer_ratio: Any = 0.95,
) -> dict[str, Any]:
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
import math
trading = _f(trading_usdc)
cap = _f(trade_budget_usdc)
buf = _f(buffer_ratio)
if buf is None or buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
trade_cap = None if cap is None else max(0.0, float(cap))
if trading_cap is None and trade_cap is None:
return {
"ok": False,
"budget_usdc": 0.0,
"trading_cap": None,
"trade_budget_cap": None,
"buffer_ratio": float(buf),
"msg": "缺少交易户余额与单笔预算",
}
if trading_cap is None:
budget = float(trade_cap or 0.0)
elif trade_cap is None:
budget = float(trading_cap)
else:
budget = min(float(trading_cap), float(trade_cap))
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
return {
"ok": budget > 0,
"budget_usdc": budget,
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
"buffer_ratio": float(buf),
"msg": "" if budget > 0 else "可用预算为 0",
}
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
import math
n = max(0, int(sheets))
depth = _f(ask_sz)
if depth is None:
return n
if depth <= 0:
return 0
return min(n, int(math.floor(float(depth) + 1e-12)))
def _normalize_oo_sheets_mode(mode: str) -> str:
m = (mode or "same_sheets").strip().lower()
if m in ("long_bias", "bias_long", "long", "做多"):
return "long_bias"
if m in ("short_bias", "bias_short", "short", "做空"):
return "short_bias"
# 旧「均分」兼容:按预算 50/50(页面已移除)
if m in ("split", "equal_budget", "split_budget", "均分"):
return "split_budget"
return "same_sheets"
def _normalize_oo_bias_split_by(raw: Any) -> str:
v = str(raw or "budget").strip().lower()
if v in ("sheets", "qty", "quantity", "张数"):
return "sheets"
return "budget"
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
try:
r = float(raw)
except (TypeError, ValueError):
r = float(default)
if r <= 0 or r >= 1:
r = float(default)
return r
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
a = (opt_type_a or "").strip().upper()
b = (opt_type_b or "").strip().upper()
if a.startswith("C"):
a = "C"
elif a.startswith("P"):
a = "P"
if b.startswith("C"):
b = "C"
elif b.startswith("P"):
b = "P"
if {a, b} != {"C", "P"}:
return None, None, "做多/做空需一腿 Call、一腿 Put"
call_side = "a" if a == "C" else "b"
put_side = "b" if call_side == "a" else "a"
return call_side, put_side, ""
def suggest_oo_sheets(
*,
mode: str,
budget_usdc: float,
ask_a: float,
ct_mult_a: float = 0.01,
ask_sz_a: Any = None,
opt_type_a: str = "",
ask_b: float,
ct_mult_b: float = 0.01,
ask_sz_b: Any = None,
opt_type_b: str = "",
bias_split_by: str = "budget",
bias_ratio: float = 0.7,
) -> dict[str, Any]:
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
import math
m = _normalize_oo_sheets_mode(mode)
split_by = _normalize_oo_bias_split_by(bias_split_by)
ratio = _clamp_oo_bias_ratio(bias_ratio)
budget = max(0.0, float(budget_usdc or 0.0))
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
"ok": False,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
if budget <= 0:
return _fail("可用预算为 0")
if cost_a <= 0 or cost_b <= 0:
return _fail("缺少有效卖一价,无法建议张数")
pair = cost_a + cost_b
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
n_same = min(
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
)
if m == "same_sheets":
n_a = n_same
n_b = n_same
elif m == "split_budget":
half = budget / 2.0
n_a = int(math.floor(half / cost_a + 1e-12))
n_b = int(math.floor(half / cost_b + 1e-12))
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
else:
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
if err:
return _fail(err)
major_is_call = m == "long_bias"
if split_by == "sheets":
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
total = int(n_same) * 2
if total < 2:
return _fail("同张数总规模不足 2,无法按比例拆分")
major_n = int(round(total * ratio))
major_n = max(1, min(major_n, total - 1))
minor_n = total - major_n
n_call = major_n if major_is_call else minor_n
n_put = minor_n if major_is_call else major_n
else:
maj_budget = budget * ratio
min_budget = budget * (1.0 - ratio)
cost_call = cost_a if call_side == "a" else cost_b
cost_put = cost_b if call_side == "a" else cost_a
if major_is_call:
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
else:
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_a = n_call if call_side == "a" else n_put
n_b = n_put if call_side == "a" else n_call
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
prem = cost_a * n_a + cost_b * n_b
ok = n_a >= 1 and n_b >= 1
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(prem, 6),
"ok": ok,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
def build_perp_options_preview(
*,
direction: str,
entry: float,
tp: float,
sl: float,
contracts: float,
contract_size: float,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
index_px: Optional[float] = None,
) -> dict[str, Any]:
"""
永期情景.
止盈账:永续止盈盈利 - 权利金.
止损账:期权到期内在(按 SL 价) - 永续止损亏损额.
"""
d = (direction or "long").strip().lower()
pnl_tp_perp = perp_pnl(
direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size
)
pnl_sl_perp = perp_pnl(
direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size
)
# 止盈统计口径
tp_total = float(pnl_tp_perp) - float(premium_paid)
# 止损:期权按 SL 价结算内在 - |永续亏损|
opt_at_sl = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=sl,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float(
pnl_sl_perp
)
# 有符号相加更稳:期权盈亏 + 永续盈亏
sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp)
spot = float(index_px) if index_px is not None else float(entry)
opt_flat = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=spot,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
flat_total = 0.0 + float(opt_flat)
opt_at_tp = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=tp,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
return {
"plan_type": "perp_options",
"direction": d,
"contracts": contracts,
"coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size),
"premium_paid": round(float(premium_paid), 6),
"scenarios": [
{
"id": "tp",
"label": "止盈(计划结束口径)",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(-float(premium_paid), 4),
"total": round(tp_total, 4),
"note": "止盈盈利 − 权利金;期权可不强平",
},
{
"id": "sl",
"label": "止损(计划结束口径)",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(sl_total_signed, 4),
"note": "期权盈利 − 永续亏损(有符号相加);期权须强平",
},
{
"id": "flat",
"label": "到期·现价附近",
"spot": spot,
"perp_pnl": 0.0,
"options_pnl": round(opt_flat, 4),
"total": round(flat_total, 4),
"note": "示意:永续未动,期权按到期内在",
},
{
"id": "expiry_tp",
"label": "到期·止盈价",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(opt_at_tp, 4),
"total": round(pnl_tp_perp + opt_at_tp, 4),
"note": "若期权拿到 TP 价到期(参考)",
},
{
"id": "expiry_sl",
"label": "到期·止损价",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(pnl_sl_perp + opt_at_sl, 4),
"note": "与止损口径相近(期权用内在)",
},
],
"summary": {
"tp_total": round(tp_total, 4),
"sl_total": round(sl_total_signed, 4),
"premium_paid": round(float(premium_paid), 4),
"hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp),
},
}
def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0
if loss <= 1e-12:
return None
if float(opt_pnl) <= 0:
return 0.0
return round(float(opt_pnl) / loss * 100.0, 2)
def build_options_options_preview(
*,
target_price: float | None = None,
target_price_up: float | None = None,
target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float,
leg_a: dict[str, Any],
leg_b: dict[str, Any],
) -> dict[str, Any]:
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
残值按亏损腿本合约权利金的 20% 计.
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl(
opt_type=str(leg.get("opt_type") or ""),
strike=float(leg["strike"]),
spot=spot,
sheets=float(leg.get("sheets") or 0),
ct_mult=float(leg.get("ct_mult") or 0.01),
premium_paid=float(leg.get("premium_paid") or 0),
)
prem_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 填两边
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
if up is None or down is None:
raise ValueError("缺少盈亏比或上破/下破目标价")
up_f = float(up)
down_f = float(down)
a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up
win_up = "a" if a_up >= b_up else "b"
a_dn = _leg_pnl(leg_a, down_f)
b_dn = _leg_pnl(leg_b, down_f)
at_dn = a_dn + b_dn
win_dn = "a" if a_dn >= b_dn else "b"
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
expiry_loss = flat_total if flat_total <= 0 else flat_total
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"target_price": up_f, # 兼容旧字段,取上破
"target_price_up": up_f,
"target_price_down": down_f,
"winner_at_up": win_up,
"winner_at_down": win_dn,
"winner_at_target": win_up,
"scenarios": [
{
"id": "target_up",
"label": "上破目标",
"spot": up_f,
"leg_a_pnl": round(a_up, 4),
"leg_b_pnl": round(b_up, 4),
"total": round(at_up, 4),
"note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期",
},
{
"id": "target_down",
"label": "下破目标",
"spot": down_f,
"leg_a_pnl": round(a_dn, 4),
"leg_b_pnl": round(b_dn, 4),
"total": round(at_dn, 4),
"note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期",
},
{
"id": "expiry_flat",
"label": "到期·现价(无突破)",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"at_target_up_total": round(at_up, 4),
"at_target_down_total": round(at_dn, 4),
"at_target_total": round(at_up, 4),
"expiry_flat_total": round(expiry_loss, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6),
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
},
}
def gate_status(
*,
hedge_enabled: bool,
sizing_mode: str,
plan_type: str,
options_enabled: bool,
live_order: bool = False,
live_trading: bool = False,
active_count: int = 0,
max_active: int = 1,
show_perp_options: bool = True,
show_options_options: bool = True,
mutual_exclusive: bool = True,
has_standalone_option: bool = False,
) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode
full = is_full_margin_mode(sizing_mode)
pt = (plan_type or "").strip().lower()
can_preview = True
can_start = True
reasons: list[str] = []
if not hedge_enabled:
can_start = False
reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)")
if not options_enabled:
can_preview = False
can_start = False
reasons.append("期权模块未启用")
if pt == "perp_options" and not show_perp_options:
can_preview = False
can_start = False
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
if pt == "options_options" and not show_options_options:
can_preview = False
can_start = False
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
if not live_order:
can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)):
can_start = False
reasons.append(f"活跃计划已达上限({max_active})")
if mutual_exclusive and has_standalone_option:
can_start = False
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
if pt == "perp_options":
if not full:
can_start = False
reasons.append("永期开仓仅全仓模式可用(当前可测算)")
if not live_trading:
can_start = False
reasons.append("未开启实盘(LIVE_TRADING_ENABLED)")
elif pt == "options_options":
pass
else:
can_start = False
reasons.append("未知计划类型")
if can_start:
reasons = []
return {
"hedge_enabled": hedge_enabled,
"options_enabled": options_enabled,
"sizing_mode": sizing_mode,
"is_full_margin": full,
"plan_type": pt,
"live_order": live_order,
"live_trading": live_trading,
"active_count": active_count,
"max_active": max_active,
"show_perp_options": bool(show_perp_options),
"show_options_options": bool(show_options_options),
"mutual_exclusive": bool(mutual_exclusive),
"has_standalone_option": bool(has_standalone_option),
"can_preview": can_preview,
"can_start": can_start,
"reasons": reasons,
}
-468
View File
@@ -1,468 +0,0 @@
"""对冲计划 SQLite 表."""
from __future__ import annotations
import sqlite3
from typing import Any, Optional
def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_type TEXT NOT NULL,
status TEXT NOT NULL,
underlying TEXT NOT NULL,
direction TEXT,
entry_mark REAL,
tp REAL,
sl REAL,
target_price REAL,
sizing_mode_at_open TEXT,
perp_size REAL,
margin REAL,
leverage REAL,
premium_total REAL,
realized_pnl_perp REAL,
realized_pnl_options REAL,
realized_pnl_total REAL,
stats_bucket TEXT,
close_reason TEXT,
wechat_start_sent INTEGER DEFAULT 0,
wechat_end_sent INTEGER DEFAULT 0,
note TEXT,
preview_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
opened_at TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plan_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL,
leg_role TEXT NOT NULL,
symbol TEXT,
inst_id TEXT,
opt_type TEXT,
strike REAL,
side TEXT,
size REAL,
avg_open REAL,
premium REAL,
status TEXT,
linked_monitor_id INTEGER,
options_trade_id INTEGER,
exchange_ord_id TEXT,
realized_pnl REAL,
close_reason TEXT,
opened_at TIMESTAMP,
closed_at TIMESTAMP,
FOREIGN KEY(plan_id) REFERENCES hedge_plans(id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)"
)
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# 期期出场:盈利金额/总权利金(默认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", "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:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
names: set[str] = set()
for r in rows:
try:
names.add(str(r["name"]))
except (TypeError, KeyError, IndexError):
names.add(str(r[1]))
if col not in names:
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:
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
if plan_type:
row = conn.execute(
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
(plan_type,),
).fetchone()
else:
row = conn.execute(
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
).fetchone()
return int((row["c"] if row else 0) or 0)
def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
for leg in legs or []:
if str(leg.get("status") or "").strip().lower() == "pending":
role = str(leg.get("leg_role") or "").strip()
if role:
return role
return None
def list_plans(
conn: sqlite3.Connection,
*,
status: Optional[str] = None,
plan_type: Optional[str] = None,
underlying: Optional[str] = None,
limit: int = 50,
) -> list[dict[str, Any]]:
wheres: list[str] = []
args: list[Any] = []
if status:
wheres.append("status=?")
args.append(status)
if plan_type:
wheres.append("plan_type=?")
args.append(plan_type)
if underlying:
wheres.append("underlying=?")
args.append(underlying)
where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
rows = conn.execute(
f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?",
[*args, int(limit)],
).fetchall()
return [dict(r) for r in rows]
def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]:
row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone()
return dict(row) if row else None
def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,)
).fetchall()
return [dict(r) for r in rows]
def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
"""删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除."""
plan = get_plan(conn, int(plan_id))
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
if st in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
return {"ok": True, "deleted_id": int(plan_id)}
def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
parts: list[str] = []
for leg in legs:
role = str(leg.get("leg_role") or "")
st = str(leg.get("status") or "").strip().lower()
if st == "pending":
suffix = "(待补)"
elif st in ("cancelled", "canceled"):
suffix = "(未成交)"
else:
suffix = ""
if role == "perp":
name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}{suffix}")
else:
inst = str(leg.get("inst_id") or "")
ot = str(leg.get("opt_type") or "").upper()
strike = leg.get("strike")
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
parts.append(f"{label}{suffix}")
return " · ".join(parts) if parts else "—"
def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for p in plans:
legs = get_plan_legs(conn, int(p["id"]))
row = dict(p)
row["legs"] = 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)
out.append(row)
return out
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
否则两套监控会同时尝试平掉同一条期权腿。
"""
rows = conn.execute(
"""
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
p.profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options'
AND p.status IN ('opening', 'active', 'partial')
AND l.status = 'open'
AND l.inst_id IS NOT NULL
AND l.inst_id != ''
ORDER BY p.id DESC, l.id DESC
"""
).fetchall()
out: dict[str, dict[str, Any]] = {}
for raw in rows:
row = dict(raw)
inst_id = str(row.get("inst_id") or "")
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_f = _sf(target)
if target_f is None or target_f <= 0:
continue
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"target_index": target_f,
"plan_type": "options_options",
"managed_by": "hedge_plan",
}
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]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]:
"""对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤."""
pnls: list[float] = []
timed: list[tuple[str, float]] = []
for r in rows:
pnl = _sf(r.get("realized_pnl_total"))
if pnl is None:
continue
pnls.append(pnl)
t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "")
timed.append((t, pnl))
n = len(pnls)
if n == 0:
return {
"count": 0,
"wins": 0,
"losses": 0,
"win_rate": None,
"net_pnl": 0.0,
"avg_pnl": None,
"avg_premium": None,
"profit_factor": None,
"max_profit": None,
"max_loss": None,
"max_drawdown": None,
}
wins = [x for x in pnls if x > 0]
losses = [x for x in pnls if x < 0]
gross_win = sum(wins)
gross_loss = abs(sum(losses))
if gross_loss > 0:
profit_factor = round(gross_win / gross_loss, 4)
elif gross_win > 0:
profit_factor = None # 全胜,标无限
else:
profit_factor = 0.0
timed.sort(key=lambda x: x[0] or "")
cum = 0.0
peak = 0.0
mdd = 0.0
for _, p in timed:
cum += p
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
premiums = [_sf(r.get("premium_total")) for r in rows]
premiums_f = [x for x in premiums if x is not None]
return {
"count": n,
"wins": len(wins),
"losses": len(losses),
"win_rate": round(len(wins) / n, 4),
"net_pnl": round(sum(pnls), 4),
"avg_pnl": round(sum(pnls) / n, 4),
"avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None,
"profit_factor": profit_factor,
"profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0),
"max_profit": round(max(pnls), 4),
"max_loss": round(min(pnls), 4),
"max_drawdown": round(mdd, 4),
}
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
reason_rows = conn.execute(
"""
SELECT plan_type, close_reason, COUNT(1) AS n,
COALESCE(SUM(realized_pnl_total), 0) AS pnl
FROM hedge_plans
WHERE status='closed'
GROUP BY plan_type, close_reason
"""
).fetchall()
closed_rows = [
dict(r)
for r in conn.execute(
"SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id"
).fetchall()
]
active = count_active_plans(conn)
overall = _metrics_from_pnls(closed_rows)
by_type = {
"perp_options": _metrics_from_pnls(
[r for r in closed_rows if r.get("plan_type") == "perp_options"]
),
"options_options": _metrics_from_pnls(
[r for r in closed_rows if r.get("plan_type") == "options_options"]
),
}
# 永期止盈/止损分桶
po = [r for r in closed_rows if r.get("plan_type") == "perp_options"]
by_type["perp_options"]["buckets"] = {
"tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]),
"sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]),
}
oo = [r for r in closed_rows if r.get("plan_type") == "options_options"]
by_type["options_options"]["buckets"] = {
"expiry_loss": _metrics_from_pnls(
[r for r in oo if r.get("close_reason") == "oo_expiry_loss"]
),
"expiry_win": _metrics_from_pnls(
[r for r in oo if r.get("close_reason") == "oo_expiry_win"]
),
}
return {
"active": active,
"closed_count": overall["count"],
"closed_pnl_total": overall["net_pnl"],
"overall": overall,
"by_type": by_type,
"by_reason": [dict(r) for r in reason_rows],
}
-285
View File
@@ -1,285 +0,0 @@
"""对冲计划虚实值选约与校验.
永期(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
-216
View File
@@ -1,216 +0,0 @@
"""对冲计划企业微信推送(起止必发,幂等落库标记)."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.hedge_plan.hedge_plan_db import update_plan
def _fmt(v: Any, d: int = 2) -> str:
try:
if v is None or v == "":
return "—"
return f"{float(v):.{d}f}"
except (TypeError, ValueError):
return str(v)
def _type_label(plan_type: str) -> str:
return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲"
def _dir_label(direction: str) -> str:
d = (direction or "").lower()
if d == "long":
return "做多"
if d == "short":
return "做空"
return "—"
def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str:
pt = plan.get("plan_type") or ""
lines = [
f"🟢 对冲计划启动 #{plan.get('id')}",
f"📌 类型:{_type_label(pt)}",
f"🪙 标的:{plan.get('underlying') or '—'}",
]
if pt == "perp_options":
lines.extend(
[
f"📈 方向:{_dir_label(plan.get('direction') or '')}",
f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}",
f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}",
f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
rr = plan.get("profit_rr")
if rr not in (None, ""):
lines.extend(
[
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:
for leg in legs:
role = leg.get("leg_role") or ""
if role == "perp":
lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}")
else:
lines.append(
f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} "
f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}"
)
lines.append("📎 独立模块推送,不进普通交易复盘")
return "\n".join(lines)
def build_hedge_end_message(plan: dict[str, Any]) -> str:
reason = plan.get("close_reason") or "—"
total = plan.get("realized_pnl_total")
try:
tv = float(total) if total is not None else None
except (TypeError, ValueError):
tv = None
head = "🔴" if (tv is not None and tv < 0) else "🟢"
reason_map = {
"perp_tp": "永续止盈(期权默认不平)",
"perp_sl": "永续止损(期权强制平)",
"target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿",
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
"oo_rest_closing": "期期残值平·清亏损腿中",
"oo_rest_closed": "期期残值平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口",
"manual": "人工结束",
"partial_fail": "半腿失败收尾",
"cancelled": "已取消",
}
lines = [
f"{head} 对冲计划结束 #{plan.get('id')}",
f"📌 类型:{_type_label(plan.get('plan_type') or '')}",
f"🪙 标的:{plan.get('underlying') or '—'}",
f"📎 原因:{reason_map.get(reason, reason)}",
f"💰 合计≈U:{_fmt(total)}",
f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT",
f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)",
f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}",
]
return "\n".join(lines)
def build_hedge_alert_message(
*,
title: str,
plan_id: Any = None,
detail: str = "",
) -> str:
lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"]
if detail:
lines.append(str(detail)[:800])
return "\n".join(lines)
def notify_hedge(
cfg: dict[str, Any],
content: str,
) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
return False
try:
send(content)
return True
except Exception:
return False
def notify_plan_start(
cfg: dict[str, Any],
conn: Any,
plan: dict[str, Any],
legs: Optional[list[dict[str, Any]]] = None,
) -> bool:
if int(plan.get("wechat_start_sent") or 0):
return False
ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_start_sent=1)
plan["wechat_start_sent"] = 1
return ok
def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool:
if int(plan.get("wechat_end_sent") or 0):
return False
# 中间态 target_win_leg 不算正式结束推送(用告警)
if (plan.get("close_reason") or "") in (
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed":
cr = str(plan.get("close_reason") or "")
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()
if mode in ("close_all", "全平", "残值平"):
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
else:
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(
cfg,
build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"),
detail=detail,
),
)
return True
ok = notify_hedge(cfg, build_hedge_end_message(plan))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_end_sent=1)
plan["wechat_end_sent"] = 1
return ok
def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool:
detail = msg
if results:
try:
detail = f"{msg}\n路径结果:{results}"[:800]
except Exception:
pass
return notify_hedge(
cfg,
build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail),
)
@@ -1,527 +0,0 @@
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
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≤K−N.点数须 >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,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-217
View File
@@ -1,217 +0,0 @@
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]:
return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or ""))
def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool:
exp = leg_exp_ms(leg)
if exp is None:
return False
now = int(now_ms if now_ms is not None else time.time() * 1000)
return now >= int(exp)
def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float:
"""按到期结算口径估算腿盈亏(USDC)."""
premium = float(leg.get("premium") or 0)
strike = _sf(leg.get("strike"))
if strike is None:
return -premium
sheets = float(leg.get("size") or 1)
# ct_mult 未入库时默认 0.01
ct = float(leg.get("ct_mult") or 0.01)
return float(
option_expiry_pnl(
opt_type=str(leg.get("opt_type") or "P"),
strike=float(strike),
spot=float(spot),
sheets=sheets,
ct_mult=ct,
premium_paid=premium,
)
)
def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool:
opts = [
x
for x in legs
if str(x.get("leg_role") or "").startswith("option")
and str(x.get("status") or "") in ("open", "hold_to_expiry")
]
if not opts:
return False
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
def _parse_opened_ms(raw: Any) -> Optional[int]:
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if raw is None or raw == "":
return None
s = str(raw).strip()
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def resolve_option_leg_realized_pnl(
*,
ex: Any = None,
leg: dict[str, Any],
fallback: Optional[float] = None,
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
hist_rows: Optional[list[dict[str, Any]]] = None,
) -> tuple[Optional[float], str]:
"""
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
返回 (pnl, source) source=exchange|fallback|none.
"""
inst_id = str(leg.get("inst_id") or "").strip()
open_ms = _parse_opened_ms(leg.get("opened_at"))
rows = hist_rows
if rows is None and inst_id:
try:
if callable(fetch_history_fn):
rows = fetch_history_fn(inst_id)
elif ex is not None:
from lib.exchange.okx_options_lib import fetch_option_position_history
rows = fetch_option_position_history(ex, inst_id)
except Exception:
rows = None
if rows:
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
if pnl is not None:
return round(float(pnl), 4), "exchange"
if fallback is not None:
return round(float(fallback), 4), "fallback"
return None, "none"
def backfill_hedge_option_legs_realized_pnl(
conn: Any,
hist_rows: list[dict[str, Any]],
*,
update_plan_fn: Optional[Callable[..., Any]] = None,
) -> dict[str, int]:
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if inst:
by_inst.setdefault(inst, []).append(raw)
legs = conn.execute(
"""
SELECT * FROM hedge_plan_legs
WHERE status = 'closed'
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
ORDER BY id DESC
LIMIT 400
"""
).fetchall()
updated_legs = 0
touched_plans: set[int] = set()
for row in legs:
leg = dict(row)
inst = str(leg.get("inst_id") or "").strip()
if not inst or inst not in by_inst:
continue
pnl, src = resolve_option_leg_realized_pnl(
leg=leg,
hist_rows=by_inst[inst],
fallback=None,
)
if src != "exchange" or pnl is None:
continue
local = _sf(leg.get("realized_pnl"))
if local is not None and abs(local - pnl) < 1e-6:
continue
conn.execute(
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
(pnl, int(leg["id"])),
)
updated_legs += 1
touched_plans.add(int(leg["plan_id"]))
updated_plans = 0
updater = update_plan_fn or update_plan
for pid in touched_plans:
plan = get_plan(conn, pid)
if not plan or str(plan.get("status") or "") != "closed":
continue
plan_legs = get_plan_legs(conn, pid)
opt_sum = 0.0
for lg in plan_legs:
role = str(lg.get("leg_role") or "")
if not (role.startswith("option") or lg.get("opt_type")):
continue
if str(lg.get("status") or "") != "closed":
continue
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
ptype = str(plan.get("plan_type") or "")
if ptype == "options_options":
total = opt_sum
kwargs: dict[str, Any] = {
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
else:
total = perp + opt_sum
kwargs = {
"realized_pnl_perp": round(perp, 4),
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
old_total = _sf(plan.get("realized_pnl_total"))
old_opts = _sf(plan.get("realized_pnl_options"))
if (
old_total is not None
and abs(old_total - total) < 1e-6
and old_opts is not None
and abs(old_opts - opt_sum) < 1e-6
):
continue
updater(conn, pid, **kwargs)
updated_plans += 1
return {"legs": updated_legs, "plans": updated_plans}
-96
View File
@@ -1,96 +0,0 @@
"""OKX 期权/对冲模式(env: OKX_TRADE_MODE).
options → 仅单独期权(隐藏对冲导航与对冲 env 配置)
options_options → 仅期期对冲(不可单独开期权)
perp_options → 永期对冲(含合约腿);产品 UI 已隐藏,仅兼容旧 .env / 历史计划
"""
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})
# 配置页可选模式(不含永期)
UI_MODES = frozenset({MODE_OPTIONS, 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 时默认单独期权(对冲关闭).
旧 HEDGE_PLAN_* 开关不再自动打开对冲,避免误开;需显式设 OKX_TRADE_MODE=options_options.
"""
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「交易模式」切换为「单独期权」"
)
@@ -1,408 +0,0 @@
<div class="hedge-plan-page-wrap" style="grid-column:1/-1" id="hedge-plan-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options 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 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-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
{% if not hedge_plan_enabled %}
<div class="flash" style="margin-bottom:12px">对冲计划未启用:请在 <code>env配置 → 对冲计划</code> 打开 <code>HEDGE_PLAN_ENABLED</code>(可热更).</div>
{% endif %}
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %}
{% 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 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
{% endif %}
<div class="card hp-head-card">
<div class="hp-head-row">
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
</h2>
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
{% if hedge_plan_show_perp_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
{% endif %}
{% if hedge_plan_show_options_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
{% 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="history">历史记录</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
</div>
<p class="muted" id="hp-gate-line"></p>
<p class="muted hp-acct-hint" id="hp-acct-hint">永续腿→合约账户 · 期权腿→期权账户</p>
</div>
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout">
<div class="card hp-po-perp-card">
<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">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
</div>
<div class="hp-po-top">
<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="做多=买Call+永续空"><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>
<div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
<label class="hp-po-field">
<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" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--tp">
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--sl">
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
<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>
</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 id="hp-perp-pnl-line" class="hp-po-pnl"></div>
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
</div>
</div>
<div class="card hp-po-right-card">
<div class="hp-po-right-stack">
<div class="hp-po-inner-card hp-po-perp-quote-card">
<h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
<div class="hp-po-quote-head">
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
</div>
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
<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 class="form-row hp-action-row hp-po-action-row">
<span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="options-dual-grid" id="hp-oo-layout">
<div class="card">
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<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>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div>
<div class="form-row hp-target-row hp-oo-target-row">
<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>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div>
<div class="hp-oo-controls">
<div class="hp-oo-ctrl">
<span class="hp-oo-ctrl-lab">张数</span>
<div class="hp-oo-seg" role="group" aria-label="自动张数">
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true">✓</span>同张数</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
</div>
</div>
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<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="盈利腿平后:亏损腿本合约权利金跌至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>
</div>
</div>
</div>
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
<div id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-oo-leg-row" data-leg="b">
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<p class="muted" id="hp-oo-prem-line"></p>
</div>
</div>
<div class="card hp-opt-card">
<h2>期权 T 型报价</h2>
<div class="form-row">
<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 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 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">
<thead>
<tr>
<th colspan="4" class="opt-t-head-call">Call</th>
<th class="opt-t-head-mid">行权</th>
<th colspan="4" class="opt-t-head-put">Put</th>
</tr>
<tr>
<th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
<th>K</th>
<th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
</tr>
</thead>
<tbody id="hp-oo-tbody">
<tr><td colspan="9" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
<div class="hp-oo-transfer-bals muted">
<span>资金 <strong id="hp-oo-funding-usdc">—</strong></span>
<span class="hp-oo-transfer-sep">·</span>
<span>交易 <strong id="hp-oo-trading-usdc">—</strong></span>
<span class="hp-oo-transfer-unit">USDC</span>
<span class="muted" id="hp-oo-xfer-msg"></span>
</div>
<div class="form-row hp-oo-transfer-form" autocomplete="off">
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
<option value="funding_to_trading" selected>资金 → 交易</option>
<option value="trading_to_funding">交易 → 资金</option>
</select>
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
</div>
</div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>进行中的计划</h2>
<p class="muted">仅显示已启动但尚未结束的计划;可查看每条腿的当前记录状态。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>目标/止盈止损</th><th>开仓</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-active-tbody">
<tr><td colspan="8" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>历史记录</h2>
<p class="muted">独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-history-tbody">
<tr><td colspan="10" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-stats" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>统计分析</h2>
<p class="muted">按永期 / 期期分别统计:胜率、盈亏比、最大盈利、最大亏损、最大回撤(按结束时间累积)</p>
<div id="hp-stats-box" class="muted">加载中…</div>
</div>
</div>
<div id="hp-detail-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal" role="dialog" aria-modal="true" aria-labelledby="hp-detail-title">
<div class="hp-modal-head">
<h3 id="hp-detail-title">成交细节</h3>
<button type="button" class="btn-secondary" id="hp-detail-close">关闭</button>
</div>
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
</div>
</div>
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
<div class="hp-modal-head">
<h3 id="hp-preview-title">情景测算</h3>
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
</div>
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th id="hp-preview-mid-th">永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">计算中…</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-preview-actions">
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=46"></script>
+1
View File
@@ -0,0 +1 @@
"""Hub bridge package (中控对接)."""
+141
View File
@@ -0,0 +1,141 @@
"""中控 Bridge 鉴权与 SSO(短期登录链)."""
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
import time
from typing import Optional
from urllib.parse import urlencode
def hub_bridge_token() -> str:
return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
def extract_hub_token_from_request(headers) -> str:
raw = (headers.get("X-Hub-Token") or "").strip()
if raw:
return raw
auth = (headers.get("Authorization") or "").strip()
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return ""
def hub_bridge_token_ok(headers) -> bool:
expected = hub_bridge_token()
if not expected:
return False
got = extract_hub_token_from_request(headers)
if not got:
return False
return hmac.compare_digest(got, expected)
def make_sso_token(bridge_token: str, *, ttl_sec: int = 120) -> str:
"""exp.hex_hmac — 中控与客户端共用 bridge_token 签名."""
exp = int(time.time()) + max(30, int(ttl_sec))
payload = str(exp)
sig = hmac.new(
bridge_token.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{payload}.{sig}"
def verify_sso_token(token: str, bridge_token: Optional[str] = None) -> bool:
expected = (bridge_token if bridge_token is not None else hub_bridge_token()).strip()
raw = (token or "").strip()
if not expected or not raw or "." not in raw:
return False
payload, sig = raw.rsplit(".", 1)
try:
exp = int(payload)
except ValueError:
return False
if exp < int(time.time()):
return False
want = hmac.new(
expected.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, want)
def build_embed_open_url(
base_url: str,
*,
bridge_token: str,
embed_path: str = "/embed",
ttl_sec: int = 120,
) -> str:
"""中控生成带 SSO 的客户端打开地址."""
root = (base_url or "").rstrip("/")
path = embed_path or "/embed"
if not path.startswith("/"):
path = "/" + path
token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
q = urlencode({"token": token, "next": path})
return f"{root}/hub_sso?{q}"
def extract_embed_sso(request) -> str:
"""中控 iframe 内用的短期会话令牌(Cookie 被拦时的后备)."""
raw = (request.headers.get("X-Hub-Embed-SSO") or "").strip()
if raw:
return raw
return (request.args.get("embed_sso") or "").strip()
def hub_embed_sso_ok(request) -> bool:
tok = extract_embed_sso(request)
return bool(tok and verify_sso_token(tok))
def make_embed_session_token(bridge_token: str | None = None, *, ttl_sec: int = 604800) -> str:
"""嵌入会话令牌(默认 7 天),算法与 SSO 相同."""
secret = (bridge_token if bridge_token is not None else hub_bridge_token()).strip()
return make_sso_token(secret, ttl_sec=ttl_sec)
def frame_ancestors_header_value() -> Optional[str]:
"""
HUB_EMBED_FRAME_ANCESTORS: 空格分隔 Origin,如 https://hub.local http://192.168.1.10:5100
未配置则不改响应头(保持现状);配 * 则允许任意父页(仅内网慎用).
"""
raw = (os.getenv("HUB_EMBED_FRAME_ANCESTORS") or "").strip()
if not raw:
return None
if raw == "*":
return "*"
parts = [p.strip() for p in raw.split() if p.strip()]
return " ".join(parts) if parts else None
def ensure_hub_bridge_token(env_path: str) -> str:
"""若 .env 中 HUB_BRIDGE_TOKEN 为空则生成并写入;返回当前 token."""
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
path = (env_path or "").strip()
if not path or not os.path.isfile(path):
existing = hub_bridge_token()
if existing:
return existing
token = secrets.token_urlsafe(32)
os.environ["HUB_BRIDGE_TOKEN"] = token
return token
lines = read_env_lines(path)
cur = (env_get(lines, "HUB_BRIDGE_TOKEN") or "").strip()
if cur:
os.environ["HUB_BRIDGE_TOKEN"] = cur
return cur
token = secrets.token_urlsafe(32)
apply_env_updates(path, {"HUB_BRIDGE_TOKEN": token})
os.environ["HUB_BRIDGE_TOKEN"] = token
return token
+646
View File
@@ -0,0 +1,646 @@
"""中控 Bridge:只读快照 + 受限写(买一全平) + SSO 登录入口."""
from __future__ import annotations
from typing import Any
from flask import Flask, jsonify, redirect, request, session
from urllib.parse import quote
from lib.hub.hub_bridge_auth import (
frame_ancestors_header_value,
hub_bridge_token,
hub_bridge_token_ok,
make_embed_session_token,
verify_sso_token,
)
def _safe_next_path(raw: str, default: str = "/embed") -> str:
p = (raw or "").strip() or default
if not p.startswith("/"):
p = "/" + p
if "://" in p or p.startswith("//"):
return default
return p
def _enrich_hub_protocol(snap: dict[str, Any], *, app_module: Any) -> dict[str, Any]:
"""对齐中控设计文档 version=1 字段,同时保留原 snapshot 键."""
out = dict(snap or {})
out["version"] = 1
mode = "live"
try:
from lib.sim.mode_lib import is_sim_mode
get_db = getattr(app_module, "get_db", None)
if callable(get_db) and is_sim_mode(get_db):
mode = "sim"
except Exception:
mode = "live"
label = ""
try:
from lib.sim.mode_lib import exchange_mode_label
get_db = getattr(app_module, "get_db", None)
if callable(get_db):
label = exchange_mode_label(get_db=get_db, exchange_key="okx") or ""
except Exception:
label = ""
if not label:
label = "okx_sim" if mode == "sim" else "okx_live"
positions = out.get("positions") if isinstance(out.get("positions"), list) else []
slim_pos = []
premium_total_usdt = 0.0
has_premium = False
for p in positions:
if not isinstance(p, dict):
continue
preview = p.get("close_preview") if isinstance(p.get("close_preview"), dict) else {}
# 仅在实例已附带 close_preview 时判定残档;缺字段=未同步,勿假装「无买一/残档」
preview_present = bool(preview) and (
"bid_invalid" in preview
or "levels" in preview
or "covered_sheets" in preview
or "liquidity_ok" in preview
)
levels = preview.get("levels") if isinstance(preview.get("levels"), list) else []
top = levels[0] if levels and isinstance(levels[0], dict) else {}
bid_px = top.get("px")
if bid_px is None:
bid_px = preview.get("raw_bid_px")
bid_sz = top.get("available_sheets")
try:
uncovered = int(float(preview.get("uncovered_sheets") or 0)) if preview_present else 0
except (TypeError, ValueError):
uncovered = 0
try:
covered = int(float(preview.get("covered_sheets") or 0)) if preview_present else 0
except (TypeError, ValueError):
covered = 0
bid_invalid = bool(preview.get("bid_invalid")) if preview_present else False
bid_invalid_reason = preview.get("bid_invalid_reason") if preview_present else None
# 残档/无效买一,或买一深度盖不住持仓 → 流动性不足;未同步时不标黄残档
if not preview_present:
liq_ok = None
else:
liq_ok = (not bid_invalid) and uncovered <= 0 and (
preview.get("liquidity_ok") is not False
)
if not levels and not bid_invalid and covered <= 0:
# 已拉盘口但无可用买一档
liq_ok = False
bid_invalid = True
if not bid_invalid_reason:
bid_invalid_reason = "无买一"
pnl_usdt = p.get("display_pnl_usdt")
if pnl_usdt is None:
try:
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
pnl_usdt = display_pnl_usdt_from_option_row(p)
except Exception:
pnl_usdt = p.get("net_pnl_usdt")
premium_paid = p.get("premium_paid")
premium_usdt = None
try:
from lib.options.options_positions_lib import premium_paid_usdt_from_option_row
premium_usdt = premium_paid_usdt_from_option_row(p)
except Exception:
premium_usdt = None
if premium_usdt is not None:
has_premium = True
premium_total_usdt += float(premium_usdt)
contract_leverage = p.get("contract_leverage")
if contract_leverage is None:
try:
from lib.options.options_pricing_lib import equivalent_contract_leverage
contract_leverage = equivalent_contract_leverage(
index_px=p.get("idx_px") or p.get("index_px"),
eth_amount=p.get("eth_amount"),
total_premium=premium_paid,
margin_mode=p.get("margin_mode"),
)
except Exception:
contract_leverage = None
slim_pos.append(
{
"inst_id": p.get("inst_id"),
"opt_type": p.get("opt_type"),
"sheets": p.get("sheets") or p.get("pos"),
"pnl_usdt": pnl_usdt,
"premium_paid": premium_paid,
"premium_paid_usdt": round(float(premium_usdt), 4) if premium_usdt is not None else None,
"premium_ccy": p.get("premium_ccy"),
"contract_leverage": contract_leverage,
"delta": p.get("delta") if p.get("delta") is not None else p.get("delta_bs"),
"mark": p.get("mark") or p.get("mark_px"),
"underlying": p.get("underlying"),
"source_label": p.get("source_label"),
"target_index": p.get("target_index"),
"profit_exit_enabled": p.get("profit_exit_enabled"),
"profit_exit_mult": p.get("profit_exit_mult"),
"profit_exit_state": p.get("profit_exit_state"),
"target_monitor_text": p.get("target_monitor_text"),
"bid_px": bid_px,
"bid_sz": bid_sz,
"covered_sheets": covered if preview_present else None,
"uncovered_sheets": uncovered if preview_present else None,
"bid_invalid": bid_invalid,
"bid_invalid_reason": bid_invalid_reason,
"liquidity_ok": liq_ok,
}
)
_allowed = out.get("options_allowed_underlys")
if not isinstance(_allowed, list) or not _allowed:
_u0 = str(out.get("options_underly") or "ETH").strip().upper() or "ETH"
_allowed = [_u0] if _u0 in ("ETH", "BTC") else ["ETH", "BTC"]
else:
_allowed = [
str(x).strip().upper()
for x in _allowed
if str(x).strip().upper() in ("ETH", "BTC")
] or ["ETH", "BTC"]
out["instance"] = {
"label": label,
"mode": mode,
"options_enabled": bool(out.get("enabled")),
"account_label": out.get("account_label") or "",
"margin_mode": out.get("options_margin_mode") or "coin",
"underly": out.get("options_underly") or "ETH",
"allowed_underlys": _allowed,
}
out["options_allowed_underlys"] = _allowed
funding_usdt = out.get("funding_usdt")
trading_usdt = out.get("trading_usdt")
funding_usdc = out.get("funding_usdc")
trading_usdc = out.get("trading_usdc")
out["funds"] = {
"funding_usdt": funding_usdt,
"trading_usdt": trading_usdt,
"funding_usdc": funding_usdc,
"trading_usdc": trading_usdc,
}
def _f(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
# 账户可用余额:优先 USDT 资+交;都空则回退 USDC(≈U)
cash_parts = [_f(trading_usdt), _f(funding_usdt)]
if all(x is None for x in cash_parts):
cash_parts = [_f(trading_usdc), _f(funding_usdc)]
cash_known = [x for x in cash_parts if x is not None]
cash_usdt = round(sum(cash_known), 4) if cash_known else None
premium_usdt = round(premium_total_usdt, 4) if has_premium else (0.0 if not slim_pos else None)
# 占比分母 = 当前余额 + 已付权利金(权利金已从余额扣出,需加回才是完整账户资金)
equity_usdt = None
if cash_usdt is not None or premium_usdt is not None:
equity_usdt = round(float(cash_usdt or 0.0) + float(premium_usdt or 0.0), 4)
ratio_pct = None
if premium_usdt is not None and equity_usdt is not None and equity_usdt > 1e-9:
ratio_pct = round(float(premium_usdt) / float(equity_usdt) * 100.0, 2)
out["pnl"] = {
"unrealized_usdt": out.get("upl_total_usdc"),
}
initial_cash_usdt = None
try:
import os
if mode == "sim":
initial_cash_usdt = float(os.getenv("SIM_INITIAL_EQUITY_USDT") or 10000)
else:
for key in (
"LIVE_INITIAL_EQUITY_USDT",
"OPTIONS_INITIAL_EQUITY_USDT",
"HUB_INITIAL_CASH_USDT",
):
raw = (os.getenv(key) or "").strip()
if not raw:
continue
initial_cash_usdt = float(raw)
break
if initial_cash_usdt is not None and initial_cash_usdt <= 0:
initial_cash_usdt = None
elif initial_cash_usdt is not None:
initial_cash_usdt = round(float(initial_cash_usdt), 4)
except (TypeError, ValueError):
initial_cash_usdt = None
out["premium"] = {
"paid_usdt": premium_usdt,
"cash_usdt": cash_usdt,
"equity_usdt": equity_usdt,
"ratio_pct": ratio_pct,
"initial_cash_usdt": initial_cash_usdt,
}
out["positions_brief"] = slim_pos
out["cond_order_locked"] = bool(out.get("cond_order_locked"))
active_cond = out.get("cond_orders_active")
out["cond_orders_active"] = active_cond if isinstance(active_cond, list) else []
try:
from lib.options.options_time_lib import options_app_now_str
out["ts"] = options_app_now_str()
except Exception:
from datetime import datetime
out["ts"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
quotes = {"tickers": [], "updated_at": out.get("ts") or ""}
try:
from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers
ex = getattr(app_module, "exchange", None)
reset_hour = int(getattr(app_module, "TRADING_DAY_RESET_HOUR", 8) or 8)
tickers = build_nav_spot_tickers(ex, reset_hour=reset_hour) if ex is not None else []
quotes = {
"tickers": tickers if isinstance(tickers, list) else [],
"updated_at": out.get("ts") or "",
}
except Exception:
pass
out["quotes"] = quotes
return out
def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
@app.route("/api/hub/options/snapshot")
def api_hub_options_snapshot():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
fn = hub_ctx.get("options_snapshot_fn")
if not callable(fn):
return jsonify(
{"ok": False, "msg": "期权快照未注册", "version": 1, "enabled": False}
), 503
try:
snap = fn() or {}
except Exception as e:
return jsonify({"ok": False, "msg": str(e), "version": 1, "enabled": True}), 500
return jsonify(_enrich_hub_protocol(snap, app_module=app_module))
@app.route("/api/hub/options/pnl_curve")
def api_hub_options_pnl_curve():
"""中控汇总用:累计已实现盈亏曲线(划转/提现不影响)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict) or not callable(cfg.get("get_db")):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
range_key = (request.args.get("range") or request.args.get("window") or "7d").strip()
conn = cfg["get_db"]()
try:
from lib.instance.instance_cash_sample_lib import (
query_realized_pnl_series_with_db,
)
out = query_realized_pnl_series_with_db(conn, range_key=range_key)
out["version"] = 1
return jsonify(out)
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "曲线失败", "version": 1}), 500
finally:
conn.close()
@app.route("/api/hub/options/stats")
def api_hub_options_stats():
"""中控汇总用:窗口化已实现盈亏统计(不含浮盈进净盈亏主指标以外的展示字段仍附带)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_positions_lib import sum_options_net_pnl_usdc
from lib.options.options_stats_lib import (
compute_options_stats_for_window,
history_pnl_to_usdt,
load_live_exchange_stats_history,
load_local_options_stats_history,
normalize_stats_window,
)
window = normalize_stats_window(request.args.get("window") or "7d")
ex = cfg.get("exchange_options")
if ex is None:
return jsonify({"ok": False, "msg": "交易所未就绪", "version": 1}), 503
try:
raw_live = cfg["fetch_option_positions"](ex)
if raw_live is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败", "version": 1})
use_sim = False
try:
from lib.sim.mode_lib import is_sim_mode
use_sim = is_sim_mode(cfg["get_db"])
except Exception:
use_sim = False
if use_sim:
conn = cfg["get_db"]()
try:
history = load_local_options_stats_history(conn, ex)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
else:
# 实盘:先网后短事务,禁止握着 DB 锁拉行情(否则中控整机超时)
history = load_live_exchange_stats_history(cfg, ex)
stats = compute_options_stats_for_window(history_pnl_to_usdt(history, ex), window)
open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live)
net_realized = stats.get("net_realized_pnl")
try:
net_f = float(net_realized) if net_realized is not None else 0.0
except (TypeError, ValueError):
net_f = 0.0
total_pnl = None
if open_float is not None:
total_pnl = round(net_f + float(open_float), 4)
elif stats.get("total_closed"):
total_pnl = round(net_f, 4)
pnl_unit = "U" if is_coin_margin_mode() else "USDC"
return jsonify(
{
"ok": True,
"version": 1,
"pnl_unit": pnl_unit,
"open_float_pnl": open_float,
"total_pnl": total_pnl,
**stats,
}
)
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "统计失败", "version": 1}), 500
@app.route("/api/hub/options/close", methods=["POST"])
def api_hub_options_close():
"""中控下发全平指令:客户端本地买一限价执行;无效买一拒绝."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
data = request.get_json(silent=True) or {}
if data.get("market"):
return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价", "version": 1})
# 总览约定只全平,忽略/拒绝自定义张数
if data.get("sheets") is not None and str(data.get("sheets")).strip() != "":
return jsonify({"ok": False, "msg": "中控平仓仅支持全平,勿传 sheets", "version": 1})
inst_id = (data.get("inst_id") or "").strip()
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id", "version": 1})
hub_ctx = app.config.get("HUB_CTX") or {}
fn = hub_ctx.get("options_close_fn")
if not callable(fn):
return jsonify({"ok": False, "msg": "平仓接口未注册", "version": 1}), 503
try:
result = fn(inst_id) or {}
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "平仓失败", "version": 1}), 500
if not isinstance(result, dict):
return jsonify({"ok": False, "msg": "平仓返回异常", "version": 1}), 500
out = dict(result)
out["version"] = 1
return jsonify(out)
@app.route("/api/hub/options/transfer", methods=["POST"])
def api_hub_options_transfer():
"""中控指令:实例内资金户↔交易户划转(默认资金→交易,USDT)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
data = request.get_json(silent=True) or {}
from lib.options.options_transfer_exec_lib import execute_options_internal_transfer
out = execute_options_internal_transfer(
cfg,
amount=data.get("amount"),
from_acct=(data.get("from") or "funding"),
to_acct=(data.get("to") or "trading"),
ccy=(data.get("ccy") or "USDT"),
)
if not isinstance(out, dict):
return jsonify({"ok": False, "msg": "划转返回异常", "version": 1}), 500
body = dict(out)
body["version"] = 1
return jsonify(body)
@app.route("/api/hub/options/cond_orders", methods=["GET"])
def api_hub_options_cond_orders_list():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import list_cond_orders
from lib.options.options_db import init_options_tables
conn = cfg["get_db"]()
try:
init_options_tables(conn)
orders = list_cond_orders(conn, active_only=False)
return jsonify({"ok": True, "version": 1, "orders": orders})
finally:
conn.close()
@app.route("/api/hub/options/cond_orders", methods=["POST"])
def api_hub_options_cond_orders_create():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import (
create_cond_order,
machine_has_option_position,
)
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import option_position_limit_block_msg
ex = cfg.get("exchange_options")
if machine_has_option_position(cfg, ex):
return jsonify({"ok": False, "msg": "本机已有期权仓,拒绝条件单", "version": 1})
limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id="",
fetch_positions=cfg.get("fetch_option_positions"),
)
if limit_msg:
return jsonify({"ok": False, "msg": limit_msg, "version": 1})
data = request.get_json(silent=True) or {}
conn = cfg["get_db"]()
try:
init_options_tables(conn)
out = create_cond_order(
conn,
data,
send_wechat=cfg.get("send_wechat"),
account_label=str(cfg.get("account_label") or ""),
)
out["version"] = 1
return jsonify(out)
finally:
conn.close()
@app.route("/api/hub/options/cond_orders/<hub_order_id>/cancel", methods=["POST"])
def api_hub_options_cond_orders_cancel(hub_order_id: str):
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import cancel_cond_order
from lib.options.options_db import init_options_tables
conn = cfg["get_db"]()
try:
init_options_tables(conn)
out = cancel_cond_order(
conn,
hub_order_id,
send_wechat=cfg.get("send_wechat"),
account_label=str(cfg.get("account_label") or ""),
)
out["version"] = 1
return jsonify(out)
finally:
conn.close()
@app.route("/api/hub/options/cond_orders/<hub_order_id>/exit", methods=["POST"])
def api_hub_options_cond_orders_exit(hub_order_id: str):
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import update_cond_order_exit
from lib.options.options_db import init_options_tables
data = request.get_json(silent=True) or {}
conn = cfg["get_db"]()
try:
init_options_tables(conn)
out = update_cond_order_exit(
conn,
hub_order_id,
target_index=data.get("target_index"),
profit_exit_enabled=data.get("profit_exit_enabled"),
profit_exit_mult=data.get("profit_exit_mult"),
)
out["version"] = 1
return jsonify(out)
finally:
conn.close()
@app.route("/api/hub/version", methods=["GET"])
def hub_version():
"""中控探测客户端当前 git 版本/在线."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
from lib.instance.instance_update_lib import read_git_version
ver = read_git_version()
ver["online"] = True
mode = "live"
try:
from lib.sim.mode_lib import exchange_mode_label, is_sim_mode
get_db = getattr(app_module, "get_db", None)
if callable(get_db) and is_sim_mode(get_db):
mode = "sim"
ver["mode"] = mode
if callable(get_db):
ver["mode_label"] = exchange_mode_label(get_db=get_db, exchange_key="okx") or ""
except Exception:
ver["mode"] = mode
return jsonify(ver)
@app.route("/api/hub/update", methods=["POST"])
def hub_update():
"""中控远程快速更新:git pull + pm2 restart(异步)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
from lib.instance.instance_update_lib import schedule_pull_and_restart
out = schedule_pull_and_restart(delay_seconds=1.5)
return jsonify(out), (200 if out.get("ok") else 400)
@app.route("/api/hub/backup/list", methods=["GET"])
def hub_backup_list():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
return jsonify(
{
"ok": True,
"items": list_backups(),
"backup_dir": backup_dir(),
"keep_days": backup_keep_days(),
"auto_hour_bj": 3,
"includes": [".env", "database", "options_journal"],
}
)
@app.route("/api/hub/backup/download/<name>", methods=["GET"])
def hub_backup_download(name: str):
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
import os
from flask import send_file
from lib.instance.backup_lib import backup_path
path = backup_path(name)
if not path or not os.path.isfile(path):
return jsonify({"ok": False, "msg": "备份不存在"}), 404
return send_file(
path,
mimetype="application/gzip",
as_attachment=True,
download_name=os.path.basename(path),
)
@app.route("/hub_sso")
def hub_sso_login():
token = (request.args.get("token") or "").strip()
next_path = _safe_next_path(request.args.get("next") or "/embed")
if not verify_sso_token(token):
return (
"<!doctype html><meta charset=utf-8><title>SSO 失败</title>"
"<p>中控登录链无效或已过期,请从中控重新打开.</p>",
403,
)
session["logged_in"] = True
session.permanent = True
session.modified = True
# iframe 跨站时常丢 Cookie:附带可放入 sessionStorage 的 embed_sso
embed_tok = make_embed_session_token(hub_bridge_token(), ttl_sec=604800)
joiner = "&" if "?" in next_path else "?"
return redirect(f"{next_path}{joiner}embed_sso={quote(embed_tok, safe='')}")
@app.after_request
def _hub_frame_ancestors(resp):
ancestors = frame_ancestors_header_value()
if ancestors:
resp.headers["Content-Security-Policy"] = f"frame-ancestors {ancestors}"
resp.headers.pop("X-Frame-Options", None)
return resp
+278
View File
@@ -0,0 +1,278 @@
"""实例备份/恢复: .env + 数据库 + 期权复盘图片;默认落盘 /root/crypto_okx_backups."""
from __future__ import annotations
import json
import os
import re
import shutil
import tarfile
import tempfile
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
_BJ = timezone(timedelta(hours=8))
_SAFE_NAME_RE = re.compile(r"^crypto_okx_backup_\d{8}_\d{6}\.tar\.gz$")
_LOCK = threading.Lock()
_LAST_AUTO_DAY = ""
def beijing_now() -> datetime:
return datetime.now(_BJ)
def backup_keep_days() -> int:
try:
return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10))
except (TypeError, ValueError):
return 10
def backup_dir() -> str:
"""备份目录:默认 /root/crypto_okx_backups,可用 BACKUP_DIR 覆盖."""
root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root"
path = os.path.join(root, "crypto_okx_backups")
os.makedirs(path, exist_ok=True)
return path
def _safe_backup_name(name: str) -> str | None:
n = os.path.basename((name or "").strip())
if not _SAFE_NAME_RE.match(n):
return None
return n
def backup_path(name: str) -> str | None:
safe = _safe_backup_name(name)
if not safe:
return None
path = os.path.abspath(os.path.join(backup_dir(), safe))
root = os.path.abspath(backup_dir())
if not path.startswith(root + os.sep) and path != root:
return None
return path
def list_backups() -> list[dict[str, Any]]:
root = backup_dir()
items: list[dict[str, Any]] = []
try:
names = os.listdir(root)
except OSError:
return []
for name in names:
if not _SAFE_NAME_RE.match(name):
continue
path = os.path.join(root, name)
try:
st = os.stat(path)
except OSError:
continue
items.append(
{
"name": name,
"size": int(st.st_size),
"mtime": int(st.st_mtime),
"mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
)
items.sort(key=lambda x: x["mtime"], reverse=True)
return items
def prune_backups(*, keep_days: int | None = None) -> int:
days = backup_keep_days() if keep_days is None else max(1, int(keep_days))
cutoff = time.time() - days * 86400
removed = 0
for item in list_backups():
if float(item["mtime"]) >= cutoff:
continue
path = backup_path(item["name"])
if not path:
continue
try:
os.remove(path)
removed += 1
except OSError:
pass
return removed
def _add_path(tar: tarfile.TarFile, abs_path: str, arcname: str) -> None:
if not abs_path or not os.path.exists(abs_path):
return
tar.add(abs_path, arcname=arcname, recursive=True)
def create_backup(
*,
base_dir: str,
db_path: str,
upload_folder: str,
label: str = "manual",
) -> dict[str, Any]:
"""打包 .env / 数据库 / options_journal 到 tar.gz."""
with _LOCK:
now = beijing_now()
name = f"crypto_okx_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
out_path = os.path.join(backup_dir(), name)
manifest = {
"kind": "crypto_okx",
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"label": (label or "manual").strip() or "manual",
"includes": [".env", "database", "options_journal"],
}
env_path = os.path.join(base_dir, ".env")
try:
from lib.options.options_review_images_lib import options_review_upload_dir
journal_dir = options_review_upload_dir(upload_folder)
except Exception:
journal_dir = os.path.join(upload_folder or "", "options_journal")
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="okx_bak_")
os.close(tmp_fd)
try:
with tarfile.open(tmp_path, "w:gz") as tar:
manifest_path = tmp_path + ".manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
try:
tar.add(manifest_path, arcname="manifest.json")
finally:
try:
os.remove(manifest_path)
except OSError:
pass
_add_path(tar, env_path, ".env")
if db_path and os.path.isfile(db_path):
_add_path(tar, db_path, "database/" + os.path.basename(db_path))
if journal_dir and os.path.isdir(journal_dir):
_add_path(tar, journal_dir, "options_journal")
shutil.move(tmp_path, out_path)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
prune_backups()
st = os.stat(out_path)
return {
"ok": True,
"name": name,
"path": out_path,
"size": int(st.st_size),
"mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"keep_days": backup_keep_days(),
}
def restore_backup(
name: str,
*,
base_dir: str,
db_path: str,
upload_folder: str,
) -> dict[str, Any]:
path = backup_path(name)
if not path or not os.path.isfile(path):
return {"ok": False, "msg": "备份不存在"}
with _LOCK:
try:
from lib.options.options_review_images_lib import options_review_upload_dir
journal_dir = options_review_upload_dir(upload_folder)
except Exception:
journal_dir = os.path.join(upload_folder or "", "options_journal")
os.makedirs(journal_dir, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="okx_restore_") as tmp:
with tarfile.open(path, "r:gz") as tar:
tar.extractall(tmp)
env_src = os.path.join(tmp, ".env")
if os.path.isfile(env_src):
shutil.copy2(env_src, os.path.join(base_dir, ".env"))
db_src_dir = os.path.join(tmp, "database")
if os.path.isdir(db_src_dir) and db_path:
for fn in os.listdir(db_src_dir):
src = os.path.join(db_src_dir, fn)
if os.path.isfile(src):
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
shutil.copy2(src, db_path)
break
journal_src = os.path.join(tmp, "options_journal")
if os.path.isdir(journal_src):
if os.path.isdir(journal_dir):
# 覆盖同名,保留其它本地文件
for root, _dirs, files in os.walk(journal_src):
rel = os.path.relpath(root, journal_src)
dest_root = journal_dir if rel == "." else os.path.join(journal_dir, rel)
os.makedirs(dest_root, exist_ok=True)
for fn in files:
shutil.copy2(os.path.join(root, fn), os.path.join(dest_root, fn))
else:
shutil.copytree(journal_src, journal_dir)
return {
"ok": True,
"msg": "已恢复 .env / 数据库 / 复盘图片,建议重启进程使配置完全生效",
"name": os.path.basename(path),
}
def maybe_run_auto_backup(
*,
base_dir: str,
db_path: str,
upload_folder: str,
hour: int = 3,
) -> dict[str, Any] | None:
"""北京时间指定整点触发一次自动备份(同日不重复)."""
global _LAST_AUTO_DAY
now = beijing_now()
day = now.strftime("%Y-%m-%d")
if now.hour != int(hour):
return None
if _LAST_AUTO_DAY == day:
return None
# 已有今日自动备份则跳过创建 prefix = f"crypto_okx_backup_{now.strftime('%Y%m%d')}_"
for item in list_backups():
if str(item["name"]).startswith(prefix):
_LAST_AUTO_DAY = day
return None
result = create_backup(
base_dir=base_dir,
db_path=db_path,
upload_folder=upload_folder,
label="auto",
)
_LAST_AUTO_DAY = day
return result
def start_backup_scheduler(
*,
base_dir: str,
db_path_fn: Callable[[], str],
upload_folder_fn: Callable[[], str],
hour: int = 3,
) -> None:
def loop() -> None:
while True:
try:
maybe_run_auto_backup(
base_dir=base_dir,
db_path=db_path_fn(),
upload_folder=upload_folder_fn(),
hour=hour,
)
except Exception:
pass
time.sleep(30)
t = threading.Thread(target=loop, name="okx-backup-scheduler", daemon=True)
t.start()
+6 -4
View File
@@ -8,10 +8,12 @@ from lib.market.ohlcv_lib import (
price_tick_from_market,
round_ohlcv_bars_to_tick,
)
from lib.trade.order_monitor_display_lib import (
apply_order_live_price_display,
apply_order_price_display_fields,
)
def apply_order_price_display_fields(payload, **kwargs):
return payload
def apply_order_live_price_display(payload, *args, **kwargs):
return payload
def resolve_kline_price_tick(
+330
View File
@@ -0,0 +1,330 @@
"""实例资金曲线采样:权益(现金+现货币+权利金+浮盈),供数据统计页使用."""
from __future__ import annotations
import datetime as dt
import os
import threading
import time
from typing import Any, Callable, Optional
_lock = threading.Lock()
_last_sample_at = 0.0
_thread: threading.Thread | None = None
_stop = threading.Event()
def cash_sample_interval_sec() -> float:
try:
return max(30.0, float(os.getenv("INSTANCE_CASH_SAMPLE_SEC") or 60))
except (TypeError, ValueError):
return 60.0
def cash_sample_keep_days() -> int:
try:
return max(7, int(float(os.getenv("INSTANCE_CASH_SAMPLE_KEEP_DAYS") or 90)))
except (TypeError, ValueError):
return 90
def init_cash_sample_table(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS instance_cash_samples (
ts_ms INTEGER PRIMARY KEY,
cash_usdt REAL NOT NULL
)
"""
)
try:
conn.execute("ALTER TABLE instance_cash_samples ADD COLUMN equity_usdt REAL")
except Exception:
pass
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 cash_usdt_from_balances(bal: dict[str, Any] | None) -> Optional[float]:
"""稳定币现金 = 交易账户 + 资金账户(优先 USDT,否则 USDC)."""
if not isinstance(bal, dict):
return None
parts = [_safe_float(bal.get("trading_usdt")), _safe_float(bal.get("funding_usdt"))]
if all(x is None for x in parts):
parts = [_safe_float(bal.get("trading_usdc")), _safe_float(bal.get("funding_usdc"))]
known = [x for x in parts if x is not None]
if not known:
return None
return round(sum(known), 4)
def spot_coin_usdt_from_balances(
bal: dict[str, Any] | None,
*,
btc_px: float | None = None,
eth_px: float | None = None,
) -> float:
"""交易+资金账户 BTC/ETH 现货按指数折 U(缓冲买币占用)."""
if not isinstance(bal, dict):
return 0.0
total = 0.0
btc = (_safe_float(bal.get("trading_btc")) or 0.0) + (
_safe_float(bal.get("funding_btc")) or 0.0
)
eth = (_safe_float(bal.get("trading_eth")) or 0.0) + (
_safe_float(bal.get("funding_eth")) or 0.0
)
if btc > 0 and btc_px is not None and btc_px > 0:
total += btc * float(btc_px)
if eth > 0 and eth_px is not None and eth_px > 0:
total += eth * float(eth_px)
return round(total, 4)
def option_premium_and_upl_usdt(positions: list[dict[str, Any]] | None) -> tuple[float, float]:
"""持仓权利金U + 浮盈U(权利金+浮盈≈期权市值)."""
prem = 0.0
upl = 0.0
if not positions:
return 0.0, 0.0
from lib.options.options_positions_lib import (
display_pnl_usdt_from_option_row,
premium_paid_usdt_from_option_row,
)
for p in positions:
if not isinstance(p, dict):
continue
try:
pu = premium_paid_usdt_from_option_row(p)
except Exception:
pu = None
if pu is not None:
prem += float(pu)
try:
du = display_pnl_usdt_from_option_row(p)
except Exception:
du = None
if du is not None:
upl += float(du)
return round(prem, 4), round(upl, 4)
def equity_usdt_from_snapshot(
bal: dict[str, Any] | None,
positions: list[dict[str, Any]] | None = None,
*,
btc_px: float | None = None,
eth_px: float | None = None,
) -> Optional[float]:
"""权益 = 稳定币现金 + 现货币折U + 权利金U + 浮盈U."""
cash = cash_usdt_from_balances(bal)
spot = spot_coin_usdt_from_balances(bal, btc_px=btc_px, eth_px=eth_px)
prem, upl = option_premium_and_upl_usdt(positions)
if cash is None and spot <= 0 and prem == 0 and upl == 0:
return None
return round(float(cash or 0.0) + float(spot) + float(prem) + float(upl), 4)
def record_cash_sample(
conn,
cash_usdt: float,
*,
equity_usdt: float | None = None,
ts_ms: int | None = None,
) -> None:
init_cash_sample_table(conn)
t = int(ts_ms if ts_ms is not None else time.time() * 1000)
eq = float(equity_usdt) if equity_usdt is not None else float(cash_usdt)
conn.execute(
"""
INSERT INTO instance_cash_samples(ts_ms, cash_usdt, equity_usdt)
VALUES (?, ?, ?)
ON CONFLICT(ts_ms) DO UPDATE SET
cash_usdt=excluded.cash_usdt,
equity_usdt=excluded.equity_usdt
""",
(t, float(cash_usdt), eq),
)
keep_ms = cash_sample_keep_days() * 86400 * 1000
cutoff = t - keep_ms
conn.execute("DELETE FROM instance_cash_samples WHERE ts_ms < ?", (cutoff,))
def _range_start_ms(range_key: str) -> tuple[str, int]:
rk = (range_key or "7d").strip().lower()
now_ms = int(time.time() * 1000)
if rk in ("today", "1d"):
start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return "today", int(start.timestamp() * 1000)
if rk in ("30d", "30"):
return "30d", now_ms - 30 * 86400 * 1000
if rk in ("all", "0"):
return "all", 0
return "7d", now_ms - 7 * 86400 * 1000
def _closed_at_to_ms(raw: Any) -> int | None:
if raw is None or raw == "":
return None
try:
from lib.options.options_stats_lib import _parse_ts
d = _parse_ts(raw)
if d is None:
return None
return int(d.timestamp() * 1000)
except Exception:
return None
def _trade_realized_usdt(row: Any) -> float | None:
u = _safe_float(row["realized_pnl_usdt"] if hasattr(row, "keys") else None)
if u is not None:
return round(float(u), 4)
# 兼容未冻结 U:币盈亏×平仓价
try:
coin = _safe_float(row["realized_pnl"])
px = _safe_float(row["close_index_px"])
except Exception:
return None
if coin is None or px is None or px <= 0:
return None
return round(float(coin) * float(px), 4)
def query_realized_pnl_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
"""累计已实现盈亏曲线(只随平仓变化,划转/提现不影响)."""
rk, start_ms = _range_start_ms(range_key)
now_ms = int(time.time() * 1000)
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
except Exception:
pass
rows = conn.execute(
"""
SELECT id, closed_at, created_at, realized_pnl_usdt, realized_pnl, close_index_px
FROM options_trades
WHERE status = 'closed'
ORDER BY COALESCE(closed_at, created_at) ASC, id ASC
"""
).fetchall()
events: list[tuple[int, float]] = []
for r in rows:
pnl_u = _trade_realized_usdt(r)
if pnl_u is None:
continue
ts = _closed_at_to_ms(r["closed_at"]) or _closed_at_to_ms(r["created_at"])
if ts is None:
continue
events.append((ts, float(pnl_u)))
cum = 0.0
all_points: list[dict[str, Any]] = []
for ts, pnl in events:
cum = round(cum + pnl, 4)
all_points.append({"t": int(ts), "v": cum})
baseline = 0.0
series: list[dict[str, Any]] = []
for p in all_points:
if int(p["t"]) < start_ms:
baseline = float(p["v"])
continue
if not series:
series.append({"t": int(start_ms), "v": baseline})
series.append({"t": int(p["t"]), "v": float(p["v"])})
if not series:
# 窗口内无新平仓:画水平线(历史累计或 0)
series = [
{"t": int(start_ms), "v": baseline},
{"t": int(now_ms), "v": baseline},
]
else:
last_v = float(series[-1]["v"])
if int(series[-1]["t"]) < now_ms:
series.append({"t": int(now_ms), "v": last_v})
return {
"ok": True,
"range": rk,
"metric": "realized_pnl",
"points": series,
"start_ms": start_ms,
"end_ms": now_ms,
"latest": series[-1]["v"] if series else None,
"trade_count": len(events),
}
def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
"""兼容旧名:数据统计曲线改为累计已实现盈亏."""
return query_realized_pnl_series_with_db(conn, range_key=range_key)
def sample_once(get_db: Callable, fetch_snapshot: Callable) -> bool:
"""拉取快照并写入采样.
fetch_snapshot 可返回:
- float: 视为权益(兼容旧现金回调)
- dict: {cash_usdt, equity_usdt}
"""
global _last_sample_at
try:
snap = fetch_snapshot()
except Exception:
return False
cash: float | None
equity: float | None
if isinstance(snap, dict):
cash = _safe_float(snap.get("cash_usdt"))
equity = _safe_float(snap.get("equity_usdt"))
else:
cash = _safe_float(snap)
equity = cash
if equity is None and cash is None:
return False
if cash is None:
cash = float(equity or 0.0)
if equity is None:
equity = float(cash)
with _lock:
conn = get_db()
try:
record_cash_sample(conn, float(cash), equity_usdt=float(equity))
conn.commit()
finally:
conn.close()
_last_sample_at = time.time()
return True
def start_cash_sample_loop(get_db: Callable, fetch_snapshot: Callable) -> None:
global _thread
if _thread and _thread.is_alive():
return
_stop.clear()
def _loop() -> None:
try:
sample_once(get_db, fetch_snapshot)
except Exception:
pass
while not _stop.is_set():
if _stop.wait(cash_sample_interval_sec()):
break
try:
sample_once(get_db, fetch_snapshot)
except Exception:
pass
_thread = threading.Thread(target=_loop, daemon=True, name="instance-cash-sample")
_thread.start()
+12 -115
View File
@@ -88,37 +88,8 @@ HEDGE_ACTIVE_STATUSES = frozenset({"opening", "active", "partial"})
def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
"""根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id)."""
default = ("option", OPTIONS_SOURCE_LABELS["option"], None)
if not inst_id or not _table_exists(conn, "hedge_plans") or not _table_exists(conn, "hedge_plan_legs"):
return default
try:
row = conn.execute(
"""
SELECT p.plan_type, p.id
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.status IN ('opening', 'active', 'partial')
AND l.status = 'open'
AND l.inst_id = ?
ORDER BY p.id DESC
LIMIT 1
""",
(inst_id,),
).fetchone()
except Exception:
return default
if not row:
return default
d = _row_dict(row)
pt = str(d.get("plan_type") or "").strip()
try:
plan_id = int(d["id"]) if d.get("id") is not None else None
except (TypeError, ValueError):
plan_id = None
if pt in OPTIONS_SOURCE_LABELS and pt != "option":
return pt, OPTIONS_SOURCE_LABELS[pt], plan_id
return default
"""对冲已移除:持仓一律记为纯期权."""
return ("option", OPTIONS_SOURCE_LABELS["option"], None)
def _format_profit_exit_mult(mult: Any) -> str:
@@ -134,18 +105,7 @@ def _format_profit_exit_mult(mult: 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
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
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()
side = "Put ≤" if ot == "P" else "Call ≥"
tgt = _safe_float(hedge.get("target_index"))
if tgt is not None:
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"))
if tgt is not None and tgt > 0:
@@ -162,12 +122,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 "-"
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")
# 看板期权列:优先买一净盈亏,残档回退交易所 upl
# 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数)
pnl = None
try:
from lib.options.options_positions_lib import display_pnl_from_option_row
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
pnl = display_pnl_from_option_row(p)
pnl = display_pnl_usdt_from_option_row(p)
except Exception:
pnl = None
pos = _safe_float(p.get("pos"))
@@ -201,31 +161,6 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
}
def _format_hedge_item(plan: dict[str, Any]) -> dict[str, Any]:
pid = plan.get("id")
underlying = plan.get("underlying") or "-"
plan_type = plan.get("plan_type") or ""
status = str(plan.get("status") or "")
summary = plan.get("contracts_summary") or ""
plan_type_label = OPTIONS_SOURCE_LABELS.get(plan_type, plan_type)
active = status in HEDGE_ACTIVE_STATUSES
status_label = "进行中" if active else (status or "—")
return {
"id": pid,
"kind": "hedge_plan",
"tab": "hedge_plan",
"title": f"对冲 #{pid} {underlying}",
"subtitle": " · ".join(x for x in (plan_type_label, status_label, summary) if x),
"underlying": underlying,
"plan_type": plan_type,
"plan_type_label": plan_type_label,
"status": status,
"status_label": status_label,
"status_active": active,
"contracts_summary": summary,
}
def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]:
sym = kd.get("exchange_symbol") or kd.get("symbol") or "-"
direction = str(kd.get("direction") or "long").lower()
@@ -302,12 +237,8 @@ def _table_exists(conn, name: str) -> bool:
def collect_orders(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "order_monitors"):
del conn
return []
rows = conn.execute(
"SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall()
return [_format_order_item(_row_dict(r)) for r in rows]
def collect_keys(conn) -> list[dict[str, Any]]:
@@ -330,32 +261,7 @@ def collect_trends(conn) -> list[dict[str, Any]]:
def collect_rolls(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"):
return []
try:
rows = conn.execute(
"""SELECT g.* FROM roll_groups g
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
WHERE g.status='active' ORDER BY g.id DESC"""
).fetchall()
except Exception:
return []
return [_format_roll_item(_row_dict(r)) for r in rows]
def collect_hedge_plans(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "hedge_plans"):
return []
try:
from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans
rows: list[dict[str, Any]] = []
for status in ("opening", "active", "partial"):
rows.extend(list_plans(conn, status=status, limit=80))
rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True)
plans = attach_legs_to_plans(conn, rows)
return [_format_hedge_item(p) for p in plans]
except Exception:
del conn
return []
@@ -372,18 +278,15 @@ def collect_options_items(
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 = {}, {}, {}
pe_map, tgt_map = {}, {}
out: list[dict[str, Any]] = []
for p in raw:
if not isinstance(p, dict):
@@ -397,11 +300,6 @@ def collect_options_items(
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
@@ -531,8 +429,6 @@ def build_instance_dashboard_payload(
orders = collect_orders(conn)
keys = collect_keys(conn)
options_items = collect_options_items(fetch_options_positions, conn=conn)
hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关
# hedge_enabled 仅影响「新建」入口,不隐藏已有仓
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
return {
"ok": True,
@@ -563,9 +459,10 @@ def build_instance_dashboard_payload(
},
"hedge_plan": {
"title": "对冲计划",
"count": len(hedge_items),
"items": hedge_items,
"visible": len(hedge_items) > 0,
"count": 0,
"items": [],
"visible": False,
"tab": "hedge_plan",
"removed": True,
},
}
@@ -0,0 +1,25 @@
"""实例数据统计:复盘 KPI + 已实现盈亏曲线 API."""
from __future__ import annotations
from typing import Any, Callable
from flask import Flask, jsonify, request
def install_instance_data_stats(app: Flask, app_module: Any) -> None:
get_db: Callable = app_module.get_db
login_required = app_module.login_required
@app.route("/api/instance/cash_curve")
@login_required
def api_instance_cash_curve():
from lib.instance.instance_cash_sample_lib import query_realized_pnl_series_with_db
range_key = (request.args.get("range") or "7d").strip()
conn = get_db()
try:
return jsonify(query_realized_pnl_series_with_db(conn, range_key=range_key))
except Exception as e:
return jsonify({"ok": False, "msg": f"加载盈亏曲线失败: {e}"}), 500
finally:
conn.close()
+13 -14
View File
@@ -10,22 +10,22 @@ DISPLAY_RUNTIME_PREFIX = "display."
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_dashboard": False,
"show_nav_account_ledger": False,
"show_nav_key_monitor": True,
"show_nav_trade": False, # 实盘下单界面已移除
"show_nav_key_monitor": True, # 关键位监控保留
"show_nav_trade": False, # 永续实盘下单已下线
"show_nav_strategy": False,
"show_nav_strategy_records": False,
"show_nav_records": False, # 永续交易记录与复盘已移除
"show_nav_stats": False,
"show_nav_stats": True, # 数据统计(复盘 KPI + 资金曲线);可在系统设置关闭
"show_nav_risk_policy": True,
"show_nav_live_guide": True,
"show_nav_system_guide": False,
"show_nav_env_config": True,
"show_nav_options": True,
"show_nav_options_review": True,
"show_nav_hedge_plan": True,
"show_settings_transfer": False, # 永续资金划转默认隐藏
"show_settings_transfer": False, # 永续资金划转已下线
"show_settings_export": True,
"show_settings_password": True,
"show_settings_options_swap": True,
"show_settings_options_swap": False, # USDC 币种兑换已下线
"show_settings_options_transfer": True,
}
@@ -37,13 +37,13 @@ DISPLAY_LABELS: dict[str, str] = {
"show_nav_strategy": "策略交易",
"show_nav_strategy_records": "策略交易记录",
"show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析",
"show_nav_stats": "数据统计",
"show_nav_risk_policy": "风控说明",
"show_nav_live_guide": "实盘说明",
"show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置",
"show_nav_options": "期权",
"show_nav_options_review": "期权复盘",
"show_nav_hedge_plan": "对冲计划",
"show_settings_transfer": "永续资金划转",
"show_settings_export": "数据导出",
"show_settings_password": "账户密码修改",
@@ -61,11 +61,11 @@ NAV_TAB_ALLOWED: dict[str, str] = {
"records": "show_nav_records",
"stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy",
"live_guide": "show_nav_live_guide",
"system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config",
"options": "show_nav_options",
"options_review": "show_nav_options_review",
"hedge_plan": "show_nav_hedge_plan",
}
@@ -111,8 +111,8 @@ def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
prefs = normalize_display_prefs(display or {})
t = (tab or "").strip()
if t in ("trade", "records", "stats"):
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除
if t in ("trade", "records", "hedge_plan"):
return False # 永续下单/记录已下线;关键位监控保留
key = NAV_TAB_ALLOWED.get(t)
if not key:
return True
@@ -124,18 +124,17 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
"show_nav_dashboard",
"show_nav_account_ledger",
"show_nav_key_monitor",
"show_nav_stats",
"show_nav_risk_policy",
"show_nav_live_guide",
"show_nav_system_guide",
"show_nav_env_config",
"show_nav_options",
"show_nav_options_review",
"show_nav_hedge_plan",
]
settings_keys = [
"show_settings_transfer",
"show_settings_export",
"show_settings_password",
"show_settings_options_swap",
"show_settings_options_transfer",
]
return [
+11 -3
View File
@@ -62,7 +62,7 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
key_history=page == "key_monitor",
key_list=page in ("key_monitor", "trade") or is_strategy,
orders=page == "trade" or is_strategy,
stats_bundle=page == "stats",
stats_bundle=False, # 旧永续统计已弃用;数据统计页走复盘 API + 资金曲线
strategy=is_strategy,
orphan_live=page == "trade" and is_shell,
)
@@ -161,10 +161,18 @@ def trading_account_label(
parts.append(f"{float(usdt):.2f} USDT")
except (TypeError, ValueError):
pass
eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
# 低于现货最小可卖的粉尘不展示(与卖回逻辑一致)
try:
from lib.options.options_spot_bridge_lib import spot_min_sell_sz
eth_min = spot_min_sell_sz(None, "ETH")
btc_min = spot_min_sell_sz(None, "BTC")
except Exception:
eth_min, btc_min = 0.0001, 0.00001
eth_txt = _fmt_coin_amount(eth, min_amt=eth_min)
if eth_txt is not None:
parts.append(f"{eth_txt} ETH")
btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
btc_txt = _fmt_coin_amount(btc, min_amt=btc_min)
if btc_txt is not None:
parts.append(f"{btc_txt} BTC")
return "\n".join(parts) if parts else "—"
+14 -9
View File
@@ -16,8 +16,9 @@ EMBED_TABS: tuple[str, ...] = (
"key_monitor",
"options",
"options_review",
"hedge_plan",
"stats",
"risk_policy",
"live_guide",
"system_guide",
"env_config",
"settings",
@@ -27,14 +28,15 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "options",
"/trade": "options", # 实盘下单界面已移除
"/records": "options_review", # 永续交易记录与复盘已移除
"/stats": "options",
"/stats": "stats",
"/dashboard": "dashboard",
"/account_ledger": "account_ledger",
"/key_monitor": "key_monitor",
"/options": "options",
"/options/review": "options_review",
"/hedge-plan": "hedge_plan",
"/hedge-plan": "options", # 对冲计划已移除,旧链接落到期权
"/risk_policy": "risk_policy",
"/live_guide": "live_guide",
"/system_guide": "system_guide",
"/env_config": "env_config",
"/settings": "settings",
@@ -53,8 +55,9 @@ def order_rule_tips_template(exchange_key: str) -> str:
def include_transfer_block(exchange_key: str) -> bool:
"""三所 standalone / embed 壳均在顶栏展示划转区块."""
return (exchange_key or "").strip().lower() in ORDER_RULE_TIPS_BY_EXCHANGE
"""永续划转区块已下线."""
del exchange_key
return False
def ui_open_guard_enabled(exchange_key: str) -> bool:
@@ -79,16 +82,18 @@ def embed_shell_enabled() -> bool:
_SETTINGS_SUB_TABS = frozenset(
{"nav", "password", "transfer", "export", "options_swap", "options_transfer"}
{"nav", "password", "transfer", "export", "backup", "options_swap", "options_transfer"}
)
def redirect_to_embed_shell_if_enabled(page: str):
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换)."""
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换).
注意:中控 iframe 会给导航链加 embed=1;若因此跳过重定向,会落到 index 整页,
顶栏链接变成 3 列 grid 子项而变形.embed=1 时仍应进 /embed 壳.
"""
if not embed_shell_enabled():
return None
if (request.args.get("embed") or "").strip() == "1":
return None
if (request.path or "").rstrip("/") == "/embed":
return None
q = {k: v for k, v in request.args.items()}
+68
View File
@@ -0,0 +1,68 @@
"""实例「实盘说明」:加载 Markdown,生成 h2 目录与带锚点正文."""
from __future__ import annotations
import re
from functools import lru_cache
from html import escape
from pathlib import Path
from typing import Any
from lib.common.markdown_html_lib import render_markdown_html
from lib.paths import REPO_ROOT
def live_guide_md_path() -> Path:
return REPO_ROOT / "docs" / "实盘说明.md"
def _slugify(text: str) -> str:
raw = re.sub(r"<[^>]+>", "", text or "")
raw = re.sub(r"\s+", "-", raw.strip())
raw = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", raw)
return raw[:80] or "section"
def _inject_h2_ids(html: str) -> tuple[str, list[dict[str, str]]]:
toc: list[dict[str, str]] = []
used: dict[str, int] = {}
def repl(m: re.Match[str]) -> str:
inner = m.group(1)
base = _slugify(inner)
n = used.get(base, 0) + 1
used[base] = n
hid = base if n == 1 else f"{base}-{n}"
toc.append({"id": hid, "title": re.sub(r"<[^>]+>", "", inner).strip()})
return f'<h2 id="{escape(hid)}">{inner}</h2>'
out = re.sub(r"<h2>(.*?)</h2>", repl, html, flags=re.I | re.S)
return out, toc
@lru_cache(maxsize=4)
def _load_payload_cached(mtime_ns: int, path_str: str) -> dict[str, Any]:
path = Path(path_str)
try:
md_text = path.read_text(encoding="utf-8")
except OSError:
md_text = "# 实盘说明缺失\n\n未找到 `docs/实盘说明.md`。"
body = render_markdown_html(md_text)
body, toc = _inject_h2_ids(body)
return {"html": body, "toc": toc, "mtime_ns": mtime_ns}
def load_live_guide_payload() -> dict[str, Any]:
path = live_guide_md_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
return dict(_load_payload_cached(mtime_ns, str(path)))
def live_guide_template_context() -> dict[str, Any]:
payload = load_live_guide_payload()
return {
"live_guide_html": payload.get("html") or "",
"live_guide_toc": payload.get("toc") or [],
}
+6 -12
View File
@@ -127,20 +127,10 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[
tabs.append({"key": "sim_funds", "title": "模拟资金"})
if disp.get("show_settings_password", True):
tabs.append({"key": "password", "title": "账户密码"})
if inst.get("show_transfer") and disp.get("show_settings_transfer", False):
tabs.append({"key": "transfer", "title": "永续划转"})
# 永续划转 / USDC 币种兑换已下线
if disp.get("show_settings_export", True):
tabs.append({"key": "export", "title": "数据导出"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True):
_coin = False
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
_coin = bool(is_coin_margin_mode())
except Exception:
_coin = False
if not _coin:
tabs.append({"key": "options_swap", "title": "币种兑换"})
tabs.append({"key": "backup", "title": "备份恢复"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True):
tabs.append({"key": "options_transfer", "title": "期权划转"})
return tabs
@@ -152,6 +142,10 @@ def settings_page_context(page: str, *, instance_base_dir: str | None = None, **
from lib.instance.instance_system_guide_lib import system_guide_template_context
return system_guide_template_context()
if p == "live_guide":
from lib.instance.instance_live_guide_lib import live_guide_template_context
return live_guide_template_context()
if p not in ("settings", "risk_policy", "env_config"):
return {}
display = kwargs.pop("display", None)
+85 -20
View File
@@ -1,13 +1,13 @@
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启,备份恢复."""
from __future__ import annotations
import os
from functools import wraps
from typing import Any, Callable
from flask import jsonify, request, session
from flask import jsonify, request, send_file, session
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
from lib.env.env_file_lib import apply_env_updates
from lib.env.env_ui_manifest import (
build_env_ui_payload,
filter_updates_for_ui,
@@ -44,6 +44,17 @@ def _api_login_required(hub_token_write_allowed: bool = False):
# crypto_okx 可无 lib.hub;鉴权与中控 request_allowed 对齐
allowed = bool(auth_disabled or logged_in)
if not allowed:
try:
from lib.hub.hub_bridge_auth import hub_embed_sso_ok
if hub_embed_sso_ok(request):
session["logged_in"] = True
session.permanent = True
session.modified = True
allowed = True
except Exception:
pass
if not allowed and hub_hdr and bridge and hub_hdr == bridge:
allowed = True
if not allowed:
@@ -72,10 +83,14 @@ def register_instance_settings_routes(
exchange_key: str,
username: str,
password: str,
db_path: str | None = None,
upload_folder: str | None = None,
) -> None:
env_path = os.path.join(base_dir, ".env")
example_path = os.path.join(base_dir, ".env.example")
api_auth = _api_login_required()
resolved_db = (db_path or "").strip() or os.path.join(base_dir, "crypto.db")
resolved_upload = (upload_folder or "").strip() or os.path.join(base_dir, "static", "images")
@app.route("/api/settings/display", methods=["GET", "POST"])
@api_auth
@@ -118,23 +133,8 @@ def register_instance_settings_routes(
if not clean:
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
# 币本位专用机:忽略任何写入,强制 coin
clean["OKX_OPTIONS_MARGIN_MODE"] = "coin"
changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups)
@@ -179,6 +179,71 @@ def register_instance_settings_routes(
def api_admin_health():
return jsonify({"ok": True, "status": "up"})
@app.route("/api/settings/backup/list", methods=["GET"])
@api_auth
def api_settings_backup_list():
from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
return jsonify(
{
"ok": True,
"items": list_backups(),
"backup_dir": backup_dir(),
"keep_days": backup_keep_days(),
"auto_hour_bj": 3,
"includes": [".env", "database", "options_journal"],
}
)
@app.route("/api/settings/backup/create", methods=["POST"])
@api_auth
def api_settings_backup_create():
from lib.instance.backup_lib import create_backup
try:
result = create_backup(
base_dir=base_dir,
db_path=resolved_db,
upload_folder=resolved_upload,
label="manual",
)
return jsonify(result)
except Exception as exc:
return jsonify({"ok": False, "msg": str(exc) or "备份失败"}), 500
@app.route("/api/settings/backup/download/<name>", methods=["GET"])
@api_auth
def api_settings_backup_download(name: str):
from lib.instance.backup_lib import backup_path
path = backup_path(name)
if not path or not os.path.isfile(path):
return jsonify({"ok": False, "msg": "备份不存在"}), 404
return send_file(
path,
mimetype="application/gzip",
as_attachment=True,
download_name=os.path.basename(path),
)
@app.route("/api/settings/backup/restore", methods=["POST"])
@api_auth
def api_settings_backup_restore():
from lib.instance.backup_lib import restore_backup
body = request.get_json(silent=True) or {}
name = str(body.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "msg": "缺少备份文件名"}), 400
result = restore_backup(
name,
base_dir=base_dir,
db_path=resolved_db,
upload_folder=resolved_upload,
)
code = 200 if result.get("ok") else 400
return jsonify(result), code
def tab_allowed_fn(tab: str) -> bool:
prefs = get_display_prefs(get_db)
return tab_allowed(tab, prefs)
+3 -1
View File
@@ -28,10 +28,12 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"journal_form_save.js": "application/javascript; charset=utf-8",
"instance_page.css": "text/css; charset=utf-8",
"instance_embed.js": "application/javascript; charset=utf-8",
"hub_embed_auth.js": "application/javascript; charset=utf-8",
"instance_mobile_nav.js": "application/javascript; charset=utf-8",
"instance_stats.js": "application/javascript; charset=utf-8",
"instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
"instance_backup.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",
@@ -40,7 +42,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"options_settings.js": "application/javascript; charset=utf-8",
"options_position_cards.js": "application/javascript; charset=utf-8",
"options_review.js": "application/javascript; charset=utf-8",
"hedge_plan.js": "application/javascript; charset=utf-8",
"options_data_stats.js": "application/javascript; charset=utf-8",
"order_entry_model.js": "application/javascript; charset=utf-8",
"focus_chart_page.js": "application/javascript; charset=utf-8",
"focus_chart_page.css": "text/css; charset=utf-8",
+124
View File
@@ -0,0 +1,124 @@
"""客户端远程快速更新:git pull + pm2 restart(供中控 Bridge 触发)."""
from __future__ import annotations
import os
import shlex
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
from lib.paths import REPO_ROOT
_update_lock = threading.Lock()
_update_pending_until = 0.0
def _repo_root() -> Path:
env_root = (os.getenv("REPO") or "").strip()
if env_root:
return Path(env_root)
return Path(REPO_ROOT)
def _run_git(args: list[str], *, cwd: Path) -> tuple[bool, str]:
try:
proc = subprocess.run(
["git", *args],
cwd=str(cwd),
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
return False, "未找到 git"
except subprocess.TimeoutExpired:
return False, "git 超时"
except Exception as e:
return False, str(e) or "git 失败"
out = (proc.stdout or "").strip() or (proc.stderr or "").strip()
return proc.returncode == 0, out
def read_git_version() -> dict[str, Any]:
"""读取当前部署目录 git 版本信息."""
root = _repo_root()
out: dict[str, Any] = {
"ok": True,
"repo": str(root),
"sha": "",
"short_sha": "",
"branch": "",
"dirty": False,
"msg": "",
}
if not (root / ".git").exists() and not (root / ".git").is_file():
# worktree 时 .git 可能是文件
out["ok"] = False
out["msg"] = "非 git 仓库"
return out
ok, sha = _run_git(["rev-parse", "HEAD"], cwd=root)
if not ok or not sha:
out["ok"] = False
out["msg"] = sha or "无法读取 HEAD"
return out
out["sha"] = sha
out["short_sha"] = sha[:7]
ok_b, branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
if ok_b and branch:
out["branch"] = branch
ok_d, dirty = _run_git(["status", "--porcelain"], cwd=root)
if ok_d:
out["dirty"] = bool(dirty.strip())
return out
def schedule_pull_and_restart(*, delay_seconds: float = 1.5) -> dict[str, Any]:
"""延迟执行 deploy/pull_and_restart.sh,便于 Bridge 响应先返回."""
if not sys.platform.startswith("linux"):
return {"ok": False, "msg": "仅 Linux 服务器支持远程更新", "deferred": False}
root = _repo_root()
script = root / "deploy" / "pull_and_restart.sh"
if not script.is_file():
return {"ok": False, "msg": f"缺少更新脚本: {script}", "deferred": False}
global _update_pending_until
now = time.time()
with _update_lock:
if now < _update_pending_until:
return {"ok": False, "msg": "已有更新任务进行中,请稍后再试", "deferred": False}
_update_pending_until = now + 120.0
version = read_git_version()
try:
delay = max(0.5, float(delay_seconds))
cmd = (
f"sleep {delay} && "
f"cd {shlex.quote(str(root))} && "
f"exec bash {shlex.quote(str(script))}"
)
subprocess.Popen(
["bash", "-c", cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
cwd=str(root),
)
return {
"ok": True,
"msg": "更新已受理,即将 git pull 并重启",
"deferred": True,
"before_sha": version.get("sha") or "",
"before_short_sha": version.get("short_sha") or "",
"branch": version.get("branch") or "",
}
except FileNotFoundError:
with _update_lock:
_update_pending_until = 0.0
return {"ok": False, "msg": "未找到 bash", "deferred": False}
except Exception as e:
with _update_lock:
_update_pending_until = 0.0
return {"ok": False, "msg": str(e) or "触发更新失败", "deferred": False}
+7
View File
@@ -71,6 +71,13 @@ def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], g
set_trading_mode(get_db, raw)
except Exception:
pass
# 清余额缓存,避免模拟盘资金残留到实盘顶栏
try:
from app import invalidate_account_balance_cache
invalidate_account_balance_cache()
except Exception:
pass
from lib.env.env_schema import updates_need_restart
return {"restart_required": updates_need_restart(groups, changed_keys)}
@@ -0,0 +1,37 @@
{# 备份恢复:本地 /root/crypto_okx_backups,凌晨3点自动,保留10天 #}
<div class="backup-settings" data-backup-root="1">
<h2>备份恢复</h2>
<p class="settings-subcard-desc muted">
含 <code>.env</code>、数据库、期权复盘图片。每天北京时间 <strong>03:00</strong> 自动备份,保留最近
<strong>10</strong> 天。目录:
<code id="backup-dir-label">/root/crypto_okx_backups</code>
</p>
<div class="settings-export-links-inline settings-export-links-block" style="gap:8px;margin-bottom:12px">
<button type="button" class="btn" id="backup-create-btn">立即备份</button>
<button type="button" class="btn-secondary" id="backup-refresh-btn">刷新列表</button>
<span class="muted" id="backup-status" style="font-size:.78rem"></span>
</div>
<div class="table-wrap">
<table class="or-trades-table" style="width:100%;font-size:.8rem">
<thead>
<tr>
<th>文件</th>
<th>时间(北京)</th>
<th>大小</th>
<th>操作</th>
</tr>
</thead>
<tbody id="backup-list-body">
<tr><td colspan="4" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
<script src="/static/instance_backup.js?v=1"></script>
<script>
(function () {
if (window.InstanceBackup && typeof window.InstanceBackup.boot === "function") {
window.InstanceBackup.boot();
}
})();
</script>
+44 -12
View File
@@ -1158,7 +1158,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
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;
// 低于 OKX 现货常见 minSz 的粉尘不展示
const minSz = ccy === "BTC" ? 0.00001 : 0.0001;
if (Number.isNaN(n) || !(n >= minSz)) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
@@ -1200,7 +1202,23 @@ function accountSnapshotFundingMissing(data){
const hasTrading = data.current_capital != null && data.current_capital !== "";
return !hasFunding && !hasTotal && !hasTrading;
}
let accountSnapshotRetryCount = 0;
let accountSnapshotMissRetryTimer = null;
let accountSnapshotMissRetryDone = false;
function clearAccountSnapshotMissRetry(){
accountSnapshotMissRetryDone = false;
if(accountSnapshotMissRetryTimer){
clearTimeout(accountSnapshotMissRetryTimer);
accountSnapshotMissRetryTimer = null;
}
}
function scheduleAccountSnapshotMissRetry(){
if(accountSnapshotMissRetryDone || accountSnapshotMissRetryTimer) return;
accountSnapshotMissRetryTimer = setTimeout(function(){
accountSnapshotMissRetryTimer = null;
accountSnapshotMissRetryDone = true;
refreshAccountSnapshot({ silent: true, force: true, missRetry: true });
}, 10000);
}
function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
@@ -1212,9 +1230,13 @@ function applyAccountSnapshot(data){
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} else if("funding_usdt" in data){
setFundsFieldText("total-capital", "—");
}
if(data.total_funds != null && data.total_funds !== ""){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} else if("total_funds" in data){
setFundsFieldText("total-funds", "—");
}
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText(
@@ -1226,6 +1248,16 @@ function applyAccountSnapshot(data){
data.options_margin_mode
)
);
} else if("current_capital" in data){
setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
null,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
}
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(
@@ -1315,20 +1347,20 @@ function applyAccountSnapshot(data){
}
function refreshAccountSnapshot(opts){
const options = opts || {};
const qs = options.force ? "?force=1" : "";
let useForce = !!options.force;
if(useForce && !options.missRetry && (accountSnapshotMissRetryTimer || accountSnapshotMissRetryDone)){
useForce = false;
}
const qs = useForce ? "?force=1" : "";
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount);
}else if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount = 0;
if(!accountSnapshotFundingMissing(data)){
clearAccountSnapshotMissRetry();
return;
}
scheduleAccountSnapshotMissRetry();
}).catch(()=>{
if(!options.silent && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
}
scheduleAccountSnapshotMissRetry();
});
}
@@ -127,8 +127,8 @@
{% include 'options_panel.html' %}
{% elif page == 'options_review' %}
{% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %}
{% include 'hedge_plan_panel.html' %}
{% elif page == 'stats' %}
{% include 'options_data_stats_panel.html' %}
{% endif %}
@@ -145,6 +145,10 @@
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'live_guide' %}
{% include 'live_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
+16 -16
View File
@@ -3,12 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=51"></script>
<script src="/static/hub_embed_auth.js?v=1"></script>
<script src="/static/instance_theme.js?v=52"></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/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=21">
<link rel="stylesheet" href="/static/instance_theme.css?v=124">
<link rel="stylesheet" href="/static/instance_page.css?v=26">
<link rel="stylesheet" href="/static/instance_theme.css?v=129">
<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">
@@ -35,7 +36,7 @@
>
<div class="container">
<div class="header">
<h1>加密货币|OKX 期权与对冲</h1>
<h1>加密货币|OKX 期权</h1>
</div>
<nav class="top-nav embed-top-nav" aria-label="实例导航">
<div class="top-nav-links">
@@ -48,12 +49,13 @@
{% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" data-embed-tab="options_review" class="{% if initial_tab == 'options_review' %}active{% endif %}">期权复盘</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" data-embed-tab="hedge_plan" class="{% if initial_tab == 'hedge_plan' %}active{% endif %}">对冲计划</a>
{% endif %}
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
{% if display.get('show_nav_live_guide', true) %}
<a href="/live_guide" data-embed-tab="live_guide" class="{% if initial_tab == 'live_guide' %}active{% endif %}">实盘说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide" class="{% if initial_tab == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
@@ -65,7 +67,7 @@
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
{% include 'instance_header_panel.html' %}
{% if initial_tab not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options_review') and include_transfer_block %}
{% if initial_tab not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options_review', 'stats') and include_transfer_block %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -97,9 +99,6 @@
{% if options_nav_visible and display.show_nav_options %}
<a href="/options" class="inst-m-tab{% if initial_tab == 'options' %} active{% endif %}" data-embed-tab="options">期权</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" class="inst-m-tab{% if initial_tab == 'hedge_plan' %} active{% endif %}" data-embed-tab="hedge_plan">对冲</a>
{% endif %}
<a href="/key_monitor" class="inst-m-tab{% if initial_tab == 'key_monitor' %} active{% endif %}" data-embed-tab="key_monitor"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位</a>
<button type="button" class="inst-m-tab" data-embed-tab="more" id="inst-m-tab-more" aria-haspopup="dialog" aria-expanded="false">更多</button>
</nav>
@@ -115,12 +114,13 @@
{% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" data-embed-tab="options_review">期权复盘</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" data-embed-tab="hedge_plan">对冲计划</a>
{% endif %}
<a href="/stats" data-embed-tab="stats"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy">风控说明</a>
{% endif %}
{% if display.get('show_nav_live_guide', true) %}
<a href="/live_guide" data-embed-tab="live_guide">实盘说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" data-embed-tab="env_config">env配置</a>
@@ -144,7 +144,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
</script>
<script src="/static/order_entry_model.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=3"></script>
<script src="/static/key_monitor_form.js?v=2"></script>
<script src="/static/instance_stats.js?v=5"></script>
{% include 'embed_boot_scripts.html' %}
@@ -157,7 +157,7 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=24"></script>
<script src="/static/instance_live.js?v=7"></script>
<script src="/static/instance_embed.js?v=32"></script>
<script src="/static/instance_embed.js?v=33"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script>
</body>
</html>
+11 -5
View File
@@ -16,11 +16,7 @@
</div>
{% if env_config_groups %}
{% 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 }}">
<div class="env-config-body card" data-env-ssr="1" id="env-config-body">
{% 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 %}>
{% endfor %}
@@ -77,6 +73,7 @@
onfocus="this.removeAttribute('readonly')"
>
{% else %}
<div class="env-field-input-wrap{% if field.key == 'HUB_BRIDGE_TOKEN' %} env-field-input-wrap--copy{% endif %}">
<input
class="env-field-input"
id="env-f-{{ field.key }}"
@@ -92,6 +89,15 @@
data-bwignore="true"
data-form-type="other"
>
{% if field.key == 'HUB_BRIDGE_TOKEN' %}
<button
type="button"
class="btn-secondary btn-sm env-copy-btn"
data-env-copy="HUB_BRIDGE_TOKEN"
title="复制 Token"
>复制</button>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
@@ -1,8 +0,0 @@
{% if force_close.enabled %}
<span class="force-close-badge" id="force-close-header-badge" role="status"
title="北京时间 {{ force_close.hour_label }} 整点未平仓将市价强制清仓(result=强制清仓)"
data-force-close-at-ms="{{ force_close.next_at_ms or '' }}"
data-force-close-active="{{ '1' if force_close.active else '0' }}">
{{ force_close.label }} 已开启 · <span class="force-close-header-cd">{{ force_close.countdown or '--:--:--' }}</span>
</span>
{% endif %}
@@ -1,8 +0,0 @@
{% if force_close.enabled %}
<span class="pos-symbol-force-close pos-force-close-meta" id="order-force-close-wrap-{{ o.id }}"
data-force-close-at-ms="{{ o.force_close_at_ms or force_close.next_at_ms or '' }}"
data-force-close-active="{{ '1' if (o.force_close_active or force_close.active) else '0' }}">
<span class="pos-force-close-label">{{ o.force_close_label or force_close.label }}</span>
· <span class="pos-force-close-cd" id="order-force-close-cd-{{ o.id }}">{{ o.force_close_countdown or force_close.countdown or '--:--:--' }}</span>
</span>
{% endif %}
@@ -1,26 +0,0 @@
<details class="tip-collapse gate-top-tips-collapse">
<summary class="tip-collapse-summary">
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)
<span class="tip-collapse-hint">· 划转规则</span>
</summary>
<div class="tip-collapse-body rule-tip gate-transfer-tip">
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};<strong>持仓中不划转</strong>并微信通知)
</div>
</details>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
</select>
<button type="submit">手动划转</button>
</form>
+77 -86
View File
@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=50"></script>
<script src="/static/instance_theme.js?v=52"></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/account_risk_badge.css?v=4">
@@ -18,8 +18,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=21">
<link rel="stylesheet" href="/static/instance_theme.css?v=118">
<link rel="stylesheet" href="/static/instance_page.css?v=26">
<link rel="stylesheet" href="/static/instance_theme.css?v=129">
</head>
<body
@@ -153,45 +153,46 @@
<div class="header">
<h1>加密货币|交易监控 + AI复盘一体化</h1>
</div>
<div class="top-nav">
<nav class="top-nav" aria-label="实例导航">
<div class="top-nav-links">
<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="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if page == '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 page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
{% if not intraday_discipline and display.show_nav_strategy %}
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
<a href="/strategy" data-embed-tab="strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
{% endif %}
{% if not intraday_discipline and display.show_nav_strategy_records %}
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
{% endif %}
{% if display.show_nav_records %}
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
{% endif %}
{% if display.show_nav_stats %}
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
<a href="/records" data-embed-tab="records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
{% endif %}
{% if options_nav_visible and display.show_nav_options %}
<a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a>
<a href="/options" data-embed-tab="options" class="{% if page == 'options' %}active{% endif %}">期权</a>
{% endif %}
{% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" class="{% if page == 'hedge_plan' %}active{% endif %}">对冲计划</a>
<a href="/options/review" data-embed-tab="options_review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a>
{% endif %}
<a href="/stats" data-embed-tab="stats" class="{% if page == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
<a href="/system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.get('show_nav_live_guide', true) %}
<a href="/live_guide" data-embed-tab="live_guide" class="{% if page == 'live_guide' %}active{% endif %}">实盘说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
<a href="/env_config" data-embed-tab="env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
{% endif %}
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
<a href="/settings" data-embed-tab="settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
</div>
{% include 'nav_spot_tickers.html' %}
</nav>
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %}
{% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% if page not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options', 'options_review', 'stats') %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -205,16 +206,8 @@
{% elif page == 'trade' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">实盘下单监控</h2>
{% if focus_order_id %}
<a href="/order_focus?order_id={{ focus_order_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
{% else %}
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
{% endif %}
</div>
{% include order_rule_tips_tpl %}
{% include 'order_monitor_open_form.html' %}
<h2 style="margin-bottom:8px">实盘下单监控</h2>
<p class="muted">永续下单已下线,请使用「期权」页。</p>
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
@@ -252,7 +245,6 @@
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
</span>
{% endif %}
{% include 'force_close_order_badge.html' %}
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div>
<div class="pos-head-actions">
@@ -365,8 +357,8 @@
{% include 'options_panel.html' %}
{% elif page == 'options_review' %}
{% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %}
{% include 'hedge_plan_panel.html' %}
{% elif page == 'stats' %}
{% include 'options_data_stats_panel.html' %}
{% endif %}
@@ -387,48 +379,14 @@
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'live_guide' %}
{% include 'live_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
{% if page == 'stats' %}
<div class="card stats-card full" id="stats-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
<h2 style="margin-bottom:0">数据统计</h2>
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
</div>
<div class="stats-content" id="stats-content">
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计):
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong> 次
</div>
<div class="form-row" style="margin-bottom:14px;align-items:center">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
统计品类
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
{% for seg in stats_bundle.segments %}
<option value="{{ seg.key }}">{{ seg.title }}</option>
{% endfor %}
</select>
</label>
</div>
{% for seg in stats_bundle.segments %}
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
<div class="stats-period-tabs" role="tablist" aria-label="统计周期">
<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="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>
{{ period_stats_pane("day", seg.day) }}
{{ period_stats_pane("week", seg.week) }}
{{ period_stats_pane("month", seg.month) }}
{{ period_stats_pane("all", seg.all) }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="modal" id="imgModal" onclick="closeModal()">
@@ -463,7 +421,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
</script>
<script src="/static/order_entry_model.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=3"></script>
<script src="/static/strategy_roll.js?v=6"></script>
<script src="/static/instance_stats.js?v=5"></script>
<script>
@@ -1639,7 +1597,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
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;
// 低于 OKX 现货常见 minSz 的粉尘不展示
const minSz = ccy === "BTC" ? 0.00001 : 0.0001;
if (Number.isNaN(n) || !(n >= minSz)) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
@@ -1681,7 +1641,23 @@ function accountSnapshotFundingMissing(data){
const hasTrading = data.current_capital != null && data.current_capital !== "";
return !hasFunding && !hasTotal && !hasTrading;
}
let accountSnapshotRetryCount = 0;
let accountSnapshotMissRetryTimer = null;
let accountSnapshotMissRetryDone = false;
function clearAccountSnapshotMissRetry(){
accountSnapshotMissRetryDone = false;
if(accountSnapshotMissRetryTimer){
clearTimeout(accountSnapshotMissRetryTimer);
accountSnapshotMissRetryTimer = null;
}
}
function scheduleAccountSnapshotMissRetry(){
if(accountSnapshotMissRetryDone || accountSnapshotMissRetryTimer) return;
accountSnapshotMissRetryTimer = setTimeout(function(){
accountSnapshotMissRetryTimer = null;
accountSnapshotMissRetryDone = true;
refreshAccountSnapshot({ silent: true, force: true, missRetry: true });
}, 10000);
}
function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
@@ -1693,9 +1669,13 @@ function applyAccountSnapshot(data){
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} else if("funding_usdt" in data){
setFundsFieldText("total-capital", "—");
}
if(data.total_funds != null && data.total_funds !== ""){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} else if("total_funds" in data){
setFundsFieldText("total-funds", "—");
}
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText(
@@ -1707,6 +1687,16 @@ function applyAccountSnapshot(data){
data.options_margin_mode
)
);
} else if("current_capital" in data){
setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
null,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
}
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(
@@ -1805,20 +1795,20 @@ function applyAccountSnapshot(data){
}
function refreshAccountSnapshot(opts){
const options = opts || {};
const qs = options.force ? "?force=1" : "";
let useForce = !!options.force;
if(useForce && !options.missRetry && (accountSnapshotMissRetryTimer || accountSnapshotMissRetryDone)){
useForce = false;
}
const qs = useForce ? "?force=1" : "";
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount);
}else if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount = 0;
if(!accountSnapshotFundingMissing(data)){
clearAccountSnapshotMissRetry();
return;
}
scheduleAccountSnapshotMissRetry();
}).catch(()=>{
if(!options.silent && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
}
scheduleAccountSnapshotMissRetry();
});
}
@@ -2082,6 +2072,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
</script>
<script src="/static/records_review_page.js?v=4"></script>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/nav_spot_tickers.js?v=3"></script>
<script src="/static/instance_dashboard.js?v=5"></script>
<script src="/static/account_ledger.js?v=1"></script>
<script>
@@ -28,10 +28,12 @@
{% if trade_policy.badge_text %}
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
{% endif %}
{% include 'force_close_header_badge.html' %}
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
</div>
{% include 'instance_theme_toggle.html' %}
{% if show_logout|default(true) %}
<a href="/logout" class="instance-logout-btn" title="退出登录">退出</a>
{% endif %}
</div>
</div>
<div class="instance-header-stats-wrap instance-desktop-only">
@@ -1,28 +0,0 @@
{# 系统设置 · 资金划转(三所共用) #}
<div class="settings-transfer-panel">
<p class="rule-tip settings-transfer-auto">
自动划转 <strong>{{ '开启' if auto_transfer_enabled else '关闭' }}</strong>:
每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong> 起该整点小时内尝试;
账簿按 <strong>UTC 自然日</strong> 去重;
将 <code>{{ auto_transfer_to }}</code> 调整至 <strong>{{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U</strong>:
不足从 <code>{{ auto_transfer_from }}</code> 划入,超出划回 <code>{{ auto_transfer_from }}</code>;
<strong>持仓中不划转</strong>并微信通知.
</p>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" id="manual-xfer-amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" aria-label="划出账户" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account" aria-label="划入账户" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
</select>
<button type="submit">执行手动划转</button>
</form>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 关键位放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=50">
<script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/symbol_live_price.js?v=3"></script>
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page" data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}">
@@ -140,8 +140,10 @@
{% endif %}
</div>
<form id="key-form" action="/add_key" method="post" class="form-row">
{% from 'trade_policy_fields.html' import trade_policy_symbol with context %}
{{ trade_policy_symbol('symbol', 'key-symbol') }}
<select name="symbol" id="key-symbol" required class="trade-policy-symbol-select" title="仅 BTC/ETH,与期权标的一致">
<option value="BTC/USDT" selected>BTC/USDT</option>
<option value="ETH/USDT">ETH/USDT</option>
</select>
<select name="type" id="key-type-select" required>
<option value="关键支撑阻力" selected>关键支撑阻力</option>
</select>
@@ -0,0 +1,87 @@
{# 实盘说明: docs/实盘说明.md + h2 目录 #}
<div class="system-guide-page full">
<div class="card system-guide-card">
<div class="system-guide-head">
<h2 style="margin:0">实盘说明</h2>
<p class="muted" style="margin:6px 0 0;font-size:.85rem">OKX API、账户模式与实例 env。可在系统设置 → 导航显示中关闭顶栏入口。</p>
</div>
<div class="system-guide-layout">
{% if live_guide_toc %}
<aside class="system-guide-toc" aria-label="章节目录">
<div class="system-guide-toc-title">目录</div>
<nav>
{% for item in live_guide_toc %}
<a href="#{{ item.id }}">{{ item.title }}</a>
{% endfor %}
</nav>
</aside>
{% endif %}
<article class="system-guide-body prose">
{{ live_guide_html|safe }}
</article>
</div>
</div>
</div>
<style>
.system-guide-page { grid-column: 1 / -1; }
.system-guide-card { padding: 16px 18px 28px; }
.system-guide-layout {
display: grid;
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
gap: 18px;
margin-top: 14px;
align-items: start;
}
.system-guide-toc {
position: sticky;
top: 8px;
padding: 10px 12px;
border: 1px solid rgba(127,127,127,.25);
border-radius: 8px;
background: rgba(127,127,127,.06);
max-height: calc(100vh - 120px);
overflow: auto;
}
.system-guide-toc-title {
font-size: .78rem;
font-weight: 600;
opacity: .75;
margin-bottom: 8px;
}
.system-guide-toc a {
display: block;
font-size: .84rem;
line-height: 1.35;
padding: 5px 0;
text-decoration: none;
color: inherit;
opacity: .9;
}
.system-guide-toc a:hover { opacity: 1; text-decoration: underline; }
.system-guide-body { min-width: 0; line-height: 1.65; font-size: .92rem; }
.system-guide-body h1 { font-size: 1.35rem; margin: 0 0 12px; }
.system-guide-body h2 { font-size: 1.12rem; margin: 22px 0 10px; padding-top: 4px; scroll-margin-top: 12px; }
.system-guide-body h3 { font-size: 1rem; margin: 16px 0 8px; }
.system-guide-body p, .system-guide-body li { margin: 0 0 8px; }
.system-guide-body ul, .system-guide-body ol { padding-left: 1.35em; margin: 0 0 10px; }
.system-guide-body table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: .86rem; }
.system-guide-body th, .system-guide-body td {
border: 1px solid rgba(127,127,127,.35);
padding: 7px 9px;
text-align: left;
vertical-align: top;
}
.system-guide-body code {
font-family: ui-monospace, Consolas, monospace;
font-size: .86em;
padding: 1px 4px;
border-radius: 4px;
background: rgba(127,127,127,.12);
}
.system-guide-body hr { border: 0; border-top: 1px solid rgba(127,127,127,.28); margin: 18px 0; }
@media (max-width: 820px) {
.system-guide-layout { grid-template-columns: 1fr; }
.system-guide-toc { position: static; max-height: none; }
.system-guide-toc nav { display: flex; flex-wrap: wrap; gap: 4px 12px; }
}
</style>
@@ -0,0 +1,85 @@
{# 实例数据统计:上方期权复盘 KPI/分组,下方资金曲线 #}
<div class="card ods-page full" id="options-data-stats" data-source-type="option_spot">
<style>
.ods-page{grid-column:1/-1}
.ods-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
.ods-head h2{margin:0;font-size:1.05rem}
.ods-section{margin-bottom:22px}
.ods-section-title{font-weight:650;font-size:.9rem;margin-bottom:4px}
.ods-section-desc{font-size:.72rem;opacity:.7;margin:0 0 12px}
.ods-kpi-row{
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
gap:8px;margin-bottom:12px;
}
.ods-kpi-tile{
border:1px solid rgba(127,127,127,.22);border-radius:10px;
padding:10px 12px;background:rgba(0,0,0,.2);min-width:0;
}
.ods-kpi-label{font-size:.7rem;opacity:.7;margin-bottom:4px}
.ods-kpi-value{font-size:.95rem;font-weight:650;letter-spacing:.01em;word-break:break-all}
.ods-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
.ods-stat-card{
border:1px solid rgba(127,127,127,.22);border-radius:10px;
padding:10px 12px;background:rgba(0,0,0,.16);font-size:.76rem;
}
.ods-stat-card-title{
font-weight:650;margin-bottom:8px;font-size:.78rem;
padding-bottom:6px;border-bottom:1px solid rgba(127,127,127,.18);
}
.ods-stat-row{
display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid rgba(127,127,127,.1);
}
.ods-stat-row:last-child{border-bottom:none;padding-bottom:0}
.ods-stat-key{opacity:.9;min-width:0;overflow:hidden;text-overflow:ellipsis}
.ods-stat-val{flex-shrink:0;font-variant-numeric:tabular-nums;opacity:.85}
.ods-curve-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.ods-range-btn{
font-size:.76rem;padding:4px 10px;border-radius:6px;cursor:pointer;
border:1px solid rgba(127,127,127,.3);background:transparent;color:inherit;
}
.ods-range-btn.active{border-color:rgba(90,160,255,.7);background:rgba(90,160,255,.12)}
.ods-curve-meta{font-size:.72rem;opacity:.75;margin-left:auto}
.ods-curve-wrap{
border:1px solid rgba(127,127,127,.22);border-radius:10px;
padding:12px 10px 8px;background:rgba(0,0,0,.14);
}
.ods-curve-svg{width:100%;height:240px;display:block}
.ods-pnl-pos{color:#3dd68c}
.ods-pnl-neg{color:#f07178}
@media (max-width:900px){.ods-kpi-row{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (max-width:640px){
.ods-kpi-row{grid-template-columns:repeat(2,minmax(0,1fr))}
.ods-curve-svg{height:200px}
}
</style>
<div class="ods-head">
<h2>数据统计</h2>
<span class="muted" id="ods-status" style="font-size:.72rem"></span>
<button type="button" class="btn-secondary" id="ods-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div>
<section class="ods-section" aria-labelledby="ods-review-heading">
<div class="ods-section-title" id="ods-review-heading">期权复盘统计</div>
<p class="ods-section-desc">与「期权复盘」页统计同源;复盘页内统计仍保留。</p>
<div id="ods-kpi" class="ods-kpi-row"></div>
<div id="ods-stats-groups" class="ods-stats-grid"></div>
</section>
<section class="ods-section" aria-labelledby="ods-curve-heading">
<div class="ods-section-title" id="ods-curve-heading">已实现盈亏曲线</div>
<p class="ods-section-desc">累计已实现盈亏(期权平仓真盈亏U累加);划转/提现不影响。</p>
<div class="ods-curve-toolbar">
<button type="button" class="ods-range-btn active" data-range="today">今日</button>
<button type="button" class="ods-range-btn" data-range="7d">7日</button>
<button type="button" class="ods-range-btn" data-range="30d">30日</button>
<button type="button" class="ods-range-btn" data-range="all">全部</button>
<span class="ods-curve-meta" id="ods-curve-meta">—</span>
</div>
<div class="ods-curve-wrap">
<svg id="ods-curve-svg" class="ods-curve-svg" viewBox="0 0 640 240" preserveAspectRatio="none" role="img" aria-label="已实现盈亏曲线"></svg>
</div>
</section>
</div>
<script src="/static/options_data_stats.js?v=4"></script>
@@ -1,72 +0,0 @@
{# 趋势户:两级开仓类型 → 自动 trade_style;日内户:假破 / 结构突破 #}
{% macro order_entry_type_fields() -%}
{% if order_entry_profile == 'trend_div' %}
<div class="order-entry-model-row">
<select id="order-entry-category" class="order-entry-category" required title="反转 / 顺势 / 波段" aria-label="开仓性质">
<option value="">性质</option>
{% for cat in entry_model_categories %}
<option value="{{ cat.key }}">{{ cat.label }}</option>
{% endfor %}
</select>
<select name="entry_model" id="order-entry-model" class="order-entry-model-sub" required disabled title="启动A/B,大分歧A/B,小分歧" aria-label="开仓类型">
<option value="">类型</option>
{% for cat in entry_model_categories %}
{% for opt in cat.options %}
<option value="{{ opt.code }}" data-entry-category="{{ cat.key }}" data-trade-style="{{ opt.trade_style }}"{% if opt.help %} title="{{ opt.help }}"{% endif %} hidden disabled>{{ opt.label }}</option>
{% endfor %}
{% endfor %}
</select>
<input type="hidden" name="trade_style" id="order-trade-style-hidden" value="trend">
<span id="order-trade-style-hint" class="order-trade-style-hint" title="由开仓类型自动设定">趋势单</span>
</div>
{% elif order_entry_profile == 'intraday' %}
<select name="entry_model" id="order-entry-model" class="order-entry-intraday" required title="日内开仓类型" aria-label="开仓类型">
<option value="">开仓类型</option>
{% for opt in intraday_entry_model_options %}
<option value="{{ opt.code }}"{% if opt.help %} title="{{ opt.help }}"{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
<input type="hidden" name="trade_style" value="trend">
{% else %}
<select name="trade_style" required>
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
{% endif %}
{%- endmacro %}
-151
View File
@@ -1,151 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 实盘下单放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page">
<div class="container">
<div class="card">
<div class="row" style="justify-content:space-between">
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
</svg>
</button>
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
</button>
</div>
<div class="row">
<a class="btn" href="/">返回首页</a>
<strong class="focus-title">实盘下单放大(100根K线)</strong><span class="exchange-tag">{{ exchange_display }}</span>
</div>
<div class="status">最近刷新:<span id="updated-at">--</span></div>
</div>
{% if orders %}
<div class="row" style="margin-top:10px">
<label>订单</label>
<select id="order-id">
{% for o in orders %}
<option value="{{ o.id }}" {% if selected_order and o.id == selected_order.id %}selected{% endif %}>
#{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
</option>
{% endfor %}
</select>
<label>周期</label>
<select id="timeframe">
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<button id="manual-refresh" type="button">刷新</button>
<span id="load-status" class="status"></span>
</div>
{% else %}
<div class="empty">当前没有激活订单,无法展示放大K线.</div>
{% endif %}
</div>
{% if orders %}
<div class="card">
<div class="meta">
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
<div class="meta-item"><div class="k">成交价</div><div class="v" id="m-entry">-</div></div>
<div class="meta-item"><div class="k">止损</div><div class="v" id="m-sl">-</div></div>
<div class="meta-item"><div class="k">止盈</div><div class="v" id="m-tp">-</div></div>
<div class="meta-item"><div class="k">盈亏比</div><div class="v" id="m-rr">-</div></div>
<div class="meta-item"><div class="k">移动保本</div><div class="v" id="m-breakeven">-</div></div>
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
<div class="meta-item meta-item--emph meta-item--pnl"><div class="k">浮盈亏</div><div class="v" id="m-pnl">-</div></div>
</div>
</div>
<div class="card">
<div id="chart-wrap"><div id="chart"></div></div>
</div>
{% endif %}
</div>
{% if orders %}
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/static/focus_chart_page.js?v=2"></script>
<script>
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
const orderSelect = document.getElementById("order-id");
const tfSelect = document.getElementById("timeframe");
const statusEl = document.getElementById("load-status");
const updatedAtEl = document.getElementById("updated-at");
const chartHost = document.getElementById("chart");
const FCP = window.FocusChartPage;
let fc = null;
function ensureChart(){
if(fc && fc.ensureSeries()) return true;
if(!window.LightweightCharts){
statusEl.className = "status err";
statusEl.innerText = "图表库加载失败";
return false;
}
fc = FCP.createFocusChart(chartHost);
if(!fc || !fc.ensureSeries()){
statusEl.className = "status err";
statusEl.innerText = "K线序列初始化失败";
return false;
}
return true;
}
async function loadOrderKline(){
if(!ensureChart()) return;
const orderId = orderSelect.value;
const timeframe = tfSelect.value;
if(!orderId) return;
statusEl.className = "status";
statusEl.innerText = "加载中...";
try{
const resp = await fetch(`/api/order_kline?order_id=${encodeURIComponent(orderId)}&timeframe=${encodeURIComponent(timeframe)}`);
const data = await resp.json();
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
else FCP.setActivePriceTick(data.price_tick);
const candles = Array.isArray(data.candles) ? data.candles : [];
if(!candles.length){
statusEl.className = "status err";
statusEl.innerText = "暂无K线数据";
return;
}
fc.candleSeries.setData(candles);
fc.resetPriceLines();
const o = data.order || {};
fc.addLine(o.trigger_price, FCP.lineTitle("成交价", o.trigger_price_display), "#42a5f5");
fc.addLine(o.stop_loss, FCP.lineTitle("止损", o.stop_loss_display), "#ff6666");
fc.addLine(o.take_profit, FCP.lineTitle("止盈", o.take_profit_display), "#4cd97f");
const markPx = o.current_price;
if(markPx) fc.addLine(markPx, FCP.lineTitle("现价", o.current_price_display), "#ffb74d");
fc.chart.timeScale().fitContent();
FCP.paintOrderMeta(o);
updatedAtEl.innerText = data.updated_at || "--";
statusEl.className = "status";
statusEl.innerText = `已加载 ${candles.length} 根K线`;
}catch(err){
statusEl.className = "status err";
statusEl.innerText = err && err.message ? err.message : "加载失败";
}
}
document.getElementById("manual-refresh").addEventListener("click", loadOrderKline);
orderSelect.addEventListener("change", loadOrderKline);
tfSelect.addEventListener("change", loadOrderKline);
loadOrderKline();
setInterval(loadOrderKline, refreshMs);
</script>
{% endif %}
</body>
</html>
@@ -1,7 +0,0 @@
{# 以损定仓:杠杆按币种默认(BTC/ETH 10x,其它 5x),不可选手输 #}
{% macro order_leverage_fields() -%}
{% if position_sizing_mode != 'full_margin' %}
<input type="hidden" id="order-leverage" name="leverage" value="">
<span id="order-leverage-hint" class="order-leverage-hint" title="BTC/ETH 默认10x,其它默认5x">杠杆 —</span>
{% endif %}
{%- endmacro %}
@@ -1,77 +0,0 @@
{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #}
<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,38 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-exit-collapse">
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
<div class="tip-collapse-body rule-tip">
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
<li><strong>强制清仓</strong>:
{% if force_close is defined and force_close.enabled %}
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
{% else %}
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
{% endif %}
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。
</li>
</ul>
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,38 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-exit-collapse">
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
<div class="tip-collapse-body rule-tip">
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
<li><strong>强制清仓</strong>:
{% if force_close is defined and force_close.enabled %}
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
{% else %}
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
{% endif %}
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓;交易所裸仓且无本地监控时不会被此逻辑平掉。
</li>
</ul>
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,21 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓达上限,单日开仓达上限,有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,38 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}){% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00){% endif %}{% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-exit-collapse">
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
<div class="tip-collapse-body rule-tip">
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
<li><strong>强制清仓</strong>:
{% if force_close is defined and force_close.enabled %}
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
{% else %}
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
{% endif %}
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。
</li>
</ul>
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,5 +0,0 @@
<div id="order-plan-preview" class="order-plan-preview">
<span id="order-risk-preview" class="order-preview-risk">预估风险:<strong>—</strong></span>
<span id="order-profit-preview" class="order-preview-profit">预估盈利:<strong>—</strong></span>
<span id="order-rr-preview" class="order-preview-rr">预估盈亏比:<strong>—</strong></span>
</div>
+4 -3
View File
@@ -37,9 +37,8 @@
{% elif tab.key == 'password' %}
{% include 'password_settings_panel.html' %}
{% elif tab.key == 'transfer' %}
<h2>永续资金划转</h2>
<p class="settings-subcard-desc">账户内:资金账户与交易账户之间划转 USDT.</p>
{% include 'instance_transfer_panel.html' %}
<h2>资金划转</h2>
<p class="settings-subcard-desc muted">永续账户划转已下线;期权/模拟盘请使用对应页内划转.</p>
{% elif tab.key == 'export' %}
<h2>数据导出</h2>
<p class="settings-subcard-desc muted">CSV · v{{ instance_settings.data_export_version }}</p>
@@ -47,6 +46,8 @@
<a href="/export/key_monitors">关键位(当前)</a>
<a href="/export/key_monitor_history">关键位历史</a>
</div>
{% elif tab.key == 'backup' %}
{% include 'backup_settings_panel.html' %}
{% elif tab.key == 'options_swap' %}
<h2>币种兑换</h2>
{% include 'options_settings_swap.html' %}
-98
View File
@@ -1,98 +0,0 @@
"""Sync order_monitors after an external/market flat."""
from __future__ import annotations
import time
from typing import Any, Callable
def reconcile_hub_external_close_impl(
conn,
symbol: str,
direction: str,
*,
exchange_configured: Callable[[], bool],
not_configured_msg: str,
symbols_match: Callable[[str, str], bool],
get_opened_at_value: Callable[[Any], str],
resolve_monitor_exchange_symbol: Callable[[Any], str],
get_live_position_contracts: Callable[[str, str], float | None],
cancel_conditional_orders: Callable[[str], None],
resolve_synced_flat_close: Callable[..., tuple],
finalize_stopped_monitor: Callable[..., None],
sync_trade_records: Callable[..., None] | None = None,
reconcile_flat_streak: dict | None = None,
to_ms_with_fallback: Callable[..., int | None] | None = None,
prefer_manual_resolve: bool = False,
order_row_monitor_type: Callable[[Any], str] | None = None,
) -> dict[str, Any]:
if not exchange_configured():
return {"ok": False, "msg": not_configured_msg, "synced": 0}
sym_req = (symbol or "").strip()
dir_l = (direction or "").strip().lower()
if dir_l not in ("long", "short"):
return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0}
synced = 0
streak = reconcile_flat_streak if reconcile_flat_streak is not None else {}
rows = conn.execute(
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
).fetchall()
for r in rows:
if not symbols_match(str(r["symbol"] or ""), sym_req):
continue
if (r["direction"] or "").strip().lower() != dir_l:
continue
oid = int(r["id"])
if r["status"] == "error":
opened_at_chk = get_opened_at_value(r)
mtype = order_row_monitor_type(r) if order_row_monitor_type else r["monitor_type"]
existing = conn.execute(
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1",
(r["symbol"], opened_at_chk, mtype),
).fetchone()
if existing:
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
synced += 1
continue
exchange_symbol = resolve_monitor_exchange_symbol(r)
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
if live_contracts is None:
continue
if live_contracts > 0:
time.sleep(0.6)
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
if live_contracts is None or live_contracts > 0:
continue
streak.pop(oid, None)
cancel_conditional_orders(exchange_symbol)
opened_at = get_opened_at_value(r)
opened_at_ms = None
if to_ms_with_fallback is not None:
keys = r.keys() if hasattr(r, "keys") else ()
opened_at_ms = to_ms_with_fallback(
r["opened_at_ms"] if "opened_at_ms" in keys else None,
opened_at,
)
resolve_kw = {"opened_at_ms": opened_at_ms}
if prefer_manual_resolve:
resolve_kw["prefer_manual"] = True
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(
r, opened_at, **resolve_kw
)
finalize_stopped_monitor(
conn,
r,
result=result,
pnl_amount=pnl_amount,
closed_at=closed_at,
miss_reason=miss_reason,
)
synced += 1
if sync_trade_records is not None:
try:
sync_trade_records(conn, force=True)
except Exception:
pass
return {"ok": True, "synced": synced}
reconcile_external_close_impl = reconcile_hub_external_close_impl
+74
View File
@@ -0,0 +1,74 @@
"""OKX 交易模式(币本位期权专用机:仅单独期权).
历史 .env 若仍为 perp_options / options_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})
UI_MODES = frozenset({MODE_OPTIONS})
_ALIASES = {
"option": MODE_OPTIONS,
"standalone": MODE_OPTIONS,
"期权": MODE_OPTIONS,
"单独期权": MODE_OPTIONS,
"po": MODE_OPTIONS,
"perp": MODE_OPTIONS,
"perp_options": MODE_OPTIONS,
"永期": MODE_OPTIONS,
"永期对冲": MODE_OPTIONS,
"oo": MODE_OPTIONS,
"options_options": MODE_OPTIONS,
"期期": MODE_OPTIONS,
"期期对冲": MODE_OPTIONS,
}
def normalize_okx_trade_mode(raw: Optional[str] = None) -> str:
s = str(raw or "").strip().lower()
if s in VALID_MODES:
return s
if s in _ALIASES:
return _ALIASES[s]
# 未知或空 → 单独期权
return MODE_OPTIONS
def legacy_infer_okx_trade_mode() -> str:
return MODE_OPTIONS
def get_okx_trade_mode() -> str:
return normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
def hedge_module_enabled() -> bool:
return False
def show_perp_options() -> bool:
return False
def show_options_options() -> bool:
return False
def standalone_options_open_allowed() -> bool:
return True
def mode_label(mode: Optional[str] = None) -> str:
return "单独期权"
def block_standalone_open_by_mode_msg() -> Optional[str]:
return None
+334
View File
@@ -0,0 +1,334 @@
"""期权已实现盈亏:交易账户 USDT 快照差分(一次一仓).
规格见 docs/规格-期权交易账户盈亏结算.md
"""
from __future__ import annotations
from typing import Any
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 fetch_trading_usdt(cfg: dict[str, Any] | None, ex: Any) -> float | None:
"""读取交易账户 USDT(可用优先)."""
fetch = (cfg or {}).get("fetch_options_balances") if isinstance(cfg, dict) else None
if not callable(fetch):
from lib.exchange.okx_options_lib import fetch_options_balances
fetch = fetch_options_balances
try:
bal = fetch(ex, force=True) or {}
except TypeError:
try:
bal = fetch(ex) or {}
except Exception:
return None
except Exception:
return None
v = _safe_float(bal.get("trading_usdt_avail"))
if v is None:
v = _safe_float(bal.get("trading_usdt"))
return round(float(v), 8) if v is not None else None
def sellable_coin_blocks_open_msg(ex: Any, underlying: str) -> str | None:
"""开仓前有可卖标的币则拒开."""
from lib.options.options_spot_bridge_lib import (
fetch_trading_coin_available,
is_sellable_spot_coin,
)
uly = (underlying or "").strip().upper()
if uly not in ("ETH", "BTC"):
return None
try:
avail = fetch_trading_coin_available(ex, uly)
except Exception:
return None
if avail is None:
return None
if is_sellable_spot_coin(ex, uly, avail):
return f"交易账户仍有可卖 {uly}({float(avail):g}),请先卖回 USDT 后再开仓"
return None
def transfer_blocked_by_position_msg(conn, ex: Any = None) -> str | None:
"""持仓中或待卖回时禁止划转."""
from lib.options.options_spot_bridge_lib import has_unfinished_bridge
try:
if has_unfinished_bridge(conn):
return "期权持仓或待卖回期间禁止划转,请先平仓并卖回 USDT"
except Exception:
pass
try:
row = conn.execute(
"SELECT COUNT(*) AS n FROM options_trades WHERE status='open'"
).fetchone()
n = int(row["n"] if hasattr(row, "keys") else row[0])
if n > 0:
return "期权持仓中禁止划转"
except Exception:
pass
if ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
for p in fetch_option_positions(ex) or []:
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return "存在未平期权持仓,禁止划转"
except (TypeError, ValueError):
continue
except Exception:
pass
return None
def sellable_coin_blocks_settle(
ex: Any,
underlying: str | None,
) -> str | None:
"""仍有可卖标的币则禁止账户差分结算(规格:卖光再冻结)."""
from lib.options.options_spot_bridge_lib import (
fetch_trading_coin_available,
is_sellable_spot_coin,
)
uly = (underlying or "").strip().upper()
if uly not in ("ETH", "BTC"):
return None
try:
avail = fetch_trading_coin_available(ex, uly)
except Exception:
return None
if avail is None:
return None
if is_sellable_spot_coin(ex, uly, avail):
return f"仍有可卖 {uly}({float(avail):g}),暂不结算账户盈亏"
return None
def _underlying_for_settle(
conn,
*,
bridge_id: int | None = None,
trade_id: int | None = None,
) -> str | None:
try:
if trade_id is not None:
row = conn.execute(
"SELECT underlying FROM options_trades WHERE id=?",
(int(trade_id),),
).fetchone()
if row:
return str(row["underlying"] if hasattr(row, "keys") else row[0] or "").strip().upper() or None
if bridge_id is not None:
row = conn.execute(
"""
SELECT underlying FROM options_trades
WHERE bridge_id=? AND status='closed'
ORDER BY id DESC LIMIT 1
""",
(int(bridge_id),),
).fetchone()
if row:
return str(row["underlying"] if hasattr(row, "keys") else row[0] or "").strip().upper() or None
brow = conn.execute(
"SELECT underlying FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if brow:
return str(brow["underlying"] if hasattr(brow, "keys") else brow[0] or "").strip().upper() or None
except Exception:
return None
return None
def try_settle_after_sellback(
get_db: Any,
ex: Any,
*,
bridge_id: int | None = None,
cfg: dict[str, Any] | None = None,
underlying: str | None = None,
sell_complete: bool | None = None,
) -> int:
"""卖回路径通用结算:读交易账户 after 并冻结未结算单.
门禁:sell_complete 显式 False 时不结算;仍有可卖标的币时不结算.
"""
if sell_complete is False:
return 0
pack = cfg if isinstance(cfg, dict) else {"fetch_options_balances": None}
import sqlite3
# Windows 上 sqlite3.Connection 的 callable() 可能为 True,不能据此判断 get_db
if isinstance(get_db, sqlite3.Connection):
conn = get_db
close_conn = False
elif callable(get_db):
conn = get_db()
close_conn = True
else:
conn = get_db
close_conn = False
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
uly = (underlying or "").strip().upper() or None
if not uly:
uly = _underlying_for_settle(conn, bridge_id=bridge_id)
block = sellable_coin_blocks_settle(ex, uly)
if block:
return 0
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
after = fetch_trading_usdt(pack, ex)
n = 0
if bridge_id is not None:
n = settle_closed_trades_from_trading_account(
conn, trading_usdt_after=after, bridge_id=int(bridge_id)
)
if n < 1:
row = conn.execute(
"""
SELECT id FROM options_trades
WHERE status='closed' AND COALESCE(pnl_settled,0)=0
AND trading_usdt_before IS NOT NULL
ORDER BY id DESC LIMIT 1
"""
).fetchone()
if row:
tid = int(row["id"] if hasattr(row, "keys") else row[0])
if not uly:
uly = _underlying_for_settle(conn, trade_id=tid)
block2 = sellable_coin_blocks_settle(ex, uly)
if block2:
return 0
n = settle_closed_trades_from_trading_account(
conn,
trading_usdt_after=after,
trade_id=tid,
)
try:
conn.commit()
except Exception:
pass
return n
finally:
if close_conn:
try:
conn.close()
except Exception:
pass
def settle_closed_trades_from_trading_account(
conn,
*,
trading_usdt_after: float | None = None,
bridge_id: int | None = None,
trade_id: int | None = None,
) -> int:
"""卖回后:拍 after 快照,并冻结复盘真盈亏.
realized_pnl_usdt = 期权币盈亏×平仓价(卖回VWAP优先);
不含现货多买.账户 after 仅审计.已 pnl_settled=1 的行永不改写.
"""
from lib.options.options_pnl_settle_lib import resolve_true_pnl_usdt_from_trade
after = _safe_float(trading_usdt_after)
if after is not None:
after = round(float(after), 8)
clauses = ["status='closed'", "COALESCE(pnl_settled, 0)=0"]
params: list[Any] = []
if trade_id is not None:
clauses.append("id=?")
params.append(int(trade_id))
elif bridge_id is not None:
clauses.append("bridge_id=?")
params.append(int(bridge_id))
else:
return 0
from lib.options.options_pnl_settle_lib import resolve_spot_vwap_prices
rows = conn.execute(
f"""
SELECT id, trading_usdt_before, premium_paid, premium_received, realized_pnl,
open_index_px, close_index_px, open_fee, close_fee, premium_ccy, fee_ccy,
bridge_id, initial_premium_usdt
FROM options_trades
WHERE {" AND ".join(clauses)}
""",
tuple(params),
).fetchall()
updated = 0
for row in rows:
tid = int(row["id"] if hasattr(row, "keys") else row[0])
before = _safe_float(
row["trading_usdt_before"] if hasattr(row, "keys") else row[1]
)
after_use = after
if after_use is None and before is not None:
after_use = None
row_bid = row["bridge_id"] if hasattr(row, "keys") else row[11]
if row_bid is None and bridge_id is not None:
row_bid = bridge_id
initial_u = (
row["initial_premium_usdt"] if hasattr(row, "keys") else row[12]
)
open_px, close_px, _gate = resolve_spot_vwap_prices(
conn,
bridge_id=row_bid,
open_index_px=row["open_index_px"] if hasattr(row, "keys") else row[5],
close_index_px=row["close_index_px"] if hasattr(row, "keys") else row[6],
)
pnl_u, src = resolve_true_pnl_usdt_from_trade(
premium_paid=row["premium_paid"] if hasattr(row, "keys") else row[2],
premium_received=row["premium_received"] if hasattr(row, "keys") else row[3],
realized_pnl=row["realized_pnl"] if hasattr(row, "keys") else row[4],
open_index_px=open_px,
close_index_px=close_px,
initial_premium_usdt=initial_u,
open_fee=row["open_fee"] if hasattr(row, "keys") else row[7],
close_fee=row["close_fee"] if hasattr(row, "keys") else row[8],
premium_ccy=row["premium_ccy"] if hasattr(row, "keys") else row[9],
fee_ccy=row["fee_ccy"] if hasattr(row, "keys") else row[10],
conn=conn,
bridge_id=row_bid,
)
if pnl_u is None or src in ("none", "pending_sell"):
continue
# 卖回价冻为卖回 VWAP;开仓价/初始权利金已在开仓时冻住,仅缺省时补
paid = _safe_float(row["premium_paid"] if hasattr(row, "keys") else row[2])
heal_initial = _safe_float(initial_u)
if heal_initial is None and paid is not None and open_px is not None and open_px > 0:
heal_initial = round(float(paid) * float(open_px), 4)
conn.execute(
"""
UPDATE options_trades
SET trading_usdt_after = COALESCE(?, trading_usdt_after),
realized_pnl_usdt = ?,
open_index_px = COALESCE(open_index_px, ?),
close_index_px = COALESCE(?, close_index_px),
initial_premium_usdt = COALESCE(initial_premium_usdt, ?),
pnl_settled = 1
WHERE id = ? AND COALESCE(pnl_settled, 0)=0
""",
(after_use, pnl_u, open_px, close_px, heal_initial, tid),
)
updated += 1
return updated
+220 -14
View File
@@ -11,9 +11,11 @@ from lib.options.options_close_gate_lib import (
update_close_gate,
)
from lib.options.options_pricing_lib import (
PREMIUM_DECIMALS_COIN,
estimate_close_by_bids,
fetch_option_mark_px,
is_stub_bid_px,
round_premium_amount,
total_premium,
)
@@ -306,7 +308,10 @@ def close_option_by_bid1(
px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "")
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4)
# 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计
prem_recv_submit = round_premium_amount(
total_premium(px, level_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN
)
time.sleep(0.6)
invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex)
@@ -325,37 +330,211 @@ def close_option_by_bid1(
reduced = max(0, before_avail - after_avail)
remaining_pos = after_avail
fully_closed = remaining_pos < 1
filled_sheets = min(reduced, level_sheets) if reduced > 0 else 0
# 全平但减仓读数异常时回退挂单张数,避免漏记本批
if fully_closed and filled_sheets < 1:
filled_sheets = level_sheets
batch_recv = (
round_premium_amount(total_premium(px, filled_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN)
if filled_sheets > 0
else 0.0
)
total_recv = batch_recv
close_px = px
merged_ord = oid or None
if fully_closed:
clear_close_gate(inst_id)
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
from lib.options.options_db import (
add_open_close_accum,
get_open_close_accum,
init_options_tables,
)
init_options_tables(conn)
# 先并入本批,再读累计(含此前分批)
if filled_sheets > 0:
add_open_close_accum(
conn,
inst_id,
recv=batch_recv,
sheets=filled_sheets,
px=px,
ord_id=oid or None,
)
accum = get_open_close_accum(conn, inst_id)
total_recv = round_premium_amount(
float(accum.get("recv") or 0), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
accum_sheets = int(accum.get("sheets") or 0)
accum_notional = float(accum.get("notional") or 0)
if accum_sheets > 0 and accum_notional > 0:
close_px = round(accum_notional / accum_sheets, 8)
merged_ord = str(accum.get("close_ord_id") or "").strip() or (oid or None)
open_rows = conn.execute(
"""
SELECT id, premium_paid FROM options_trades
SELECT id, premium_paid, open_fee, premium_ccy, underlying,
exchange_ord_id, budget_usdt, open_index_px, fee_ccy,
eth_amount, open_quote, trading_usdt_before
FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
""",
(inst_id,),
).fetchall()
from lib.options.options_pnl_settle_lib import (
extract_order_fee,
fee_to_premium_ccy,
resolve_close_index_px,
settle_closed_pnl,
sum_option_order_fees_in_premium,
)
from lib.options.options_time_lib import options_app_now_str
row_ccy = premium_ccy
if open_rows:
row_ccy = str(open_rows[0]["premium_ccy"] or premium_ccy or "").strip() or premium_ccy
underly = str((open_rows[0]["underlying"] if open_rows else "") or "").strip()
close_idx = resolve_close_index_px(
ex, inst_id=inst_id, underlying=underly, hint=index_px
)
# 平仓手续费:优先累计订单号查单;查不到再用本笔下单 info
close_fee_total = 0.0
try:
close_fee_total = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=merged_ord,
premium_ccy=row_ccy,
index_px=close_idx,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
close_fee_total = 0.0
if close_fee_total <= 0:
fee_hint, fee_hint_ccy = extract_order_fee(
order if isinstance(order, dict) else None
)
if fee_hint > 0:
close_fee_total = fee_to_premium_ccy(
fee_hint,
fee_hint_ccy or row_ccy,
premium_ccy=row_ccy,
index_px=close_idx,
)
total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows)
total_open_fee = sum(float(r["open_fee"] or 0) for r in open_rows)
# 开仓费未落库时按开仓单号补查
if total_open_fee <= 0:
open_oids = [
str(r["exchange_ord_id"] or "").strip()
for r in open_rows
if str(r["exchange_ord_id"] or "").strip()
]
try:
total_open_fee = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=open_oids,
premium_ccy=row_ccy,
index_px=close_idx,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
total_open_fee = 0.0
allocated = 0.0
allocated_cfee = 0.0
allocated_ofee = 0.0
for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1:
recv = round(prem_recv - allocated, 4)
elif total_paid > 0:
recv = round(prem_recv * (paid / total_paid), 4)
allocated += recv
recv = round_premium_amount(
total_recv - allocated, decimals=PREMIUM_DECIMALS_COIN
) or 0.0
row_cfee = round(close_fee_total - allocated_cfee, 8)
stored_ofee = float(row["open_fee"] or 0)
if stored_ofee > 0:
row_ofee = stored_ofee
elif total_paid > 0 and total_open_fee > 0:
row_ofee = round(total_open_fee - allocated_ofee, 8)
else:
recv = round(prem_recv / len(open_rows), 4)
row_ofee = round(total_open_fee - allocated_ofee, 8) if total_open_fee > 0 else 0.0
elif total_paid > 0:
share = paid / total_paid
recv = round_premium_amount(
total_recv * share, decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv
pnl = round(recv - paid, 4)
row_cfee = round(close_fee_total * share, 8)
allocated_cfee += row_cfee
stored_ofee = float(row["open_fee"] or 0)
if stored_ofee > 0:
row_ofee = stored_ofee
else:
row_ofee = round(total_open_fee * share, 8)
allocated_ofee += row_ofee
else:
recv = round_premium_amount(
total_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv
row_cfee = round(close_fee_total / len(open_rows), 8)
allocated_cfee += row_cfee
stored_ofee = float(row["open_fee"] or 0)
row_ofee = stored_ofee if stored_ofee > 0 else round(
total_open_fee / len(open_rows), 8
)
if stored_ofee <= 0:
allocated_ofee += row_ofee
row_budget = _safe_float(row["budget_usdt"])
row_open_idx = _safe_float(row["open_index_px"])
# 禁止 budget/premium 反推开仓价(会把缓冲算进均价,开仓价被抬飞)
if (row_open_idx is None or row_open_idx <= 0) and row.get("bridge_id") is not None:
try:
from lib.options.options_spot_bridge_lib import bridge_buy_vwap
bv = bridge_buy_vwap(conn, int(row["bridge_id"]))
if bv is not None and float(bv) > 0:
row_open_idx = float(bv)
except Exception:
pass
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=recv,
open_fee=row_ofee,
close_fee=row_cfee,
premium_ccy=str(row["premium_ccy"] or row_ccy or premium_ccy),
index_px=close_idx,
open_index_px=row_open_idx,
budget_usdt=row_budget,
fee_ccy=str(row["fee_ccy"] or row["premium_ccy"] or row_ccy or ""),
)
pnl = settled.get("realized_pnl")
# 真盈亏U=期权币盈亏×平仓价
pnl_usdt = settled.get("realized_pnl_usdt")
note_sql = ""
params: list[Any] = [px, recv, pnl, oid or None]
# 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/close_idx/open_idx/pnl_u/[note]/id
params: list[Any] = [
close_px,
recv,
pnl,
merged_ord,
options_app_now_str(),
row_ofee,
row_cfee,
str(row["premium_ccy"] or row_ccy or premium_ccy or ""),
settled.get("close_index_px"),
settled.get("open_index_px") or row_open_idx,
pnl_usdt,
]
if signal_note and i == len(open_rows) - 1:
note_sql = """,
signal_note = CASE
@@ -368,7 +547,12 @@ def close_option_by_bid1(
f"""
UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
realized_pnl = ?, close_ord_id = ?, closed_at = ?,
open_fee = ?, close_fee = ?, fee_ccy = ?,
close_index_px = ?, open_index_px = COALESCE(open_index_px, ?),
realized_pnl_usdt = ?,
close_recv_accum = 0, close_sheets_accum = 0,
close_notional_accum = 0
{note_sql}
WHERE id = ?
""",
@@ -377,6 +561,27 @@ def close_option_by_bid1(
conn.commit()
finally:
conn.close()
elif filled_sheets > 0:
# 部分成交:持久化本批收回,下次全平合计
conn = cfg["get_db"]()
try:
from lib.options.options_db import add_open_close_accum, init_options_tables
init_options_tables(conn)
add_open_close_accum(
conn,
inst_id,
recv=batch_recv,
sheets=filled_sheets,
px=px,
ord_id=oid or None,
)
conn.commit()
finally:
conn.close()
if require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id)
elif require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id)
@@ -388,11 +593,12 @@ def close_option_by_bid1(
"bid": px,
"locked_bid_px": locked_bid_px,
"submitted_sheets": level_sheets,
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0,
"filled_or_reduced_sheets": filled_sheets,
"remaining_sheets": remaining_pos,
"premium_received": prem_recv,
"premium_received": total_recv if fully_closed else (batch_recv or prem_recv_submit),
"batch_premium_received": batch_recv,
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
"close_ord_id": oid or None,
"close_ord_id": merged_ord if fully_closed else (oid or None),
"fully_closed": fully_closed,
"msg": (
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
+2 -2
View File
@@ -27,7 +27,7 @@ def _close_recycle_mult(premium_ccy: str) -> float:
def _close_hold_seconds() -> float:
return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 20.0)
def _close_net_pnl_min_usdt() -> float:
@@ -36,7 +36,7 @@ def _close_net_pnl_min_usdt() -> float:
# 兼容旧引用
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0)
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 20.0)
_lock = threading.Lock()
# inst_id -> gate state
+370 -19
View File
@@ -27,6 +27,55 @@ from lib.options.options_spot_bridge_lib import (
)
def _sell_excess_bridge_coin(
cfg: dict[str, Any],
ex: Any,
conn: Any,
*,
bridge_id: int,
underlying: str,
keep_coin: float,
coin_bought: float,
) -> dict[str, Any] | None:
"""开仓后卖掉多买的币:卖量不超过 bridge 买入量,并保留权利金所需 keep_coin."""
try:
keep = max(0.0, float(keep_coin))
except (TypeError, ValueError):
keep = 0.0
try:
bought = max(0.0, float(coin_bought))
except (TypeError, ValueError):
bought = 0.0
excess_cap = max(0.0, bought - keep)
if excess_cap <= 0:
return None
try:
conn.commit()
except Exception:
pass
avail = _spot_bridge.fetch_trading_coin_available(ex, underlying)
if avail is None:
return None
sell_amt = min(float(avail), excess_cap)
if not _spot_bridge.is_sellable_spot_coin(ex, underlying, sell_amt):
return None
sell = _spot_bridge.spot_market_sell_coin_to_usdt(
ex, underlying=underlying, coin_amount=sell_amt
)
if sell and sell.get("ok"):
try:
recovered = float(sell.get("usdt_recovered") or 0)
except (TypeError, ValueError):
recovered = 0.0
if recovered > 0:
try:
_spot_bridge.add_bridge_usdt_recovered(conn, int(bridge_id), recovered)
conn.commit()
except Exception:
pass
return sell
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances
@@ -38,7 +87,7 @@ def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
trading_f = float(trading or 0)
except (TypeError, ValueError):
trading_f = 0.0
buf = float(cfg.get("budget_buffer") or 0.95)
buf = float(cfg.get("budget_buffer") or 1.0)
return compute_coin_budget_usdt(trading_f, buffer=buf)
@@ -69,13 +118,33 @@ def open_coin_option_buy_full(
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
# 先清粉尘桥 + 空仓扫尾卖回(短连接),再开业务连接
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], ex)
except Exception:
pass
try:
sweep_spot_after_options_flat(cfg, ex)
except Exception:
pass
conn: Any = None
conn = cfg["get_db"]()
try:
init_options_tables(conn)
try:
conn.commit()
except Exception:
pass
block = _spot_bridge.bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
# 网络检查前再提交,避免握锁拉持仓/行情
try:
conn.commit()
except Exception:
pass
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
@@ -142,6 +211,21 @@ def open_coin_option_buy_full(
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 买币/读余额(模拟盘写钱包)前必须提交释放锁
try:
conn.commit()
except Exception:
pass
# 开仓前有可卖标的币 → 拒开(规格:先卖回)
from lib.options.options_account_pnl_lib import (
fetch_trading_usdt,
sellable_coin_blocks_open_msg,
)
coin_block = sellable_coin_blocks_open_msg(ex, underlying)
if coin_block:
return {"ok": False, "msg": coin_block, "can_open": False}
trading_usdt_before = fetch_trading_usdt(cfg, ex)
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0
buy = _spot_bridge.spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
@@ -161,6 +245,10 @@ def open_coin_option_buy_full(
inst_id=inst_id,
message="已买币,待开期权",
)
try:
conn.commit()
except Exception:
pass
# 等余额落账
time.sleep(1.5)
try:
@@ -183,14 +271,59 @@ def open_coin_option_buy_full(
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
# 落账延迟:短重试取增量;绝不把整户余额当成「本次买入」
for _ in range(3):
time.sleep(0.6)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
again = _spot_bridge.fetch_trading_coin_available(ex, underlying)
if again is None:
continue
coin_bought = max(0.0, float(again) - float(coin_before or 0))
if coin_bought > 0:
coin_after = again
break
if coin_bought <= 0:
# 订单回执有数量则用之,否则回滚
try:
hinted = float(buy.get("coin_bought") or 0)
except (TypeError, ValueError):
hinted = 0.0
if hinted > 0:
coin_bought = hinted
else:
rb = _spot_bridge.rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
return {
"ok": False,
"msg": "买币后可用增量无效",
"rollback": rb,
"budget": budget_info,
}
_spot_bridge.update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
# 买侧 VWAP 累加(多笔买同理);USDT 优先成交回报,否则用计划买额
try:
usdt_spent = float(buy.get("usdt_spent") or buy_usdt)
except (TypeError, ValueError):
usdt_spent = float(buy_usdt)
try:
_spot_bridge.add_bridge_buy_fill(
conn,
bridge_id,
usdt_spent=usdt_spent,
coin_bought=float(coin_bought),
)
except Exception:
pass
try:
conn.commit()
except Exception:
pass
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
@@ -288,6 +421,17 @@ def open_coin_option_buy_full(
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
# 等待成交可能十余秒:先提交并释放业务连接,避免锁死中控/卖币
try:
conn.commit()
except Exception:
pass
try:
conn.close()
except Exception:
pass
conn = None
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
@@ -296,6 +440,9 @@ def open_coin_option_buy_full(
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
conn = cfg["get_db"]()
init_options_tables(conn)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
@@ -313,11 +460,73 @@ def open_coin_option_buy_full(
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
# 开仓手续费(折权利金单位);优先成交回包,否则查单
open_fee = 0.0
idx_open = None
try:
from lib.options.options_pnl_settle_lib import (
extract_order_fee,
fee_to_premium_ccy,
resolve_close_index_px,
sum_option_order_fees_in_premium,
)
idx_open = resolve_close_index_px(
ex,
inst_id=inst_id,
underlying=underlying,
hint=(q.get("index_px") if isinstance(q, dict) else None) or idx,
)
fee_hint, fee_ccy_hint = extract_order_fee(order if isinstance(order, dict) else None)
if fee_hint <= 0 and isinstance(fill, dict):
fee_hint, fee_ccy_hint = extract_order_fee(fill.get("order") if isinstance(fill.get("order"), dict) else fill)
if fee_hint > 0:
open_fee = fee_to_premium_ccy(
fee_hint, fee_ccy_hint or premium_ccy, premium_ccy=premium_ccy, index_px=idx_open
)
if open_fee <= 0:
open_fee = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=[ord_id],
premium_ccy=premium_ccy,
index_px=idx_open,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
open_fee = 0.0
# 部分成交/减半张数后卖掉多余买币(按 bridge coin_bought 封顶)
need_coin = float(premium_paid)
try:
_sell_excess_bridge_coin(
cfg,
ex,
conn,
bridge_id=bridge_id,
underlying=underlying,
keep_coin=need_coin,
coin_bought=float(coin_bought),
)
except Exception:
pass
# 开仓价必须在缩减 coin_bought 之前按买币 VWAP 冻住
# (buy_usdt_spent/buy_coin_accum;禁止事后 budget/缩水权利金量反推)
open_px_freeze = idx_open
try:
bv = _spot_bridge.bridge_buy_vwap(conn, bridge_id)
if bv is not None and float(bv) > 0:
open_px_freeze = float(bv)
except Exception:
pass
_spot_bridge.update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
coin_bought=float(need_coin),
message="期权持仓中",
)
@@ -337,6 +546,9 @@ def open_coin_option_buy_full(
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
open_fee=open_fee,
open_index_px=open_px_freeze,
trading_usdt_before=trading_usdt_before,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
@@ -393,6 +605,7 @@ def open_coin_option_buy_full(
"fill": fill,
}
finally:
if conn is not None:
try:
conn.close()
except Exception:
@@ -400,17 +613,45 @@ def open_coin_option_buy_full(
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
from lib.options.options_time_lib import options_app_now_str
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"
open_fee = float(kwargs.get("open_fee") or 0.0)
if open_fee < 0:
open_fee = abs(open_fee)
fee_ccy = str(kwargs.get("premium_ccy") or "ETH")
open_index_px = kwargs.get("open_index_px")
try:
open_index_px = float(open_index_px) if open_index_px is not None else None
if open_index_px is not None and open_index_px <= 0:
open_index_px = None
except (TypeError, ValueError):
open_index_px = None
tusdt_before = kwargs.get("trading_usdt_before")
try:
tusdt_before = float(tusdt_before) if tusdt_before is not None else None
except (TypeError, ValueError):
tusdt_before = None
paid = kwargs.get("premium_paid")
try:
paid_f = float(paid) if paid is not None else None
except (TypeError, ValueError):
paid_f = None
initial_premium_usdt = None
if open_index_px is not None and paid_f is not None and paid_f > 0:
initial_premium_usdt = round(float(open_index_px) * float(paid_f), 4)
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', ?, ?, ?, ?, ?, ?)
open_fee, fee_ccy, open_index_px, trading_usdt_before, pnl_settled,
initial_premium_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
@@ -427,9 +668,15 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
open_fee,
fee_ccy,
open_index_px,
tusdt_before,
initial_premium_usdt,
pe,
pe_mult,
pe_state,
options_app_now_str(),
),
)
conn.commit()
@@ -443,13 +690,20 @@ def maybe_sell_spot_after_close(
inst_id: str,
close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.
持仓查询失败/空响应时不卖,等下次监控再查(与 sweep_spot_after_options_flat 一致).
"""
del close_result # 预留字段;卖回只认交易所持仓实况
if margin_mode_from_inst_id(inst_id) != "coin":
return None
# 仍有仓则不卖
try:
rows = cfg["fetch_option_positions"](ex) or []
for p in rows:
rows = cfg["fetch_option_positions"](ex)
except Exception as e:
return {"ok": False, "msg": f"持仓查询失败,暂不卖币: {e}", "skipped": True}
if rows is None:
return {"ok": False, "msg": "持仓查询失败: 空响应,暂不卖币", "skipped": True}
for p in rows or []:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
@@ -457,18 +711,115 @@ def maybe_sell_spot_after_close(
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"]()
result = _spot_bridge.sell_residual_after_option_flat(
cfg["get_db"],
ex,
underlying=underlying,
inst_id=inst_id,
)
# 卖回完成(sell_complete):交易账户 after − 开仓 before → 冻结本单盈亏
try:
from lib.options.options_db import init_options_tables
from lib.options.options_account_pnl_lib import try_settle_after_sellback
init_options_tables(conn)
return _spot_bridge.sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
finally:
try:
conn.close()
res = result or {}
if res.get("ok") and res.get("sell_complete", True) and not res.get("incomplete"):
bid = res.get("bridge_id")
try_settle_after_sellback(
cfg["get_db"],
ex,
bridge_id=int(bid) if bid is not None else None,
cfg=cfg,
underlying=underlying,
sell_complete=True,
)
except Exception:
pass
return result
def sweep_spot_after_options_flat(
cfg: dict[str, Any],
ex: Any | None = None,
) -> dict[str, Any]:
"""币本位:某标的已无期权持仓时,把交易户该标的残留币卖回 USDT.
覆盖到期结算、限价平仓后同步关仓等未走买一平仓卖回路径的情况。
"""
if not is_coin_margin_mode():
return {"ok": True, "skipped": True, "msg": "非币本位"}
exchange = ex if ex is not None else cfg.get("exchange_options")
if exchange is None:
return {"ok": False, "msg": "exchange 未就绪"}
live_uly: set[str] = set()
try:
rows = cfg["fetch_option_positions"](exchange)
except Exception as e:
# 持仓查询失败:只清 pending 粉尘桥,绝不卖币
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
return {"ok": False, "msg": f"持仓查询失败: {e}"}
if rows is None:
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
return {"ok": False, "msg": "持仓查询失败: 空响应"}
for p in rows or []:
try:
if abs(float(p.get("pos") or 0)) <= 1e-12:
continue
except (TypeError, ValueError):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
live_uly.add(inst.split("-")[0].upper())
# 先关掉只剩粉尘的 pending 桥,避免拦开仓又看不到重试按钮
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
sold: list[dict[str, Any]] = []
for uly in ("BTC", "ETH"):
if uly in live_uly:
continue
try:
avail = _spot_bridge.fetch_trading_coin_available(exchange, uly)
# 低于现货 minSz 的粉尘不扫、不重试
if avail is None or not _spot_bridge.is_sellable_spot_coin(exchange, uly, avail):
continue
except Exception:
continue
try:
out = _spot_bridge.sell_residual_after_option_flat(
cfg["get_db"],
exchange,
underlying=uly,
inst_id=None,
)
out = out or {}
if out.get("ok") and out.get("sell_complete", True) and not out.get("incomplete"):
try:
from lib.options.options_account_pnl_lib import try_settle_after_sellback
bid = out.get("bridge_id")
try_settle_after_sellback(
cfg["get_db"],
exchange,
bridge_id=int(bid) if bid is not None else None,
cfg=cfg,
underlying=uly,
sell_complete=True,
)
except Exception:
pass
sold.append({"underlying": uly, **out})
except Exception as e:
sold.append({"underlying": uly, "ok": False, "msg": str(e)})
return {"ok": True, "results": sold, "skipped": not sold}
+999
View File
@@ -0,0 +1,999 @@
"""中控条件开仓单:客户端本地监控/开仓/出场(中控只下发不盯盘)."""
from __future__ import annotations
import json
import sqlite3
import time
from datetime import datetime, timedelta, timezone
from typing import Any
# armed:待命穿越; triggering:已穿越等偏离区内开仓; open:持仓中;
# done:终态(leave_up/leave_down/cancel/timeout/closed)
ACTIVE_STATUSES = ("armed", "triggering", "open")
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 ensure_cond_order_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_cond_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hub_order_id TEXT NOT NULL UNIQUE,
underlying TEXT NOT NULL,
opt_type TEXT NOT NULL,
trigger_side TEXT NOT NULL,
trigger_px REAL NOT NULL,
deviation_pct REAL NOT NULL,
min_expiry_hours REAL NOT NULL,
target_index REAL,
profit_exit_enabled INTEGER DEFAULT 0,
profit_exit_mult REAL DEFAULT 1.0,
pending_target_index REAL,
pending_profit_exit_enabled INTEGER,
pending_profit_exit_mult REAL,
exit_update_pending INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'armed',
end_reason TEXT,
last_index REAL,
crossed_at TEXT,
opened_inst_id TEXT,
opened_trade_id INTEGER,
opened_at TEXT,
message TEXT,
created_at TEXT,
updated_at TEXT,
ended_at TEXT
)
"""
)
try:
conn.execute("ALTER TABLE options_cond_orders ADD COLUMN opened_at TEXT")
except Exception:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_cond_orders_status
ON options_cond_orders(status)
"""
)
try:
conn.execute(
"""
UPDATE options_cond_orders
SET opened_at = updated_at
WHERE opened_at IS NULL
AND status = 'open'
AND updated_at IS NOT NULL
AND TRIM(updated_at) != ''
"""
)
except Exception:
pass
def _now() -> str:
try:
from lib.options.options_time_lib import options_app_now_str
return options_app_now_str()
except Exception:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _notify_cond(
send_wechat: Any,
*,
title: str,
order: dict[str, Any],
account_label: str = "",
extra: list[str] | None = None,
) -> None:
if not callable(send_wechat) or not order:
return
try:
lines = [
title,
f"账户:{account_label or 'OKX期权'}",
f"单号:{order.get('hub_order_id') or '—'}",
f"标的:{order.get('underlying') or '—'} {order.get('opt_type') or '—'}",
f"触发价:{order.get('trigger_px') if order.get('trigger_px') is not None else '—'}",
]
for line in extra or []:
if line:
lines.append(line)
send_wechat("\n".join(lines))
except Exception:
pass
def next_thursday_2300(created_at: Any) -> datetime | None:
"""下发后第一个周四 23:00(业务时区).周四 23:00 之后下发的顺延到下周四."""
from lib.options.options_time_lib import options_app_tz, options_wall_clock_to_ms
ms = options_wall_clock_to_ms(created_at)
if ms is None:
return None
tz = options_app_tz()
dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(tz)
days = (3 - dt.weekday()) % 7
cand = dt.replace(hour=23, minute=0, second=0, microsecond=0) + timedelta(days=days)
if cand <= dt:
cand += timedelta(days=7)
return cand
def expire_untriggered_cond_orders(
conn: sqlite3.Connection,
*,
send_wechat: Any = None,
account_label: str = "",
now: datetime | None = None,
) -> int:
"""仅取消仍待命(未触发)的条件单;持仓/触发中不动."""
from lib.options.options_time_lib import options_app_tz
now_dt = now or datetime.now(options_app_tz())
n = 0
for order in list_cond_orders(conn, active_only=True):
if str(order.get("status") or "") != "armed":
continue
deadline = next_thursday_2300(order.get("created_at"))
if deadline is None or now_dt < deadline:
continue
_end_order(
conn,
order,
end_reason="timeout",
message="周四23:00未触发自动取消",
send_wechat=send_wechat,
account_label=account_label,
)
n += 1
return n
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
d = dict(row)
d["profit_exit_enabled"] = bool(int(d.get("profit_exit_enabled") or 0))
d["exit_update_pending"] = bool(int(d.get("exit_update_pending") or 0))
if d.get("pending_profit_exit_enabled") is not None:
d["pending_profit_exit_enabled"] = bool(int(d["pending_profit_exit_enabled"]))
return d
def list_cond_orders(conn: sqlite3.Connection, *, active_only: bool = False) -> list[dict[str, Any]]:
ensure_cond_order_table(conn)
if active_only:
rows = conn.execute(
f"""
SELECT * FROM options_cond_orders
WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))})
ORDER BY id DESC
""",
ACTIVE_STATUSES,
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM options_cond_orders ORDER BY id DESC LIMIT 100"
).fetchall()
return [_row_to_dict(r) for r in rows if r]
def get_cond_order_by_hub_id(conn: sqlite3.Connection, hub_order_id: str) -> dict[str, Any] | None:
ensure_cond_order_table(conn)
row = conn.execute(
"SELECT * FROM options_cond_orders WHERE hub_order_id = ?",
((hub_order_id or "").strip(),),
).fetchone()
return _row_to_dict(row)
def has_active_cond_order(conn: sqlite3.Connection) -> bool:
ensure_cond_order_table(conn)
row = conn.execute(
f"""
SELECT 1 FROM options_cond_orders
WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))})
LIMIT 1
""",
ACTIVE_STATUSES,
).fetchone()
return row is not None
def cond_order_blocks_manual_open_msg(conn: sqlite3.Connection) -> str | None:
if has_active_cond_order(conn):
return "中控条件单占用中,禁止手动开仓"
return None
def machine_has_option_position(cfg: dict[str, Any], ex: Any) -> bool:
try:
raw = cfg["fetch_option_positions"](ex)
except Exception:
return False
if not raw:
return False
from lib.options.options_position_limit_lib import count_live_option_positions
return count_live_option_positions(raw) > 0
def create_cond_order(
conn: sqlite3.Connection,
payload: dict[str, Any],
*,
send_wechat: Any = None,
account_label: str = "",
) -> dict[str, Any]:
"""接收中控下发;同机仅允许一张活跃条件单."""
ensure_cond_order_table(conn)
hub_order_id = (payload.get("hub_order_id") or "").strip()
if not hub_order_id:
return {"ok": False, "msg": "缺少 hub_order_id"}
existing = get_cond_order_by_hub_id(conn, hub_order_id)
if existing:
return {"ok": True, "msg": "条件单已存在", "order": existing, "deduped": True}
if has_active_cond_order(conn):
return {"ok": False, "msg": "本机已有活跃条件单"}
underlying = (payload.get("underlying") or "ETH").strip().upper() or "ETH"
try:
from lib.options.options_underly_lib import resolve_default_underly, underly_not_allowed_msg
if not (payload.get("underlying") or "").strip():
underlying = resolve_default_underly()
uly_block = underly_not_allowed_msg(underlying)
if uly_block:
return {"ok": False, "msg": uly_block}
except Exception:
pass
opt_type = (payload.get("opt_type") or "P").strip().upper()
if opt_type not in ("P", "C"):
return {"ok": False, "msg": "opt_type 须为 P 或 C"}
# 左侧单:看跌等上穿、看涨等下穿;忽略中控传入的 trigger_side
trigger_side = "gte" if opt_type == "P" else "lte"
trigger_px = _safe_float(payload.get("trigger_px"))
if trigger_px is None or trigger_px <= 0:
return {"ok": False, "msg": "trigger_px 无效"}
deviation_pct = _safe_float(payload.get("deviation_pct"))
if deviation_pct is None or deviation_pct <= 0:
return {"ok": False, "msg": "deviation_pct 须 > 0"}
min_expiry_hours = _safe_float(payload.get("min_expiry_hours"))
if min_expiry_hours is None or min_expiry_hours <= 0:
return {"ok": False, "msg": "min_expiry_hours 须 > 0"}
exit_cfg = payload.get("exit") if isinstance(payload.get("exit"), dict) else {}
target_index = _safe_float(exit_cfg.get("target_index") or payload.get("target_index"))
pe_en = bool(exit_cfg.get("profit_exit_enabled") or payload.get("profit_exit_enabled"))
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
pe_mult = normalize_profit_exit_mult(
exit_cfg.get("profit_exit_mult", payload.get("profit_exit_mult")), default=1.0
)
if target_index is not None and target_index <= 0:
return {"ok": False, "msg": "target_index 无效"}
if not pe_en and target_index is None:
return {"ok": False, "msg": "请至少配置目标位或翻倍出场"}
now = _now()
conn.execute(
"""
INSERT INTO options_cond_orders (
hub_order_id, underlying, opt_type, trigger_side, trigger_px, deviation_pct,
min_expiry_hours, target_index, profit_exit_enabled, profit_exit_mult,
status, message, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?, 'armed', ?, ?, ?)
""",
(
hub_order_id,
underlying,
opt_type,
trigger_side,
float(trigger_px),
float(deviation_pct),
float(min_expiry_hours),
float(target_index) if target_index is not None else None,
1 if pe_en else 0,
float(pe_mult),
"已接收,待命穿越",
now,
now,
),
)
conn.commit()
order = get_cond_order_by_hub_id(conn, hub_order_id)
_notify_cond(
send_wechat,
title="【OKX期权·条件单下发】",
order=order or {},
account_label=account_label,
extra=[
f"方向:{'Put 上穿' if opt_type == 'P' else 'Call 下穿'}",
f"偏离:{deviation_pct:g}%",
f"最短到期:{float(min_expiry_hours):g}h",
"状态:待命",
],
)
return {"ok": True, "msg": "条件单已 armed", "order": order}
def cancel_cond_order(
conn: sqlite3.Connection,
hub_order_id: str,
*,
send_wechat: Any = None,
account_label: str = "",
) -> dict[str, Any]:
order = get_cond_order_by_hub_id(conn, hub_order_id)
if not order:
return {"ok": False, "msg": "条件单不存在"}
if order.get("status") not in ACTIVE_STATUSES:
return {"ok": True, "msg": "条件单已结束", "order": order}
if order.get("status") == "open":
return {"ok": False, "msg": "已开仓,请用平仓/出场结束;取消仅适用于未成交条件单"}
_end_order(
conn,
order,
end_reason="cancel",
message="中控取消",
send_wechat=send_wechat,
account_label=account_label,
)
return {"ok": True, "msg": "已取消", "order": get_cond_order_by_hub_id(conn, hub_order_id)}
def update_cond_order_exit(
conn: sqlite3.Connection,
hub_order_id: str,
*,
target_index: Any = None,
profit_exit_enabled: Any = None,
profit_exit_mult: Any = None,
) -> dict[str, Any]:
"""成交后改出场;若正在平仓挂单则先记 pending,本轮结束后再套用."""
order = get_cond_order_by_hub_id(conn, hub_order_id)
if not order:
return {"ok": False, "msg": "条件单不存在"}
if order.get("status") != "open":
return {"ok": False, "msg": "仅持仓中的条件单可改出场"}
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
new_target = _safe_float(target_index) if target_index is not None else order.get("target_index")
if target_index is not None and str(target_index).strip() == "":
new_target = None
if new_target is not None and new_target <= 0:
return {"ok": False, "msg": "target_index 无效"}
if profit_exit_enabled is None:
pe_en = bool(order.get("profit_exit_enabled"))
else:
pe_en = bool(profit_exit_enabled)
pe_mult = normalize_profit_exit_mult(
profit_exit_mult if profit_exit_mult is not None else order.get("profit_exit_mult"),
default=1.0,
)
if not pe_en and new_target is None:
return {"ok": False, "msg": "请至少配置目标位或翻倍出场"}
closing = _exit_close_in_progress(conn, str(order.get("opened_inst_id") or ""))
now = _now()
if closing:
conn.execute(
"""
UPDATE options_cond_orders SET
pending_target_index = ?,
pending_profit_exit_enabled = ?,
pending_profit_exit_mult = ?,
exit_update_pending = 1,
message = ?,
updated_at = ?
WHERE hub_order_id = ?
""",
(
float(new_target) if new_target is not None else None,
1 if pe_en else 0,
float(pe_mult),
"出场已更新,等待本轮平仓挂单结束后生效",
now,
hub_order_id,
),
)
conn.commit()
return {
"ok": True,
"msg": "已记录,本轮平仓结束后生效",
"deferred": True,
"order": get_cond_order_by_hub_id(conn, hub_order_id),
}
_apply_exit_to_position(
conn,
order,
target_index=new_target,
profit_exit_enabled=pe_en,
profit_exit_mult=pe_mult,
)
conn.execute(
"""
UPDATE options_cond_orders SET
target_index = ?,
profit_exit_enabled = ?,
profit_exit_mult = ?,
exit_update_pending = 0,
pending_target_index = NULL,
pending_profit_exit_enabled = NULL,
pending_profit_exit_mult = NULL,
message = ?,
updated_at = ?
WHERE hub_order_id = ?
""",
(
float(new_target) if new_target is not None else None,
1 if pe_en else 0,
float(pe_mult),
"出场已更新并生效",
now,
hub_order_id,
),
)
conn.commit()
return {"ok": True, "msg": "出场已更新", "order": get_cond_order_by_hub_id(conn, hub_order_id)}
def _exit_close_in_progress(conn: sqlite3.Connection, inst_id: str) -> bool:
if not inst_id:
return False
try:
from lib.options.options_target_lib import list_closing_targets
for t in list_closing_targets(conn):
if str(t.get("inst_id") or "") == inst_id:
return True
except Exception:
pass
try:
row = conn.execute(
"""
SELECT profit_exit_state FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC LIMIT 1
""",
(inst_id,),
).fetchone()
if row and str(row["profit_exit_state"] or "") == "closing":
return True
except Exception:
pass
return False
def _apply_exit_to_position(
conn: sqlite3.Connection,
order: dict[str, Any],
*,
target_index: float | None,
profit_exit_enabled: bool,
profit_exit_mult: float,
) -> None:
inst_id = str(order.get("opened_inst_id") or "").strip()
if not inst_id:
return
from lib.options.options_profit_exit_lib import set_profit_exit
from lib.options.options_target_lib import cancel_target_monitor, upsert_target_monitor
set_profit_exit(conn, inst_id=inst_id, enabled=profit_exit_enabled, mult=profit_exit_mult)
if target_index is not None:
upsert_target_monitor(
conn,
inst_id=inst_id,
target_index=float(target_index),
underlying=str(order.get("underlying") or ""),
opt_type=str(order.get("opt_type") or ""),
)
else:
cancel_target_monitor(conn, inst_id=inst_id)
conn.commit()
def _end_order(
conn: sqlite3.Connection,
order: dict[str, Any],
*,
end_reason: str,
message: str,
send_wechat: Any = None,
account_label: str = "",
index_px: float | None = None,
) -> None:
now = _now()
conn.execute(
"""
UPDATE options_cond_orders SET
status = 'done',
end_reason = ?,
message = ?,
updated_at = ?,
ended_at = ?
WHERE hub_order_id = ?
""",
(end_reason, message, now, now, order["hub_order_id"]),
)
conn.commit()
if callable(send_wechat):
try:
reason_map = {
"leave_up": "上破偏离区结束",
"leave_down": "下破偏离区结束",
"cancel": "中控取消",
"timeout": "周四23:00未触发自动取消",
"closed": "出场完成",
}
_notify_cond(
send_wechat,
title="【OKX期权·条件单结束】",
order=order,
account_label=account_label,
extra=[
f"原因:{reason_map.get(end_reason, end_reason)}",
f"说明:{message}",
]
+ ([f"当前指数:{index_px:g}"] if index_px is not None else []),
)
except Exception:
pass
def band_bounds(trigger_px: float, deviation_pct: float) -> tuple[float, float]:
d = max(0.0, float(deviation_pct)) / 100.0
lo = float(trigger_px) * (1.0 - d)
hi = float(trigger_px) * (1.0 + d)
return lo, hi
def pick_nearest_itm_contract(
cfg: dict[str, Any],
ex: Any,
*,
underlying: str,
opt_type: str,
index_px: float,
min_expiry_hours: float,
) -> dict[str, Any] | None:
"""按当前指数选最近一档实值;到期 ≥ min_expiry_hours."""
from lib.exchange.okx_options_lib import build_option_chain, option_buy_liquidity_ok
from lib.options.options_pricing_lib import option_moneyness
max_dte_days = max(float(min_expiry_hours) / 24.0 + 14.0, 14.0)
# 放宽实值距离:取全部 ITM,再按距离排序
chain = build_option_chain(
ex,
underlying,
max_dte_days=max_dte_days,
itm_only=False,
itm_max_dist_usd=1e12,
index_px=index_px,
)
min_ms = time.time() * 1000 + float(min_expiry_hours) * 3600 * 1000
want = (opt_type or "P").upper()
candidates: list[tuple[float, dict[str, Any]]] = []
expiries = chain.get("expiries") or []
# build_option_chain 返回 list[{exp_time, contracts}]
if isinstance(expiries, dict):
exp_iter = [{"exp_time": k, "contracts": v} for k, v in expiries.items()]
else:
exp_iter = list(expiries)
for bucket in exp_iter:
try:
exp_ms = int(bucket.get("exp_time") or 0)
except (TypeError, ValueError):
exp_ms = 0
if exp_ms and exp_ms < min_ms:
continue
for c in bucket.get("contracts") or []:
if str(c.get("opt_type") or "").upper() != want:
continue
strike = _safe_float(c.get("strike"))
if strike is None:
continue
mny = option_moneyness(opt_type=want, strike=strike, index_px=index_px)
if mny != "itm":
continue
dist = abs(float(strike) - float(index_px))
candidates.append((dist, c))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], str(x[1].get("inst_id") or "")))
# 优先有卖一深度的最近档
for _dist, c in candidates[:12]:
inst = str(c.get("inst_id") or "")
if not inst:
continue
try:
q = cfg["quote_option_contract"](ex, inst)
except Exception:
continue
ask = q.get("ask")
ask_sz = q.get("ask_sz")
ok, _ = option_buy_liquidity_ok(ask, ask_sz)
if ok:
out = dict(c)
out["quote"] = q
return out
# 深度都不够时仍返回最近档,由上层继续等
best = dict(candidates[0][1])
return best
def _fetch_index(cfg: dict[str, Any], ex: Any, underlying: str) -> float | None:
from lib.exchange.okx_options_lib import fetch_index_price
uly = f"{(underlying or 'ETH').upper()}-USD"
try:
return fetch_index_price(ex, uly)
except Exception:
return None
def _try_open_full(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
order: dict[str, Any],
) -> dict[str, Any]:
from lib.options.options_coin_open_lib import open_coin_option_buy_full
note = f"中控条件单:{order.get('hub_order_id')}"
target_index = _safe_float(order.get("target_index"))
pe_en = bool(order.get("profit_exit_enabled"))
pe_mult = float(order.get("profit_exit_mult") or 1.0)
# USDC 期权开仓已下线,条件单仅走币本位
return open_coin_option_buy_full(
cfg,
ex,
inst_id=inst_id,
signal_note=note,
target_index=target_index,
profit_exit_enabled=pe_en,
profit_exit_mult=pe_mult,
target_sheets=None,
)
def run_cond_orders_tick(
conn_or_get_db: Any,
cfg: dict[str, Any],
*,
send_wechat: Any = None,
account_label: str = "",
) -> None:
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾.
推荐传 get_db:开仓前会关闭连接,避免与买币/等待成交嵌套锁库。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
if get_db is None:
conn = conn_or_get_db
ensure_cond_order_table(conn)
expire_untriggered_cond_orders(
conn, send_wechat=send_wechat, account_label=account_label
)
ex = cfg.get("exchange_options")
if ex is None:
return
for order in list_cond_orders(conn, active_only=True):
try:
_tick_one(
conn,
cfg,
ex,
order,
send_wechat=send_wechat,
account_label=account_label,
get_db=None,
)
except Exception as e:
try:
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"监控异常: {e}"[:200], _now(), order["hub_order_id"]),
)
conn.commit()
except Exception:
pass
return
ex: Any = None
orders: list[dict[str, Any]] = []
conn = get_db()
try:
ensure_cond_order_table(conn)
expire_untriggered_cond_orders(
conn, send_wechat=send_wechat, account_label=account_label
)
ex = cfg.get("exchange_options")
if ex is not None:
orders = list_cond_orders(conn, active_only=True)
try:
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
if ex is None or not orders:
return
for order in orders:
conn = get_db()
try:
_tick_one(
conn,
cfg,
ex,
order,
send_wechat=send_wechat,
account_label=account_label,
get_db=get_db,
)
try:
conn.commit()
except Exception:
pass
except Exception as e:
try:
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"监控异常: {e}"[:200], _now(), order["hub_order_id"]),
)
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def _tick_one(
conn: sqlite3.Connection,
cfg: dict[str, Any],
ex: Any,
order: dict[str, Any],
*,
send_wechat: Any,
account_label: str,
get_db: Any = None,
) -> None:
status = str(order.get("status") or "")
underlying = str(order.get("underlying") or "ETH")
idx = _fetch_index(cfg, ex, underlying)
if idx is None or idx <= 0:
return
last = _safe_float(order.get("last_index"))
conn.execute(
"UPDATE options_cond_orders SET last_index=?, updated_at=? WHERE hub_order_id=?",
(float(idx), _now(), order["hub_order_id"]),
)
conn.commit()
trigger_px = float(order["trigger_px"])
lo, hi = band_bounds(trigger_px, float(order["deviation_pct"]))
side = str(order.get("trigger_side") or "gte")
if status == "armed":
crossed = False
if side == "gte" and last is not None and last < trigger_px and idx >= trigger_px:
crossed = True
elif side == "lte" and last is not None and last > trigger_px and idx <= trigger_px:
crossed = True
# 首次无 last:只记录,等下一跳再判穿越(避免挂单时已在上方立刻开)
if last is None:
return
if crossed:
conn.execute(
"""
UPDATE options_cond_orders SET status='triggering', crossed_at=?, message=?, updated_at=?
WHERE hub_order_id=?
""",
(_now(), f"已穿越 {trigger_px:g},等待偏离区内开仓", _now(), order["hub_order_id"]),
)
conn.commit()
order["status"] = "triggering"
status = "triggering"
else:
return
if status == "triggering":
if idx > hi:
_end_order(
conn,
order,
end_reason="leave_up",
message=f"指数 {idx:g} 上破偏离上沿 {hi:g}",
send_wechat=send_wechat,
account_label=account_label,
index_px=idx,
)
return
if idx < lo:
_end_order(
conn,
order,
end_reason="leave_down",
message=f"指数 {idx:g} 下破偏离下沿 {lo:g}",
send_wechat=send_wechat,
account_label=account_label,
index_px=idx,
)
return
# 区内:有仓则不再开
if machine_has_option_position(cfg, ex):
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
("偏离区内但已有持仓,等待", _now(), order["hub_order_id"]),
)
conn.commit()
return
picked = pick_nearest_itm_contract(
cfg,
ex,
underlying=underlying,
opt_type=str(order.get("opt_type") or "P"),
index_px=idx,
min_expiry_hours=float(order.get("min_expiry_hours") or 24),
)
if not picked or not picked.get("inst_id"):
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
("偏离区内暂无合适实值合约", _now(), order["hub_order_id"]),
)
conn.commit()
return
inst_id = str(picked["inst_id"])
# 深度检查
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
q = picked.get("quote") or cfg["quote_option_contract"](ex, inst_id)
ok_liq, msg_liq = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not ok_liq:
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"深度不足继续等待: {msg_liq or '无卖一'}", _now(), order["hub_order_id"]),
)
conn.commit()
return
# 开仓(买币/等成交)前释放本连接,避免嵌套锁库
try:
conn.commit()
except Exception:
pass
hub_id = order["hub_order_id"]
if get_db is not None:
try:
conn.close()
except Exception:
pass
conn = None
result = _try_open_full(cfg, ex, inst_id=inst_id, order=order)
if get_db is not None:
conn = get_db()
ensure_cond_order_table(conn)
assert conn is not None
if not result.get("ok"):
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"开仓未成继续等: {result.get('msg') or '失败'}"[:180], _now(), hub_id),
)
conn.commit()
return
trade_id = result.get("trade_id")
conn.execute(
"""
UPDATE options_cond_orders SET
status='open', opened_inst_id=?, opened_trade_id=?,
opened_at=COALESCE(opened_at, ?),
message=?, updated_at=?
WHERE hub_order_id=?
""",
(
inst_id,
int(trade_id) if trade_id is not None else None,
_now(),
f"已开仓 {inst_id}",
_now(),
hub_id,
),
)
conn.commit()
opened = get_cond_order_by_hub_id(conn, order["hub_order_id"]) or order
_notify_cond(
send_wechat,
title="【OKX期权·条件单开仓】",
order=opened,
account_label=account_label,
extra=[
f"合约:{inst_id}",
f"说明:已开仓 {inst_id}",
],
)
order2 = opened
order2["opened_inst_id"] = inst_id
_apply_exit_to_position(
conn,
order2,
target_index=_safe_float(order2.get("target_index")),
profit_exit_enabled=bool(order2.get("profit_exit_enabled")),
profit_exit_mult=float(order2.get("profit_exit_mult") or 1.0),
)
return
if status == "open":
# 延迟出场更新
if order.get("exit_update_pending") and not _exit_close_in_progress(
conn, str(order.get("opened_inst_id") or "")
):
pe_en = order.get("pending_profit_exit_enabled")
if pe_en is None:
pe_en = order.get("profit_exit_enabled")
_apply_exit_to_position(
conn,
order,
target_index=_safe_float(order.get("pending_target_index")),
profit_exit_enabled=bool(pe_en),
profit_exit_mult=float(
order.get("pending_profit_exit_mult")
if order.get("pending_profit_exit_mult") is not None
else order.get("profit_exit_mult")
or 1.0
),
)
conn.execute(
"""
UPDATE options_cond_orders SET
target_index = pending_target_index,
profit_exit_enabled = COALESCE(pending_profit_exit_enabled, profit_exit_enabled),
profit_exit_mult = COALESCE(pending_profit_exit_mult, profit_exit_mult),
exit_update_pending = 0,
pending_target_index = NULL,
pending_profit_exit_enabled = NULL,
pending_profit_exit_mult = NULL,
message = ?,
updated_at = ?
WHERE hub_order_id = ?
""",
("延迟出场已生效", _now(), order["hub_order_id"]),
)
conn.commit()
inst_id = str(order.get("opened_inst_id") or "")
if not inst_id:
return
if not machine_has_option_position(cfg, ex):
# 确认该合约已平
raw = cfg["fetch_option_positions"](ex) or []
still = any(str(p.get("instId") or "") == inst_id for p in raw)
if still:
return
_end_order(
conn,
order,
end_reason="closed",
message="持仓已平,条件单完成",
send_wechat=send_wechat,
account_label=account_label,
index_px=idx,
)
-8
View File
@@ -47,14 +47,11 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict
try:
init_options_tables(conn)
try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_target_lib import targets_by_inst
tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
except Exception:
tgt_map = {}
hedge_target_map = {}
for p in raw:
if not isinstance(p, dict):
@@ -74,11 +71,6 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict
row["target_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
if hedge_target:
row["hedge_plan_target"] = hedge_target
if not mon:
row["target_index"] = hedge_target.get("target_index")
rows.append(row)
finally:
conn.close()
+140
View File
@@ -95,6 +95,19 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ON options_target_monitors(status)
"""
)
# 实盘统计:按期权历史 history_key 冻结交易所盈亏折 U,避免跟实时指数漂移
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_exchange_pnl_freeze (
history_key TEXT PRIMARY KEY,
realized_pnl REAL,
realized_pnl_usdt REAL,
close_index_px REAL,
premium_ccy TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
@@ -105,6 +118,23 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
"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",
# 分批平仓累计:全平前写在最早一笔 open 上,全平后并入 premium_received
"ALTER TABLE options_trades ADD COLUMN close_recv_accum REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_sheets_accum INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_notional_accum REAL DEFAULT 0",
# 手续费(权利金单位)与平仓冻结 U 口径
"ALTER TABLE options_trades ADD COLUMN open_fee REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_fee REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN fee_ccy TEXT",
"ALTER TABLE options_trades ADD COLUMN close_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN open_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt REAL",
# 交易账户快照盈亏(一次一仓):before→after 差分冻结
"ALTER TABLE options_trades ADD COLUMN trading_usdt_before REAL",
"ALTER TABLE options_trades ADD COLUMN trading_usdt_after REAL",
"ALTER TABLE options_trades ADD COLUMN pnl_settled INTEGER DEFAULT 0",
# 初始权利金(U)=开仓指数×权利金币量,不含多买缓冲
"ALTER TABLE options_trades ADD COLUMN initial_premium_usdt REAL",
):
try:
conn.execute(ddl)
@@ -116,6 +146,12 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ensure_bridge_table(conn)
except Exception:
pass
try:
from lib.options.options_cond_order_lib import ensure_cond_order_table
ensure_cond_order_table(conn)
except Exception:
pass
init_options_review_tables(conn)
@@ -138,6 +174,27 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
return round(float(row["total"] or 0), 8)
def sum_open_budget_usdt(conn: sqlite3.Connection, inst_id: str) -> float | None:
"""同合约 open 腿开仓预算/兑换占用 USDT 合计(权利金折 U 展示用,不随币价变)."""
inst = (inst_id or "").strip()
if not inst:
return None
row = conn.execute(
"""
SELECT SUM(budget_usdt) AS total, COUNT(*) AS n
FROM options_trades
WHERE inst_id = ? AND status = 'open' AND budget_usdt IS NOT NULL
""",
(inst,),
).fetchone()
if not row or int(row["n"] or 0) < 1:
return None
try:
return round(float(row["total"] or 0), 4)
except (TypeError, ValueError):
return None
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
"""同合约所有 open 腿张数合计."""
inst = (inst_id or "").strip()
@@ -154,3 +211,86 @@ def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
if not row or int(row["n"] or 0) < 1:
return None
return int(row["total"] or 0)
def _earliest_open_trade_id(conn: sqlite3.Connection, inst_id: str) -> int | None:
inst = (inst_id or "").strip()
if not inst:
return None
row = conn.execute(
"""
SELECT id FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
LIMIT 1
""",
(inst,),
).fetchone()
return int(row["id"]) if row else None
def get_open_close_accum(conn: sqlite3.Connection, inst_id: str) -> dict[str, float | int | str | None]:
"""读取分批平仓累计(挂在最早一笔 open 上)."""
tid = _earliest_open_trade_id(conn, inst_id)
if tid is None:
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
row = conn.execute(
"""
SELECT close_recv_accum, close_sheets_accum, close_notional_accum, close_ord_id
FROM options_trades WHERE id = ?
""",
(tid,),
).fetchone()
if not row:
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
return {
"recv": float(row["close_recv_accum"] or 0),
"sheets": int(row["close_sheets_accum"] or 0),
"notional": float(row["close_notional_accum"] or 0),
"close_ord_id": str(row["close_ord_id"] or "").strip() or None,
}
def add_open_close_accum(
conn: sqlite3.Connection,
inst_id: str,
*,
recv: float,
sheets: int,
px: float,
ord_id: str | None = None,
) -> bool:
"""分批平仓成交后累加收回权利金/张数/名义(供全平 VWAP 与总盈亏)."""
tid = _earliest_open_trade_id(conn, inst_id)
if tid is None:
return False
sheets_i = max(0, int(sheets or 0))
recv_f = float(recv or 0)
px_f = float(px or 0)
if sheets_i < 1 and abs(recv_f) < 1e-15:
return False
notional_add = px_f * sheets_i if sheets_i > 0 and px_f > 0 else 0.0
oid = str(ord_id or "").strip()
prev = conn.execute(
"SELECT close_ord_id FROM options_trades WHERE id = ?", (tid,)
).fetchone()
prev_oids = str((prev["close_ord_id"] if prev else None) or "").strip()
if oid:
parts = [p for p in prev_oids.split(",") if p] if prev_oids else []
if oid not in parts:
parts.append(oid)
merged_oid = ",".join(parts)
else:
merged_oid = prev_oids or None
conn.execute(
"""
UPDATE options_trades
SET close_recv_accum = COALESCE(close_recv_accum, 0) + ?,
close_sheets_accum = COALESCE(close_sheets_accum, 0) + ?,
close_notional_accum = COALESCE(close_notional_accum, 0) + ?,
close_ord_id = ?
WHERE id = ?
""",
(recv_f, sheets_i, notional_add, merged_oid, tid),
)
return True
+33 -3
View File
@@ -29,6 +29,25 @@ def enrich_position_row_display(
if premium_override is not None:
row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
try:
from lib.options.options_pricing_lib import equivalent_contract_leverage
def _sf(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
row["contract_leverage"] = equivalent_contract_leverage(
index_px=_sf(row.get("idx_px")),
eth_amount=_sf(row.get("eth_amount")),
total_premium=_sf(row.get("premium_paid")),
margin_mode=row_mode,
)
except Exception:
row.setdefault("contract_leverage", None)
return row
@@ -48,6 +67,8 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
if raw_live is None:
return []
prem_by_inst: dict[str, float | None] = {}
hidden_keys: set[str] = set()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
@@ -57,7 +78,18 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
}
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
if inst:
prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = prem_by_inst.get(inst)
row = enrich_position_row_display(
cfg,
ex,
@@ -73,8 +105,6 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
except (TypeError, ValueError):
open_ms = None
items.append(format_live_option_history_row(row, open_ms=open_ms))
finally:
conn.close()
hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw:
+46 -15
View File
@@ -23,20 +23,20 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = build_display_option_positions(cfg, ex, raw)
# 中控需同步实例买一/流动性;盘口在 DB 已释放后拉取(见 build_display_option_positions)
positions = build_display_option_positions(
cfg, ex, raw, include_close_preview=True
)
target_monitors: list[dict[str, Any]] = []
try:
conn = cfg["get_db"]()
try:
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
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = 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())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
@@ -59,12 +59,6 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
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 ""))
if hedge_target:
p["hedge_plan_target"] = hedge_target
if not mon:
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
p["target_index"] = hedge_target.get("target_index")
try:
from lib.instance.instance_dashboard_lib import (
_format_options_target,
@@ -85,13 +79,14 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn.close()
except Exception:
target_monitors = []
from lib.options.options_positions_lib import display_pnl_from_option_row
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
upl_total = 0.0
has_upl = False
for p in positions:
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
pnl = display_pnl_from_option_row(p)
# 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_usdt_from_option_row(p)
p["display_pnl_usdt"] = pnl
if pnl is None:
continue
has_upl = True
@@ -138,7 +133,10 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
except Exception:
open_bridges = []
return {
from lib.options.options_underly_lib import underly_cfg_snapshot
_uly_snap = underly_cfg_snapshot()
out = {
"ok": True,
"enabled": True,
"positions": positions,
@@ -157,10 +155,43 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
"max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH",
"options_underly": _uly_snap["default_underly"],
"options_allowed_underlys": _uly_snap["allowed_underlys"],
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
"cond_order_locked": False,
"cond_orders_active": [],
"transfer_blocked": False,
"transfer_block_msg": "",
}
try:
from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders
conn_c = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn_c)
out["cond_order_locked"] = has_active_cond_order(conn_c)
out["cond_orders_active"] = list_cond_orders(conn_c, active_only=True)
finally:
conn_c.close()
except Exception:
pass
try:
from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg
conn_tb = cfg["get_db"]()
try:
tmsg = transfer_blocked_by_position_msg(conn_tb, ex)
if tmsg:
out["transfer_blocked"] = True
out["transfer_block_msg"] = tmsg
finally:
conn_tb.close()
except Exception:
pass
return out
except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)}
+16 -24
View File
@@ -24,30 +24,21 @@ def _env_float(name: str, default: float) -> float:
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
"""币本位专用机:始终 coin;历史 usdc 配置读取时亦归一为 coin."""
del raw
return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool:
return normalize_options_margin_mode(raw) == MODE_COIN
del raw
return True
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
"""仅币本位期权族 {U}-USD;USD_UM 已下线."""
del margin_mode
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:
@@ -63,9 +54,10 @@ def margin_mode_from_inst_id(inst_id: str) -> str:
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"
"""币本位权利金为标的币;历史 USDC 行(margin_mode=usdc)仍标 USDC."""
if str(margin_mode or "").strip().lower() == MODE_USDC:
return "USDC"
return (underlying or "ETH").strip().upper() or "ETH"
def spot_quote_inst_id(underlying: str) -> str:
@@ -90,9 +82,9 @@ def compute_coin_budget_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)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 1.0)
if buf <= 0:
buf = 0.95
buf = 1.0
fixed = (
float(fixed_budget_usdt)
if fixed_budget_usdt is not None
@@ -137,18 +129,18 @@ def compute_coin_budget_usdt(
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.05(=多买 5%).
也可写 0.05 表示 +5%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.05)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
v = 1.05
if v <= 0:
return 1.10
return 1.05
if v < 1.0:
return 1.0 + v
return v
+222 -44
View File
@@ -1,16 +1,12 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations
import os
import sqlite3
import time
from datetime import datetime, timezone
from typing import Any, Callable
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
def _safe_float(v: Any) -> float | None:
@@ -133,12 +129,16 @@ def run_options_profit_alerts(
premium_ccy=str(bucket.get("premium_ccy") or pos.get("premium_ccy") or ""),
)
try:
# 推送前先提交,避免握锁等 webhook
try:
conn.commit()
except Exception:
pass
send_wechat(msg)
conn.execute(
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
tuple(bucket["ids"]),
)
# 立即提交,避免后续目标/翻倍/同步异常回滚后每轮重推
try:
conn.commit()
except Exception:
@@ -151,18 +151,7 @@ def run_options_profit_alerts(
def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if not created_at:
return None
raw = str(created_at).strip()
if not raw:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
return options_wall_clock_to_ms(created_at)
def _group_key_for_closed_trade(row: Any) -> str:
@@ -198,7 +187,8 @@ def backfill_closed_options_realized_pnl_from_history(
rows = conn.execute(
"""
SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote,
created_at, closed_at, close_ord_id
created_at, closed_at, close_ord_id, realized_pnl_usdt,
open_fee, close_fee, premium_ccy, underlying
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
@@ -209,8 +199,16 @@ def backfill_closed_options_realized_pnl_from_history(
if not rows:
return 0
from lib.options.options_pnl_settle_lib import (
resolve_close_index_px,
settle_exchange_realized_pnl,
)
groups: dict[str, list[Any]] = {}
for row in rows:
# 已冻结 U 的本地结算不再被交易所历史覆盖
if _safe_float(row["realized_pnl_usdt"]) is not None:
continue
inst = str(row["inst_id"] or "").strip()
if not inst or inst not in by_inst:
continue
@@ -244,6 +242,8 @@ def backfill_closed_options_realized_pnl_from_history(
total_paid = 0.0
for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
underly = str(group[0]["underlying"] or "").strip()
close_idx = resolve_close_index_px(None, inst_id=inst, underlying=underly)
allocated = 0.0
for i, r in enumerate(group):
paid = float(_safe_float(r["premium_paid"]) or 0.0)
@@ -265,17 +265,37 @@ def backfill_closed_options_realized_pnl_from_history(
ord_ok = (not matched_pos) or (local_ord == matched_pos)
if pnl_ok and quote_ok and ord_ok:
continue
prem_recv = round(paid + share, 4)
# OKX realizedPnl 已含手续费,只冻结折 U,禁止再减 open_fee/close_fee
prem_ccy = str(r["premium_ccy"] or "").strip() or "ETH"
settled = settle_exchange_realized_pnl(
realized_pnl=share,
premium_ccy=prem_ccy,
index_px=close_idx,
)
# 展示用收回≈已付+净盈亏+已记手续费(毛额近似,不参与再扣费)
ofee = float(_safe_float(r["open_fee"]) or 0.0)
cfee = float(_safe_float(r["close_fee"]) or 0.0)
prem_recv = round(paid + share + ofee + cfee, 4)
conn.execute(
"""
UPDATE options_trades
SET realized_pnl = ?,
premium_received = ?,
close_quote = COALESCE(?, close_quote),
close_ord_id = COALESCE(?, close_ord_id)
WHERE id = ?
close_ord_id = COALESCE(?, close_ord_id),
close_index_px = COALESCE(?, close_index_px),
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt)
WHERE id = ? AND realized_pnl_usdt IS NULL
""",
(share, prem_recv, close_quote, matched_pos, int(r["id"])),
(
settled.get("realized_pnl"),
prem_recv,
close_quote,
matched_pos,
settled.get("close_index_px"),
settled.get("realized_pnl_usdt"),
int(r["id"]),
),
)
updated += 1
return updated
@@ -294,13 +314,21 @@ def sync_open_options_trades(
"""
rows = conn.execute(
"""
SELECT id, inst_id, premium_paid, exp_time, created_at
SELECT id, inst_id, premium_paid, exp_time, created_at,
open_fee, premium_ccy, underlying, exchange_ord_id,
budget_usdt, open_index_px, fee_ccy
FROM options_trades
WHERE status = 'open'
"""
).fetchall()
updated = 0
closed_inst_ids: list[str] = []
now_ms = int(time.time() * 1000)
from lib.options.options_pnl_settle_lib import (
resolve_close_index_px,
settle_closed_pnl,
)
for row in rows:
inst_id = str(row["inst_id"] or "")
if not inst_id or inst_id in live_inst_ids:
@@ -323,25 +351,60 @@ def sync_open_options_trades(
close_quote = close_info.get("close_quote")
realized_pnl = close_info.get("realized_pnl")
close_ord_id = close_info.get("pos_id")
if realized_pnl is not None:
prem_recv = round(paid + float(realized_pnl), 4)
close_ms = close_info.get("close_ms")
if close_ms:
closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
closed_at = ms_to_options_app_str(close_ms)
elif exp_ms is not None and now_ms >= int(exp_ms):
close_reason = "expired"
close_quote = 0.0
prem_recv = 0.0
realized_pnl = round(-paid, 4)
if exp_ms:
closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
closed_at = ms_to_options_app_str(exp_ms)
else:
continue
if not closed_at:
closed_at = options_app_now_str()
open_fee = float(_safe_float(row["open_fee"]) or 0.0)
prem_ccy = str(row["premium_ccy"] or "").strip() or "ETH"
underly = str(row["underlying"] or "").strip()
close_idx = resolve_close_index_px(None, inst_id=inst_id, underlying=underly)
budget = _safe_float(row["budget_usdt"])
open_idx = _safe_float(row["open_index_px"])
fee_ccy = str(row["fee_ccy"] or prem_ccy or "")
if close_reason == "exchange" and realized_pnl is not None:
# OKX realizedPnl 已含费 → 反推毛收回,再按开仓成本折 U(不再二次扣费)
prem_recv = round(paid + float(realized_pnl) + open_fee, 4)
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=prem_recv,
open_fee=open_fee,
close_fee=0,
premium_ccy=prem_ccy,
index_px=close_idx,
open_index_px=open_idx,
budget_usdt=budget,
fee_ccy=fee_ccy,
)
else:
if prem_recv is None:
prem_recv = 0.0
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=prem_recv,
open_fee=open_fee,
close_fee=0,
premium_ccy=prem_ccy,
index_px=close_idx,
open_index_px=open_idx,
budget_usdt=budget,
fee_ccy=fee_ccy,
)
realized_pnl = settled.get("realized_pnl")
pnl_usdt = settled.get("realized_pnl_usdt")
conn.execute(
"""
UPDATE options_trades
@@ -350,7 +413,11 @@ def sync_open_options_trades(
premium_received = ?,
realized_pnl = ?,
close_ord_id = COALESCE(?, close_ord_id),
closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP),
closed_at = COALESCE(?, closed_at),
close_index_px = COALESCE(?, close_index_px),
open_index_px = COALESCE(open_index_px, ?),
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt),
fee_ccy = COALESCE(fee_ccy, ?),
signal_note = CASE
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
THEN '到期结算'
@@ -364,11 +431,16 @@ def sync_open_options_trades(
realized_pnl,
close_ord_id,
closed_at,
settled.get("close_index_px"),
settled.get("open_index_px"),
pnl_usdt,
prem_ccy,
close_reason,
int(row["id"]),
),
)
updated += 1
closed_inst_ids.append(inst_id)
if notify_cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
@@ -387,9 +459,80 @@ def sync_open_options_trades(
)
except Exception:
pass
if notify_cfg is not None:
_notify_recent_unsent_expiry_closes(conn, notify_cfg)
# 卖币不在此处做:调用方须先 commit/close 本连接,再调 sell_spot_after_options_sync,
# 否则监控线程握锁时中控快照会超时,表现为全部离线。
return updated
def sell_spot_after_options_sync(
cfg: dict[str, Any] | None,
closed_inst_ids: list[str] | None = None,
) -> None:
"""在释放业务 DB 连接后调用:同步关仓/到期后的币本位卖回."""
if not isinstance(cfg, dict):
return
ex = cfg.get("exchange_options")
if ex is None:
return
try:
from lib.options.options_coin_open_lib import (
maybe_sell_spot_after_close,
sweep_spot_after_options_flat,
)
seen: set[str] = set()
for inst_id in closed_inst_ids or []:
iid = str(inst_id or "").strip()
if not iid or iid in seen:
continue
seen.add(iid)
try:
maybe_sell_spot_after_close(cfg, ex, inst_id=iid)
except Exception:
pass
sweep_spot_after_options_flat(cfg, ex)
except Exception:
pass
def _notify_recent_unsent_expiry_closes(
conn: sqlite3.Connection,
notify_cfg: dict[str, Any],
*,
within_hours: float = 48.0,
) -> None:
"""页面同步曾漏推时,补发近期到期结算."""
try:
from lib.options.options_notify_lib import notify_options_close_trade_ids
from lib.options.options_time_lib import options_wall_clock_to_ms
now_ms = int(time.time() * 1000)
by_inst: dict[str, list[int]] = {}
rows = conn.execute(
"""
SELECT id, inst_id, closed_at
FROM options_trades
WHERE status = 'closed'
AND COALESCE(wechat_close_sent, 0) = 0
AND COALESCE(signal_note, '') = '到期结算'
ORDER BY id DESC
LIMIT 20
"""
).fetchall()
for r in rows:
closed_ms = options_wall_clock_to_ms(r["closed_at"])
if closed_ms is None or now_ms - int(closed_ms) > float(within_hours) * 3600 * 1000:
continue
inst = str(r["inst_id"] or "")
by_inst.setdefault(inst, []).append(int(r["id"]))
for ids in by_inst.values():
notify_options_close_trade_ids(notify_cfg, conn, ids, reason="到期结算")
except Exception:
pass
def reconcile_live_open_trades(
conn: sqlite3.Connection,
*,
@@ -456,6 +599,7 @@ def options_monitor_loop(
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,
cond_order_tick_fn: Callable[[sqlite3.Connection], None] | None = None,
stop_event: Any = None,
) -> None:
if not enabled:
@@ -464,9 +608,25 @@ def options_monitor_loop(
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
break
try:
conn = get_db()
# 持仓拉取不握 DB,避免与模拟盘钱包写互相锁
try:
positions = fetch_positions()
except Exception:
positions = []
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
# 条件单自管连接(开仓前会释放)
if cond_order_tick_fn is not None:
try:
cond_order_tick_fn(None)
except Exception:
pass
conn = get_db()
try:
run_options_profit_alerts(
conn,
positions,
@@ -475,25 +635,37 @@ def options_monitor_loop(
account_label=account_label,
ticker_bid_fn=ticker_bid_fn,
)
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
# 目标/翻倍出场:传 get_db,平仓时不握业务连接
if target_close_fn is not None:
from lib.options.options_target_lib import run_options_target_closes
try:
run_options_target_closes(
conn,
get_db,
positions,
close_fn=target_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
)
except Exception:
pass
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
try:
run_options_profit_exits(
conn,
get_db,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
@@ -501,12 +673,18 @@ def options_monitor_loop(
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
except Exception:
pass
if sync_trades_fn is not None:
sync_trades_fn(conn)
conn.commit()
finally:
conn.close()
# 平仓限价挂单超时撤单(独立于 DB 事务)
try:
sync_trades_fn(None)
except Exception:
pass
try:
sell_spot_after_options_sync(pe_cfg)
except Exception:
pass
if stale_pending_fn is not None:
try:
stale_pending_fn()
+44
View File
@@ -58,6 +58,38 @@ def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
pass
_BOUND_NOTIFY_CFG: dict[str, Any] | None = None
def bind_options_notify_cfg(cfg: dict[str, Any] | None) -> None:
global _BOUND_NOTIFY_CFG
_BOUND_NOTIFY_CFG = cfg
def active_options_notify_cfg(cfg: dict[str, Any] | None = None) -> dict[str, Any]:
if cfg is not None and callable(cfg.get("send_wechat")):
return cfg
if _BOUND_NOTIFY_CFG is not None and callable(_BOUND_NOTIFY_CFG.get("send_wechat")):
return _BOUND_NOTIFY_CFG
import os
from lib.common.wechat_notify_lib import send_wechat_webhook
webhook = (os.getenv("WECHAT_WEBHOOK") or "").strip()
try:
timeout = int(float(os.getenv("WECHAT_TIMEOUT_SECONDS") or "10"))
except (TypeError, ValueError):
timeout = 10
label = (
os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or os.getenv("OKX_ACCOUNT_LABEL") or "OKX期权"
).strip()
def send(content: str) -> None:
send_wechat_webhook(webhook, content, timeout=timeout)
return {"send_wechat": send, "account_label": label}
def notify_options_send(cfg: dict[str, Any], content: str) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
@@ -200,6 +232,12 @@ def notify_options_open(
trade_id=trade_id,
premium_ccy=ccy,
)
# 推送前先提交,避免握锁等 webhook 导致模拟盘 database is locked
if conn is not None:
try:
conn.commit()
except Exception:
pass
ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None:
conn.execute(
@@ -304,6 +342,12 @@ def notify_options_close(
trade_id=head.get("id") if len(rows) == 1 else None,
premium_ccy=ccy,
)
# 推送企业微信前先提交,避免握锁等待 webhook(可达数秒)导致卖币/快照 database is locked
if conn is not None:
try:
conn.commit()
except Exception:
pass
ok = notify_options_send(cfg, msg)
if ok and conn is not None:
for r in pending or rows:
+477
View File
@@ -0,0 +1,477 @@
"""已实现盈亏结算:扣手续费,并在平仓时按指数冻结 U 口径."""
from __future__ import annotations
from typing import Any
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 fee_amount_abs(fee: Any) -> float:
"""OKX 扣费多为负;统一为正数「已付手续费」."""
f = _safe_float(fee)
if f is None:
return 0.0
return abs(float(f))
def fee_to_premium_ccy(
fee_amt: Any,
fee_ccy: Any,
*,
premium_ccy: str,
index_px: float | None,
) -> float:
"""把手续费折成权利金币种数量(与 premium_paid/received 同单位)."""
amt = fee_amount_abs(fee_amt)
if amt <= 0:
return 0.0
fc = str(fee_ccy or "").strip().upper()
pc = str(premium_ccy or "").strip().upper()
if not fc or not pc or fc == pc:
return round(amt, 8)
# 稳定币互兑近似 1:1
stables = {"USDT", "USDC", "USD"}
if fc in stables and pc in stables:
return round(amt, 8)
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return 0.0
if fc in stables and pc in ("ETH", "BTC"):
return round(amt / float(idx), 8)
if fc in ("ETH", "BTC") and pc in stables:
return round(amt * float(idx), 8)
if fc in ("ETH", "BTC") and pc in ("ETH", "BTC") and fc != pc:
# 跨币种无可靠汇率时放弃折算,避免脏数据
return 0.0
return round(amt, 8)
def extract_order_fee(order: dict[str, Any] | None) -> tuple[float, str]:
"""从下单/查单结果取手续费(原始币种,正数)."""
if not isinstance(order, dict):
return 0.0, ""
raw = order.get("raw") if isinstance(order.get("raw"), dict) else {}
info = order.get("info") if isinstance(order.get("info"), dict) else {}
fee = order.get("fee")
if fee is None:
fee = raw.get("fee")
if fee is None:
fee = info.get("fee")
ccy = (
order.get("fee_ccy")
or raw.get("feeCcy")
or raw.get("fee_ccy")
or info.get("fee_ccy")
or info.get("feeCcy")
or ""
)
return fee_amount_abs(fee), str(ccy or "").strip().upper()
def sum_option_order_fees_in_premium(
ex: Any,
*,
inst_id: str,
ord_ids: list[str] | str | None,
premium_ccy: str,
index_px: float | None,
fetch_order_fn: Any = None,
) -> float:
"""按订单号汇总手续费并折成权利金单位."""
if isinstance(ord_ids, str):
ids = [p.strip() for p in ord_ids.split(",") if p.strip()]
else:
ids = [str(x).strip() for x in (ord_ids or []) if str(x).strip()]
if not ids:
return 0.0
if fetch_order_fn is None:
from lib.exchange.okx_options_lib import fetch_option_order
fetch_order_fn = fetch_option_order
total = 0.0
seen: set[str] = set()
for oid in ids:
if oid in seen:
continue
seen.add(oid)
try:
info = fetch_order_fn(ex, inst_id=inst_id, ord_id=oid)
except Exception:
continue
if not isinstance(info, dict) or not info.get("ok"):
continue
fee_amt, fee_ccy = extract_order_fee(info)
total += fee_to_premium_ccy(
fee_amt, fee_ccy, premium_ccy=premium_ccy, index_px=index_px
)
return round(total, 8)
def freeze_pnl_to_usdt(
net_pnl: float,
*,
premium_ccy: str,
index_px: float | None,
total_fee: float = 0.0,
) -> dict[str, float | None]:
"""把已是「净额」的权利金盈亏冻结为 U(不再扣费)."""
net = round(float(net_pnl), 8)
pc = str(premium_ccy or "").strip().upper()
idx = _safe_float(index_px)
fee_total = abs(float(total_fee or 0.0))
if pc in ("ETH", "BTC"):
if idx is None or idx <= 0:
return {
"realized_pnl": net,
"close_index_px": None,
"realized_pnl_usdt": None,
"total_fee": round(fee_total, 8),
}
return {
"realized_pnl": net,
"close_index_px": float(idx),
"realized_pnl_usdt": round(net * float(idx), 4),
"total_fee": round(fee_total, 8),
}
return {
"realized_pnl": net,
"close_index_px": float(idx) if idx is not None and idx > 0 else None,
"realized_pnl_usdt": round(net, 4),
"total_fee": round(fee_total, 8),
}
def fee_to_usdt(
fee_amt: Any,
fee_ccy: Any,
*,
index_px: float | None,
) -> float:
"""手续费折 USDT."""
amt = fee_amount_abs(fee_amt)
if amt <= 0:
return 0.0
fc = str(fee_ccy or "").strip().upper()
if fc in ("USDT", "USDC", "USD", ""):
# 空币种时按稳定币近似,避免把小数额 ETH 费当成整 U
if fc == "" and amt < 0.05:
idx = _safe_float(index_px)
if idx is not None and idx > 100:
return round(amt * float(idx), 4)
return round(amt, 4)
if fc in ("ETH", "BTC"):
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return 0.0
return round(amt * float(idx), 4)
return round(amt, 4)
def true_pnl_usdt_premium_move(
*,
coin_pnl: Any,
premium_paid: Any = None,
sell_back_px: Any = None,
initial_premium_usdt: Any = None,
open_index_px: Any = None,
) -> float | None:
"""复盘真盈亏(U)=期权历史币盈亏×平仓价.
平仓价优先卖回 VWAP,否则用冻结/传入的 close 价.
premium_paid / initial_premium_usdt / open_index_px 仅兼容旧调用,不参与计算.
"""
del premium_paid, initial_premium_usdt, open_index_px
pnl = _safe_float(coin_pnl)
close_px = _safe_float(sell_back_px)
if pnl is None or close_px is None or close_px <= 0:
return None
return round(float(pnl) * float(close_px), 4)
def resolve_spot_vwap_prices(
conn: Any = None,
*,
bridge_id: Any = None,
open_index_px: Any = None,
close_index_px: Any = None,
) -> tuple[float | None, float | None, str]:
"""开仓价/平仓价解析(期权盈亏折U用).
平仓价:桥上卖回 VWAP > 冻结 close_index_px > 开仓价.
桥未关不再阻断折U(现货卖回与期权盈亏脱钩).
返回 (open_px, close_px, gate):
gate='' 可算; 'missing' 缺价
"""
open_px = _safe_float(open_index_px)
close_hint = _safe_float(close_index_px)
close_px = close_hint
bid = None
try:
if bridge_id is not None and str(bridge_id).strip() != "":
bid = int(bridge_id)
except (TypeError, ValueError):
bid = None
if conn is not None and bid is not None:
try:
from lib.options.options_spot_bridge_lib import (
bridge_buy_vwap,
bridge_sell_vwap,
ensure_bridge_table,
)
ensure_bridge_table(conn)
if open_px is None or open_px <= 0:
bv = bridge_buy_vwap(conn, bid)
if bv is not None and bv > 0:
open_px = bv
sv = bridge_sell_vwap(conn, bid)
if sv is not None and sv > 0:
close_px = sv
except Exception:
pass
if close_px is None or close_px <= 0:
close_px = open_px
if close_px is None or close_px <= 0:
return open_px, close_px, "missing"
return open_px, close_px, ""
def resolve_true_pnl_usdt_from_trade(
*,
premium_paid: Any = None,
premium_received: Any = None,
realized_pnl: Any = None,
open_index_px: Any = None,
close_index_px: Any = None,
initial_premium_usdt: Any = None,
open_fee: Any = 0,
close_fee: Any = 0,
premium_ccy: str | None = None,
fee_ccy: str | None = None,
conn: Any = None,
bridge_id: Any = None,
) -> tuple[float | None, str]:
"""由成交字段算复盘真盈亏: 期权币盈亏×平仓价.
返回 (pnl_u, source): premium_move | stable | none
"""
del fee_ccy, initial_premium_usdt
pc = str(premium_ccy or "").strip().upper()
paid = _safe_float(premium_paid)
recv = _safe_float(premium_received)
ofee = abs(float(_safe_float(open_fee) or 0.0))
cfee = abs(float(_safe_float(close_fee) or 0.0))
# 期权历史币盈亏已含开/平手续费;真盈亏不再另扣费
coin = _safe_float(realized_pnl)
if coin is None and paid is not None and recv is not None:
coin = round(float(recv) - float(paid) - ofee - cfee, 8)
if pc not in ("ETH", "BTC"):
if coin is not None:
return round(float(coin), 4), "stable"
return None, "none"
_open_px, close_px, gate = resolve_spot_vwap_prices(
conn,
bridge_id=bridge_id,
open_index_px=open_index_px,
close_index_px=close_index_px,
)
if gate == "missing":
return None, "none"
usdt = true_pnl_usdt_premium_move(
coin_pnl=coin,
sell_back_px=close_px,
)
if usdt is not None:
return usdt, "premium_move"
return None, "none"
def settle_sim_coin_pnl_usdt(
*,
premium_paid: Any,
premium_received: Any,
open_fee: Any = 0,
close_fee: Any = 0,
open_index_px: float | None,
sell_back_px: float | None,
fee_ccy: str | None = None,
premium_ccy: str = "ETH",
) -> dict[str, float | None]:
"""由毛权利金生成净币盈亏,再按期权盈亏折 U(费只扣进币盈亏一次).
币盈亏 = 收回 − 已付 − 开/平手续费
真盈亏U = 币盈亏 × 平仓价
"""
del fee_ccy
paid = float(_safe_float(premium_paid) or 0.0)
recv = float(_safe_float(premium_received) or 0.0)
ofee = abs(float(_safe_float(open_fee) or 0.0))
cfee = abs(float(_safe_float(close_fee) or 0.0))
open_idx = _safe_float(open_index_px)
sell_px = _safe_float(sell_back_px)
net_coin = round(recv - paid - ofee - cfee, 8)
fee_u = None
if open_idx is not None and open_idx > 0 and sell_px is not None and sell_px > 0:
fee_u = round(ofee * float(open_idx) + cfee * float(sell_px), 4)
elif sell_px is not None and sell_px > 0 and (ofee > 0 or cfee > 0):
fee_u = round((ofee + cfee) * float(sell_px), 4)
if sell_px is None or sell_px <= 0:
return {
"realized_pnl": net_coin,
"close_index_px": None,
"open_index_px": float(open_idx) if open_idx is not None and open_idx > 0 else None,
"realized_pnl_usdt": None,
"total_fee": round(ofee + cfee, 8),
"initial_premium_usdt": (
round(float(paid) * float(open_idx), 4)
if open_idx is not None and open_idx > 0 and paid > 0
else None
),
"proceeds_usdt": None,
"fee_usdt": fee_u,
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
}
initial_u = (
float(paid) * float(open_idx)
if open_idx is not None and open_idx > 0
else None
)
proceeds_u = float(recv) * float(sell_px)
pnl_u = true_pnl_usdt_premium_move(
coin_pnl=net_coin,
sell_back_px=sell_px,
)
return {
"realized_pnl": net_coin,
"close_index_px": float(sell_px),
"open_index_px": float(open_idx) if open_idx is not None and open_idx > 0 else None,
"realized_pnl_usdt": pnl_u,
"total_fee": round(ofee + cfee, 8),
"initial_premium_usdt": round(initial_u, 4) if initial_u is not None else None,
"proceeds_usdt": round(proceeds_u, 4),
"fee_usdt": fee_u,
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
}
def settle_closed_pnl(
*,
premium_paid: Any,
premium_received: Any,
open_fee: Any = 0,
close_fee: Any = 0,
premium_ccy: str,
index_px: float | None,
open_index_px: float | None = None,
budget_usdt: float | None = None,
fee_ccy: str | None = None,
) -> dict[str, float | None]:
"""
由「毛权利金」结算.
币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次).
币本位 U: 净币盈亏×平仓价(币量不含现货多余仓)
USDC: 净额原样为 U.
注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲.
不要对 OKX realizedPnl 再调此函数扣费.
"""
del budget_usdt # 买币预算不是权利金成本
paid = float(_safe_float(premium_paid) or 0.0)
recv = float(_safe_float(premium_received) or 0.0)
ofee = float(_safe_float(open_fee) or 0.0)
cfee = float(_safe_float(close_fee) or 0.0)
if ofee < 0:
ofee = abs(ofee)
if cfee < 0:
cfee = abs(cfee)
net = round(recv - paid - ofee - cfee, 8)
pc = str(premium_ccy or "").strip().upper()
if pc in ("ETH", "BTC"):
open_idx = _safe_float(open_index_px)
sell_px = _safe_float(index_px)
if sell_px is not None and sell_px > 0:
return settle_sim_coin_pnl_usdt(
premium_paid=paid,
premium_received=recv,
open_fee=ofee,
close_fee=cfee,
open_index_px=open_idx,
sell_back_px=sell_px,
fee_ccy=fee_ccy,
premium_ccy=pc,
)
return freeze_pnl_to_usdt(
net,
premium_ccy=pc,
index_px=index_px,
total_fee=ofee + cfee,
)
out = freeze_pnl_to_usdt(
net,
premium_ccy=premium_ccy,
index_px=index_px,
total_fee=ofee + cfee,
)
return out
def settle_exchange_realized_pnl(
*,
realized_pnl: Any,
premium_ccy: str,
index_px: float | None,
fee_hint: Any = None,
) -> dict[str, float | None]:
"""
冻结交易所 positions-history 的 realizedPnl.
该字段已含手续费,此处只折 U,不再减 fee.
"""
net = _safe_float(realized_pnl)
if net is None:
return {
"realized_pnl": None,
"close_index_px": None,
"realized_pnl_usdt": None,
"total_fee": fee_amount_abs(fee_hint),
}
return freeze_pnl_to_usdt(
float(net),
premium_ccy=premium_ccy,
index_px=index_px,
total_fee=fee_amount_abs(fee_hint),
)
def resolve_close_index_px(
ex: Any,
*,
inst_id: str = "",
underlying: str = "",
hint: Any = None,
) -> float | None:
"""平仓冻结用指数:优先调用方传入,否则拉标的近似价."""
h = _safe_float(hint)
if h is not None and h > 0:
return float(h)
underly = (underlying or "").strip().upper()
if not underly and inst_id:
underly = str(inst_id).split("-")[0].strip().upper() or "ETH"
if not underly:
underly = "ETH"
try:
from lib.options.options_stats_lib import _underlying_index_usdt
return _underlying_index_usdt(ex, underly)
except Exception:
return None

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