122 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
163 changed files with 18200 additions and 20674 deletions
+19 -45
View File
@@ -80,6 +80,8 @@ LIVE_TRADING_ENABLED=true
SIM_DEFAULT_MODE=sim SIM_DEFAULT_MODE=sim
SIM_INITIAL_EQUITY_USDT=10000 SIM_INITIAL_EQUITY_USDT=10000
SIM_INITIAL_USDC=0 SIM_INITIAL_USDC=0
# 实盘初始资金(U);中控收益率=已结算盈亏/该值。不填则中控回退最早余额采样
LIVE_INITIAL_EQUITY_USDT=
SIM_FEE_RATE=0.0005 SIM_FEE_RATE=0.0005
# ============================================================================= # =============================================================================
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2) # OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2)
@@ -102,11 +104,11 @@ OKX_POSITION_INST_TYPE=SWAP
EXCHANGE_DISPLAY_NAME=OKX EXCHANGE_DISPLAY_NAME=OKX
# 企业微信推送里展示的账户备注 # 企业微信推送里展示的账户备注
# OKX_ACCOUNT_LABEL= # OKX_ACCOUNT_LABEL=
# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧 # 顶栏是否显示 USDT 资金/交易账户(热更);币本位买币仍会显示交易账户可用额
OKX_SHOW_PERP_FUNDS=true OKX_SHOW_PERP_FUNDS=true
# ============================================================================= # =============================================================================
# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2) # 期权模块(币本位专用;与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
# 详见 docs/期权方案.md 与 docs/期权用法.md # 详见 docs/期权方案.md 与 docs/期权用法.md
# ============================================================================= # =============================================================================
OKX_OPTIONS_ENABLED=true OKX_OPTIONS_ENABLED=true
@@ -116,7 +118,7 @@ OKX_OPTIONS_ENABLED=true
# OKX_OPTIONS_API_PASSPHRASE= # OKX_OPTIONS_API_PASSPHRASE=
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC) # 仅币本位期权(USDT买币桥);USDC 期权已下线,此项恒视为 coin
OKX_OPTIONS_MARGIN_MODE=coin OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_COIN_COMPOUND=true OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10 OKX_OPTIONS_COIN_BUDGET_USDT=10
@@ -125,19 +127,14 @@ OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换 # 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10 OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_BUDGET_BUFFER=0.95
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算 # 交易模式固定单独期权(历史 perp_options / options_options 读取时归一为 options)
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
OKX_TRADE_MODE=options OKX_TRADE_MODE=options
# 永期对冲改为: OKX_TRADE_MODE=perp_options # 期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
# 期期对冲改为: OKX_TRADE_MODE=options_options
# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0
# 期权标的白名单(逗号分隔,仅 ETH/BTC)。只配一个则交易页只显示该标的;开仓/链/报价/条件单均校验。资金页不受影响。
OKX_OPTIONS_UNDERLYINGS=ETH,BTC
# 默认选中标的;须落在白名单内,否则取白名单第一项
OKX_OPTIONS_DEFAULT_UNDERLY=ETH OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 # 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
@@ -148,46 +145,15 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 # 开仓成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# 目标平仓门控(目标位触发后按买一平仓前需满足) # 目标平仓门控(目标位触发后按买一平仓前需满足)
OKX_OPTIONS_CLOSE_GATE_MODE=premium OKX_OPTIONS_CLOSE_GATE_MODE=premium
# premium:可回收≥权利金×倍数;net_pnl:净盈亏(买一可回收−权利金)折USDT≥下限 # premium:可回收≥权利金×倍数;net_pnl:净盈亏(买一可回收−权利金)折USDT≥下限
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05 OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT=0 OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT=0
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 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 APP_TIMEZONE=Asia/Shanghai
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED # 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**. 安装到 `/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)**.
## 环境要求 ## 环境要求
+421 -2093
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", records: "/records",
stats: "/stats", stats: "/stats",
risk_policy: "/risk_policy", risk_policy: "/risk_policy",
live_guide: "/live_guide",
system_guide: "/system_guide", system_guide: "/system_guide",
env_config: "/env_config", env_config: "/env_config",
settings: "/settings", settings: "/settings",
@@ -154,6 +155,9 @@
} }
} }
} }
if (tab === "settings" && global.InstanceBackup && typeof global.InstanceBackup.boot === "function") {
global.InstanceBackup.boot();
}
} }
if (!revisit) { if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") { if (typeof global.refreshAccountSnapshot === "function") {
@@ -358,6 +362,7 @@
const hideTopBar = const hideTopBar =
tab === "settings" || tab === "settings" ||
tab === "risk_policy" || tab === "risk_policy" ||
tab === "live_guide" ||
tab === "system_guide" || tab === "system_guide" ||
tab === "env_config" || tab === "env_config" ||
tab === "options_review"; tab === "options_review";
+1
View File
@@ -102,6 +102,7 @@
records: "/records", records: "/records",
stats: "/stats", stats: "/stats",
risk_policy: "/risk_policy", risk_policy: "/risk_policy",
live_guide: "/live_guide",
system_guide: "/system_guide", system_guide: "/system_guide",
env_config: "/env_config", env_config: "/env_config",
settings: "/settings", 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-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} .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} body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 12px}
/* 1920 左右用满:约 96vw / 1840,两侧留白更小 */ /* 普通屏:铺满可用宽度;带鱼屏再锁 1800(见下方 media) */
.container{width:100%;max-width:min(1840px,96vw);margin:0 auto;padding:0 clamp(6px,1vw,14px)} .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{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} .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} .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} .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{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-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{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} .top-nav a.active{background:#2a3f6c;color:#dbe4ff}
@@ -165,11 +175,12 @@
.panel-scroll,.pos-list{max-height:420px} .panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto} .records-card .table-wrap{max-height:620px;overflow:auto}
} }
/* 带鱼屏(宽高比≥21:9):整页固定 1920 居中;不依赖视口宽度(避免 Win 缩放后失效) */ /* 带鱼屏:独立打开与中控内嵌内容均锁 1800 居中留白;工作台外壳由中控全屏 */
@media (min-aspect-ratio: 21/9) { @media (min-aspect-ratio: 21/9) {
.container{ .container,
body[data-embed-shell="1"] .container{
width:100%; width:100%;
max-width:min(1920px, calc(100vw - 24px)); max-width:min(1800px, calc(100vw - 24px));
margin-left:auto; margin-left:auto;
margin-right:auto; margin-right:auto;
} }
+77 -29
View File
@@ -46,9 +46,8 @@
options: "show_nav_options", options: "show_nav_options",
"options-review": "show_nav_options_review", "options-review": "show_nav_options_review",
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", risk_policy: "show_nav_risk_policy",
live_guide: "show_nav_live_guide",
system_guide: "show_nav_system_guide", system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config", env_config: "show_nav_env_config",
}; };
@@ -59,6 +58,11 @@
.forEach((a) => { .forEach((a) => {
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0]; const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
if (tab === "more") return; 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]; const key = map[tab];
if (!key) return; if (!key) return;
const show = navPrefShow(display, key); const show = navPrefShow(display, key);
@@ -70,6 +74,7 @@
function pageNavAllowed(tab) { function pageNavAllowed(tab) {
const d = DISPLAY(); const d = DISPLAY();
if (tab === "hedge_plan" || tab === "hedge-plan") return false;
const map = { const map = {
dashboard: "show_nav_dashboard", dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger", account_ledger: "show_nav_account_ledger",
@@ -82,9 +87,8 @@
options: "show_nav_options", options: "show_nav_options",
"options-review": "show_nav_options_review", "options-review": "show_nav_options_review",
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", risk_policy: "show_nav_risk_policy",
live_guide: "show_nav_live_guide",
system_guide: "show_nav_system_guide", system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config", env_config: "show_nav_env_config",
}; };
@@ -295,7 +299,21 @@
} }
input.dataset.envKey = field.key; input.dataset.envKey = field.key;
input.className = "env-field-input"; input.className = "env-field-input";
row.appendChild(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) { if (field.hidden) {
row.hidden = true; row.hidden = true;
row.style.display = "none"; row.style.display = "none";
@@ -397,41 +415,25 @@
function syncOptionsSectionVisibility(body) { function syncOptionsSectionVisibility(body) {
if (!body) return; if (!body) return;
var margin = (envFieldValue(body, "OKX_OPTIONS_MARGIN_MODE", "coin") || "coin").toLowerCase(); // 仅币本位期权:USDC/永续相关 env 始终隐藏
var isCoin = margin !== "usdc"; var isCoin = true;
var gateMode = (envFieldValue(body, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") || "premium").toLowerCase(); 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 coinCompoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_COMPOUND", "true"));
var coinMaxOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false")); 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) { COIN_ONLY_ENV_KEYS.forEach(function (key) {
setEnvRowHidden(envFieldRowByKey(body, key), !isCoin); setEnvRowHidden(envFieldRowByKey(body, key), !isCoin);
}); });
USDC_ONLY_ENV_KEYS.forEach(function (key) { 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);
// 门控子项:按模式显隐(仍受本位过滤) setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium");
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"); setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT"), gateMode !== "net_pnl");
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
if (!isCoin) { setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
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) { function bindOptionsSectionVisibility(body) {
@@ -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() { function installDelegatedHandlers() {
if (document.documentElement.dataset.prefsDelegateBound === "1") return; if (document.documentElement.dataset.prefsDelegateBound === "1") return;
document.documentElement.dataset.prefsDelegateBound = "1"; document.documentElement.dataset.prefsDelegateBound = "1";
document.addEventListener("click", (ev) => { document.addEventListener("click", (ev) => {
const target = ev.target; const target = ev.target;
if (!(target instanceof Element)) return; 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")) { if (target.closest("#display-prefs-save")) {
ev.preventDefault(); ev.preventDefault();
void saveDisplayPrefs(); void saveDisplayPrefs();
+158
View File
@@ -626,6 +626,39 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
margin-left: auto; 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 { .instance-header-toolbar-filter label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -661,9 +694,15 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center;
min-width: 0; min-width: 0;
} }
.top-nav-links > a {
flex: 0 0 auto;
white-space: nowrap;
}
.top-nav-spot-tickers { .top-nav-spot-tickers {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2684,6 +2723,24 @@ html[data-theme="light"] .journal-detail-img-thumb {
box-sizing: border-box; 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 { .env-config-loading-wrap {
padding: 24px; padding: 24px;
text-align: center; text-align: center;
@@ -3315,6 +3372,15 @@ html[data-theme="light"] .settings-side-export-label {
html[data-theme="light"] .opt-chain-lev { html[data-theme="light"] .opt-chain-lev {
color: #1a4a8a; 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 { .opt-be-dist-up {
color: #5ee89a; color: #5ee89a;
} }
@@ -3344,6 +3410,21 @@ html[data-theme="light"] .opt-be-dist-down {
background: rgba(74, 124, 255, 0.45); background: rgba(74, 124, 255, 0.45);
box-shadow: inset 0 0 0 1px rgba(140, 175, 255, 0.6); 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 { .opt-strike-row.opt-row-selected td {
background: rgba(74, 124, 255, 0.1); background: rgba(74, 124, 255, 0.1);
} }
@@ -5483,12 +5564,89 @@ html[data-theme="light"] .opt-source-badge--oo {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; 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 { .opt-spot-sell-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
/* display:flex 会盖掉 UA [hidden] */
.opt-spot-sell-head[hidden] {
display: none !important;
}
.opt-spot-sell-avail { .opt-spot-sell-avail {
font-size: 0.78rem; font-size: 0.78rem;
color: #c9d6f5; color: #c9d6f5;
+2
View File
@@ -171,6 +171,8 @@
function patchHubNavLinks(theme) { function patchHubNavLinks(theme) {
if (!isHubLinked()) return; 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()); const t = normalize(theme || get());
document document
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]") .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 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 = { const state = {
underlying: root.dataset.defaultUnderly || "ETH", underlying: bootUnderly,
optType: "C", optType: "C",
moneyFilter: "all", moneyFilter: "all",
chainView: "list", chainView: "list",
@@ -19,7 +41,7 @@
budgetBuffer: (function () { budgetBuffer: (function () {
const raw = root.dataset.budgetBuffer; const raw = root.dataset.budgetBuffer;
const n = raw != null && raw !== "" ? Number(raw) : NaN; 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, chain: panelCache.chain || null,
selectedInst: null, selectedInst: null,
@@ -30,6 +52,7 @@
targetDraftByInst: {}, targetDraftByInst: {},
/** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */ /** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */
profitExitDraftByInst: {}, profitExitDraftByInst: {},
condOrderLocked: false,
}; };
let lastGoodPositions = null; let lastGoodPositions = null;
@@ -78,7 +101,21 @@
async function apiJson(url, opts) { async function apiJson(url, opts) {
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
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() { function orderPanel() {
@@ -89,14 +126,35 @@
return document.getElementById("opt-order-panel-host"); 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) { function syncPickButtons(instId) {
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) { 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.classList.toggle("active", on);
btn.disabled = false;
if (!btn.dataset.origText) btn.dataset.origText = "选择"; if (!btn.dataset.origText) btn.dataset.origText = "选择";
btn.textContent = on ? "已选" : btn.dataset.origText; btn.textContent = on ? "已选" : btn.dataset.origText;
}); });
applyCondPickLock();
} }
function parkOrderPanel() { function parkOrderPanel() {
@@ -282,9 +340,10 @@
const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)'); const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
if (el) return el.value; if (el) return el.value;
const any = document.querySelector('input[name="opt-size-mode"]:checked'); 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"; if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full";
return "sheets"; // 默认按可用余额打满(复利开时等价全仓复利)
return compoundFullEnabled() ? "compound_full" : "budget_full";
} }
function applyCompoundModeUi(compoundOn) { function applyCompoundModeUi(compoundOn) {
@@ -336,13 +395,13 @@
compoundRadio.checked = false; compoundRadio.checked = false;
compoundRadio.disabled = true; compoundRadio.disabled = true;
} }
// currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓 // 无有效选中时默认「按可用余额打满」
const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)'); const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
if (!checkedOk) { if (!checkedOk) {
const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]'); const fullRadio = document.querySelector('input[name="opt-size-mode"][value="budget_full"]');
if (sheetsRadio) { if (fullRadio) {
sheetsRadio.disabled = false; fullRadio.disabled = false;
sheetsRadio.checked = true; fullRadio.checked = true;
} }
} }
} }
@@ -418,7 +477,15 @@
} }
function strikeTableColspan() { 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() { function syncChainViewUI() {
@@ -637,7 +704,16 @@
function pickBtnHtml(instId) { function pickBtnHtml(instId) {
if (!instId) return "—"; 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() { function syncMoneyFilterButtons() {
@@ -1031,6 +1107,25 @@
return "约 " + Number(v).toFixed(1) + "×"; 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÷卖一. */ /** 链上展示:USDC=指数÷卖一(美元);币本位卖一为币报价 → 1÷卖一. */
function calcAskLeverage(indexPx, askPx) { function calcAskLeverage(indexPx, askPx) {
if (indexPx == null || askPx == null) return null; if (indexPx == null || askPx == null) return null;
@@ -1153,6 +1248,11 @@
function finishStrikeRender(tbody, prevSelected, matchedSelected) { function finishStrikeRender(tbody, prevSelected, matchedSelected) {
bindStrikePickButtons(tbody); bindStrikePickButtons(tbody);
applyCondPickLock();
if (state.condOrderLocked) {
state.selectedInst = null;
return;
}
if (matchedSelected && prevSelected) { if (matchedSelected && prevSelected) {
selectContract(prevSelected, null, true); selectContract(prevSelected, null, true);
} else if (!matchedSelected) { } else if (!matchedSelected) {
@@ -1216,6 +1316,7 @@
"<td><code>" + c.inst_id + "</code></td>" + "<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-px-sz\">" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "</td>" +
'<td class="opt-chain-lev">' + fmtChainLeverage(chainLev) + "</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 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>" + (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>" + '<td class="' + distBeClass(c.dist_expiry_be) + '">' + fmtDist(c.dist_expiry_be) + "</td>" +
@@ -1393,6 +1494,10 @@
} }
async function selectContract(instId, pickBtn, silent) { async function selectContract(instId, pickBtn, silent) {
if (state.condOrderLocked) {
applyCondPickLock();
return null;
}
const seq = ++selectSeq; const seq = ++selectSeq;
state.selectedInst = instId; state.selectedInst = instId;
placeOrderPanelAfter(instId); placeOrderPanelAfter(instId);
@@ -1637,6 +1742,10 @@
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" + '<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.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</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 (expAttr
? '<span class="pos-meta-item">到期倒计时: <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>' ? '<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">' + premTxt + " " + premCcy + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">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.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.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>" + '<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) + "倍"; 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) { function renderProfitExitRow(p) {
const inst = p.inst_id || ""; const inst = p.inst_id || "";
if (p.hedge_plan_target) { if (p.hedge_plan_target) {
@@ -1694,6 +1821,7 @@
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle")); const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
const req = p.profit_exit_required_recycle; const req = p.profit_exit_required_recycle;
const premCcy = posPremiumCcy(p); const premCcy = posPremiumCcy(p);
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启"; let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel; if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
return ( return (
@@ -1710,7 +1838,7 @@
'<span class="opt-target-armed">' + statusTxt + "</span>" + '<span class="opt-target-armed">' + statusTxt + "</span>" +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(enabled (enabled
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : "")) ? profitExitHint(hintMult, req, premCcy)
: "开启后自选倍数;达标按买一限价平;可随时关闭") + : "开启后自选倍数;达标按买一限价平;可随时关闭") +
"</span>" + "</span>" +
"</div>" "</div>"
@@ -2227,26 +2355,53 @@
return n.toFixed(8).replace(/\.?0+$/, ""); 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) { function shouldShowSpotSellBanner(d, positions) {
if (!isCoinMarginMode()) return false; // 仅当交易户有「可卖」(>= 现货 minSz)残留时展示;粉尘不展示、不提示重试
const ss = d && d.spot_sell; 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;
const eth = Number(ss.trading_eth || 0); const eth = Number(ss.trading_eth || 0);
const btc = Number(ss.trading_btc || 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) { function spotSellUnderlying(ss) {
if (!ss) return (state.underlying || "ETH").toUpperCase(); const selected = (state.underlying || "ETH").toUpperCase();
if (ss.bridge_underlying) return String(ss.bridge_underlying).toUpperCase(); if (!ss) return selected;
const bridgeUly = String(ss.bridge_underlying || "").trim().toUpperCase();
const eth = Number(ss.trading_eth || 0); const eth = Number(ss.trading_eth || 0);
const btc = Number(ss.trading_btc || 0); const btc = Number(ss.trading_btc || 0);
if (btc > eth && btc > 1e-12) return "BTC"; const ethMin = spotSellMinSz(ss, "ETH");
if (eth > 1e-12) return "ETH"; const btcMin = spotSellMinSz(ss, "BTC");
return (ss.default_underly || state.underlying || "ETH").toUpperCase(); // 待卖回桥优先;否则优先当前选中标的上的可卖残留
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) { function paintSpotSellBanner(d, positions) {
@@ -2271,6 +2426,90 @@
head.hidden = false; 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() { async function retrySpotSell() {
const btn = document.getElementById("opt-spot-sell-retry-btn"); const btn = document.getElementById("opt-spot-sell-retry-btn");
const uly = (state.spotSellUnderlying || state.underlying || "ETH").toUpperCase(); const uly = (state.spotSellUnderlying || state.underlying || "ETH").toUpperCase();
@@ -2396,6 +2635,13 @@
const list = resolvePositionsList(d); const list = resolvePositionsList(d);
paintPositions(list); paintPositions(list);
paintSpotSellBanner(d, 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) { const fromPos = list.reduce(function (targets, p) {
if (!p) return targets; if (!p) return targets;
if (p.target_index != null) { if (p.target_index != null) {
@@ -2741,6 +2987,8 @@
btn.classList.add("active"); btn.classList.add("active");
state.underlying = btn.getAttribute("data-uly"); state.underlying = btn.getAttribute("data-uly");
loadChain(); loadChain();
// 切换 ETH/BTC 时重刷持仓区,以便「重试卖回」标的与当前选择同步
refreshAllPositions();
}); });
}); });
@@ -88,6 +88,38 @@
return ""; 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) { function fmtPxSz(px, sz, tickSz) {
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
let price = fmtOptionPx(px, tickSz); let price = fmtOptionPx(px, tickSz);
@@ -224,6 +256,7 @@
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" + '<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.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</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 (expAttr
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>" ? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
: "") + : "") +
+212 -27
View File
@@ -14,7 +14,7 @@
option_spot: { option_spot: {
strategy: ["顺势", "反转"], strategy: ["顺势", "反转"],
direction: ["多", "空"], direction: ["多", "空"],
entry: ["假突破", "结构突破"], entry: ["假突破", "结构突破", "左侧条件单"],
}, },
hedge: { hedge: {
strategy: ["横盘", "趋势"], strategy: ["横盘", "趋势"],
@@ -32,11 +32,68 @@
var tradesPages = 1; var tradesPages = 1;
var reviewedPage = 0; var reviewedPage = 0;
var reviewedPages = 1; var reviewedPages = 1;
var tradesListFp = "";
var reviewedListFp = "";
var statsFp = "";
function $(id) { function $(id) {
return document.getElementById(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) { function escapeHtml(s) {
return String(s == null ? "" : s) return String(s == null ? "" : s)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -52,6 +109,37 @@
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; 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) { function fmtHold(sec) {
if (sec == null) return "—"; if (sec == null) return "—";
var s = Math.max(0, Number(sec) || 0); var s = Math.max(0, Number(sec) || 0);
@@ -168,10 +256,33 @@
} }
function reloadAll() { function reloadAll() {
setSyncStatus("读取本地记录…"); // 软刷新:保留现有表格/KPI,数据变了才换 DOM,避免整页闪动
loadTrades({ sync: true }); setSyncStatus("刷新中…");
loadReviewed({ sync: false }); loadTrades({ sync: false, soft: true });
loadStats(); 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) { function isHedgeSource(sourceType) {
@@ -325,12 +436,8 @@
function beginListLoad(wrapId, soft) { function beginListLoad(wrapId, soft) {
var wrap = $(wrapId); var wrap = $(wrapId);
if (!wrap) return null; if (!wrap) return null;
if (soft) { // soft 刷新不打半透明:无变化时也不应闪;有变化时静默换表
if (!wrap.style.minHeight) { if (!soft) {
wrap.style.minHeight = Math.max(wrap.offsetHeight, 1) + "px";
}
wrap.classList.add("or-list-loading");
} else {
wrap.classList.remove("or-list-loading"); wrap.classList.remove("or-list-loading");
wrap.style.minHeight = ""; wrap.style.minHeight = "";
} }
@@ -351,7 +458,8 @@
if (!tbody) return; if (!tbody) return;
var wrap = beginListLoad("or-trades-wrap", soft); var wrap = beginListLoad("or-trades-wrap", soft);
if (!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(); var p = baseQs();
// 交易记录保留已复盘条目,不再只显示待复盘 // 交易记录保留已复盘条目,不再只显示待复盘
@@ -365,7 +473,8 @@
.then(function (data) { .then(function (data) {
if (doSync) setSyncStatus("本地记录已加载"); if (doSync) setSyncStatus("本地记录已加载");
if (!data.ok) { 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); endListLoad(wrap);
return; return;
} }
@@ -374,10 +483,16 @@
return; return;
} }
var rows = data.trades || []; var rows = data.trades || [];
var fp = listFingerprint("trades", data, rows);
if (soft && fp === tradesListFp) {
endListLoad(wrap);
return;
}
tradesListFp = fp;
tradesCache = {}; tradesCache = {};
if (!rows.length) { if (!rows.length) {
tbody.innerHTML = tbody.innerHTML =
'<tr><td colspan="7" class="muted">暂无交易记录</td></tr>'; '<tr><td colspan="10" class="muted">暂无交易记录</td></tr>';
endListLoad(wrap); endListLoad(wrap);
return; return;
} }
@@ -386,6 +501,7 @@
tradesCache[t.id] = t; tradesCache[t.id] = t;
var active = currentTradeId === t.id ? " or-row-active" : ""; var active = currentTradeId === t.id ? " or-row-active" : "";
var reviewed = !!t.reviewed; var reviewed = !!t.reviewed;
var pr = resolveProfitRate(t);
var actionBtn = reviewed var actionBtn = reviewed
? '<button type="button" class="btn or-review-btn" data-id="' + ? '<button type="button" class="btn or-review-btn" data-id="' +
t.id + t.id +
@@ -396,6 +512,17 @@
var badgeExtra = reviewed var badgeExtra = reviewed
? ' <span class="or-badge" style="background:rgba(61,214,140,.2)">已复盘</span>' ? ' <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 ( return (
'<tr class="or-trade-row' + '<tr class="or-trade-row' +
active + active +
@@ -410,10 +537,25 @@
"<td>" + "<td>" +
escapeHtml(tradeTitle(t)) + escapeHtml(tradeTitle(t)) +
"</td>" + "</td>" +
"<td>" +
fmtPremiumU(t.initial_premium_usdt) +
"</td>" +
'<td class="' + '<td class="' +
pnlClass(t.realized_pnl_total) + 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>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' + '<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") + escapeHtml(t.opened_at || "—") +
@@ -451,7 +593,8 @@
endListLoad(wrap); endListLoad(wrap);
}) })
.catch(function () { .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); endListLoad(wrap);
}); });
} }
@@ -464,7 +607,8 @@
if (!tbody) return; if (!tbody) return;
var wrap = beginListLoad("or-reviewed-wrap", soft); var wrap = beginListLoad("or-reviewed-wrap", soft);
if (!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(); var p = baseQs();
p.set("reviewed", "1"); p.set("reviewed", "1");
@@ -477,7 +621,8 @@
}) })
.then(function (data) { .then(function (data) {
if (!data.ok) { 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); endListLoad(wrap);
return; return;
} }
@@ -486,9 +631,15 @@
return; return;
} }
var rows = data.trades || []; var rows = data.trades || [];
var fp = listFingerprint("reviewed", data, rows);
if (soft && fp === reviewedListFp) {
endListLoad(wrap);
return;
}
reviewedListFp = fp;
reviewedCache = {}; reviewedCache = {};
if (!rows.length) { 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); endListLoad(wrap);
return; return;
} }
@@ -498,23 +649,36 @@
var entry = t.entry || {}; var entry = t.entry || {};
var direction = t.direction_view || entry.direction_view || ""; var direction = t.direction_view || entry.direction_view || "";
var entryLogic = t.entry_logic || entry.entry_logic || ""; var entryLogic = t.entry_logic || entry.entry_logic || "";
var needsMarkR = String(t.status_raw || "") === "缺价未核";
return ( return (
'<tr class="or-reviewed-row" data-id="' + '<tr class="or-reviewed-row" data-id="' +
t.id + t.id +
'">' + '">' +
"<td><span class=\"or-badge\">" + "<td><span class=\"or-badge\">" +
escapeHtml(t.source_label || t.source_type) + 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>" + "<td>" +
escapeHtml(tradeContractLabel(t)) + escapeHtml(tradeContractLabel(t)) +
"</td>" + "</td>" +
"<td>" + "<td>" +
escapeHtml(direction || "—") + escapeHtml(direction || "—") +
"</td>" + "</td>" +
"<td>" +
fmtPremiumU(t.initial_premium_usdt) +
"</td>" +
'<td class="' + '<td class="' +
pnlClass(t.realized_pnl_total) + 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>" +
'<td class="muted" style="font-size:12px;white-space:nowrap">' + '<td class="muted" style="font-size:12px;white-space:nowrap">' +
escapeHtml(t.opened_at || "—") + escapeHtml(t.opened_at || "—") +
@@ -551,7 +715,7 @@
endListLoad(wrap); endListLoad(wrap);
}) })
.catch(function () { .catch(function () {
tbody.innerHTML = '<tr><td colspan="11" class="muted">加载失败</td></tr>'; tbody.innerHTML = '<tr><td colspan="13" class="muted">加载失败</td></tr>';
endListLoad(wrap); endListLoad(wrap);
}); });
} }
@@ -713,7 +877,8 @@
function resetChartOptions() { function resetChartOptions() {
var cb = $("or-exchange-chart"); var cb = $("or-exchange-chart");
if (cb) cb.checked = false; // 期权复盘:默认勾选后台生成 K 线
if (cb) cb.checked = true;
} }
function renderDetailImages(images, opts) { function renderDetailImages(images, opts) {
@@ -813,10 +978,14 @@
var meta = $("or-detail-meta"); var meta = $("or-detail-meta");
if (meta) { if (meta) {
var pnlLabel = t.is_hedge ? "盈亏" : "期权盈亏";
var cells = [ var cells = [
["标的", t.underlying || "—"], ["标的", t.underlying || "—"],
["合约/计划", tradeTitle(t)], ["合约/计划", 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)], ["持有", fmtHold(t.hold_seconds)],
["开仓时间", t.opened_at || "—"], ["开仓时间", t.opened_at || "—"],
["平仓时间", t.closed_at || "—"], ["平仓时间", t.closed_at || "—"],
@@ -826,6 +995,7 @@
["离场", e.exit_reason || "—"], ["离场", e.exit_reason || "—"],
["按计划", e.followed_plan || "—"], ["按计划", e.followed_plan || "—"],
["入场逻辑", e.entry_logic || "—"], ["入场逻辑", e.entry_logic || "—"],
["入场周期", e.prev_entry_tf || "—"],
]; ];
if (t.is_hedge) { if (t.is_hedge) {
cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]); cells.push(["永续盈亏", fmtPnl(t.realized_pnl_perp)]);
@@ -837,7 +1007,7 @@
if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") { if (pair[0] === "盈亏" || pair[0] === "永续盈亏" || pair[0] === "期权盈亏") {
cls = pnlClass(t.realized_pnl_total); cls = pnlClass(t.realized_pnl_total);
if (pair[0] === "永续盈亏") cls = pnlClass(t.realized_pnl_perp); 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] === "结果") { } else if (pair[0] === "结果") {
cls = resultClass(e.result_tag); cls = resultClass(e.result_tag);
} }
@@ -923,16 +1093,25 @@
); );
} }
function loadStats() { function loadStats(opts) {
opts = opts || {};
var soft = !!opts.soft;
var kpi = $("or-kpi"); var kpi = $("or-kpi");
var groups = $("or-stats-groups"); var groups = $("or-stats-groups");
if (!kpi || !groups) return; 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) { .then(function (r) {
return r.json(); return r.json();
}) })
.then(function (data) { .then(function (data) {
if (!data.ok) return; if (!data.ok) return;
var fp = statsFingerprint(data);
if (soft && fp === statsFp) return;
statsFp = fp;
var k = data.kpi || {}; var k = data.kpi || {};
kpi.innerHTML = [ kpi.innerHTML = [
["笔数", k.total], ["笔数", k.total],
@@ -1094,6 +1273,7 @@
($("or-f-followed") || {}).value = ""; ($("or-f-followed") || {}).value = "";
($("or-f-result") || {}).value = ""; ($("or-f-result") || {}).value = "";
($("or-f-entry") || {}).value = ""; ($("or-f-entry") || {}).value = "";
($("or-f-prev-tf") || {}).value = "";
($("or-f-note") || {}).value = ""; ($("or-f-note") || {}).value = "";
setMoodTags(""); setMoodTags("");
resetUploadSlots(); resetUploadSlots();
@@ -1151,6 +1331,9 @@
: (t.source_label || "") + : (t.source_label || "") +
(t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : ""); (t.plan_close_reason ? " · " + closeReasonLabel(t.plan_close_reason) : "");
($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total); ($("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); ($("or-f-hold") || {}).value = fmtHold(t.hold_seconds);
setSelectValue($("or-f-strategy"), e.strategy_tag || ""); setSelectValue($("or-f-strategy"), e.strategy_tag || "");
setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t)); setSelectValue($("or-f-direction"), e.direction_view || autoDirection(t));
@@ -1158,6 +1341,7 @@
($("or-f-followed") || {}).value = e.followed_plan || ""; ($("or-f-followed") || {}).value = e.followed_plan || "";
setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total)); setSelectValue($("or-f-result"), e.result_tag || autoResultTag(t.realized_pnl_total));
setSelectValue($("or-f-entry"), e.entry_logic || ""); setSelectValue($("or-f-entry"), e.entry_logic || "");
setSelectValue($("or-f-prev-tf"), e.prev_entry_tf || "");
($("or-f-note") || {}).value = e.note || ""; ($("or-f-note") || {}).value = e.note || "";
setMoodTags(e.mistake_tags); setMoodTags(e.mistake_tags);
resetChartOptions(); resetChartOptions();
@@ -1275,6 +1459,7 @@
result_tag: ($("or-f-result") || {}).value || "", result_tag: ($("or-f-result") || {}).value || "",
mistake_tags: collectMoodTags(), mistake_tags: collectMoodTags(),
entry_logic: ($("or-f-entry") || {}).value || "", entry_logic: ($("or-f-entry") || {}).value || "",
prev_entry_tf: ($("or-f-prev-tf") || {}).value || "",
note: ($("or-f-note") || {}).value || "", note: ($("or-f-note") || {}).value || "",
images: collectImages(), images: collectImages(),
}; };
+28
View File
@@ -353,4 +353,32 @@
intMsg: "opt-page-int-msg", 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");
}
});
};
})(); })();
+66 -7
View File
@@ -1,5 +1,6 @@
/** /**
* 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults). * 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults).
* BTC/ETH 在 order_defaults 无价时回退 /api/nav_spot_tickers(与顶栏同源).
*/ */
(function (global) { (function (global) {
"use strict"; "use strict";
@@ -65,6 +66,41 @@
el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)"; 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) { function bindOne(el) {
if (!el || bound.has(el)) return; if (!el || bound.has(el)) return;
bound.add(el); bound.add(el);
@@ -87,6 +123,15 @@
pollTimer = setInterval(refresh, pollMs()); 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() { function refresh() {
const symEl = $(symId); const symEl = $(symId);
const sym = symbolValue(symEl); const sym = symbolValue(symEl);
@@ -116,21 +161,35 @@
el.classList.remove("symbol-live-price--loading"); el.classList.remove("symbol-live-price--loading");
const data = res.data || {}; const data = res.data || {};
if (res.status >= 400 || !data || !data.ok) { if (res.status >= 400 || !data || !data.ok) {
paint(el, sym, null, (data && data.msg) || "读取失败"); return fetchNavLast(sym).then(function (navPx) {
return; 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; const px = data.last_price != null ? data.last_price : data.price;
if (px === null || typeof px === "undefined") { if (px === null || typeof px === "undefined") {
paint(el, data.symbol || sym, null, "无法读取交易所价格"); return fetchNavLast(sym).then(function (navPx) {
return; if (seq !== fetchSeq) return;
applyPrice(data.symbol || sym, navPx);
});
} }
paint(el, data.symbol || sym, px, ""); applyPrice(data.symbol || sym, px);
if (!pollTimer) startPoll();
}) })
.catch(function () { .catch(function () {
if (seq !== fetchSeq) return; if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading"); el.classList.remove("symbol-live-price--loading");
paint(el, sym, null, "网络错误"); fetchNavLast(sym).then(function (navPx) {
if (seq !== fetchSeq) return;
if (navPx != null) {
applyPrice(sym, navPx);
return;
}
paint(el, sym, null, "网络错误");
});
}); });
} }
+2 -40
View File
@@ -88,18 +88,13 @@ HOT_RELOAD_EXACT = frozenset({
"APP_AUTH_DISABLED", "APP_AUTH_DISABLED",
"APP_SESSION_DAYS", "APP_SESSION_DAYS",
"WECHAT_WEBHOOK", "WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_SHOW_PERP_FUNDS", "OKX_SHOW_PERP_FUNDS",
"HUB_BRIDGE_TOKEN",
"HUB_EMBED_FRAME_ANCESTORS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS", "OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "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_BUDGET_BUFFER",
"OKX_OPTIONS_COIN_COMPOUND", "OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT", "OKX_OPTIONS_COIN_BUDGET_USDT",
@@ -107,33 +102,15 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_COIN_MAX_USDT", "OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_OPTIONS_CLOSE_GATE_MODE", "OKX_OPTIONS_CLOSE_GATE_MODE",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT", "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS", "OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"OKX_TRADE_MODE", "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({ SENSITIVE_EXACT = frozenset({
"APP_PASSWORD", "APP_PASSWORD",
"FLASK_SECRET_KEY", "FLASK_SECRET_KEY",
"HUB_BRIDGE_TOKEN",
"OPENAI_API_KEY", "OPENAI_API_KEY",
}) })
@@ -164,26 +141,13 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("spot", "spot 现货"), ("spot", "spot 现货"),
), ),
"TRANSFER_CCY": (("USDT", "USDT"),), "TRANSFER_CCY": (("USDT", "USDT"),),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
"OKX_TRADE_MODE": ( "OKX_TRADE_MODE": (
("options", "单独期权"), ("options", "单独期权"),
("options_options", "期期对冲"),
),
"OKX_OPTIONS_MARGIN_MODE": (
("coin", "币本位(USDT买币桥)"),
("usdc", "USDC(USDⓈ权利金)"),
), ),
"OKX_OPTIONS_CLOSE_GATE_MODE": ( "OKX_OPTIONS_CLOSE_GATE_MODE": (
("premium", "权利金倍数"), ("premium", "权利金倍数"),
("net_pnl", "净盈亏(USDT)"), ("net_pnl", "净盈亏(USDT)"),
), ),
"HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
("false", "保险模式"),
),
} }
_SELECT_ALIASES: dict[str, dict[str, str]] = { _SELECT_ALIASES: dict[str, dict[str, str]] = {
@@ -248,8 +212,6 @@ def _field_type(key: str, value: str) -> str:
return "bool" return "bool"
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in ( if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
"OKX_SHOW_PERP_FUNDS", "OKX_SHOW_PERP_FUNDS",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
): ):
return "bool" return "bool"
try: try:
+77 -292
View File
@@ -20,15 +20,15 @@ from lib.env.env_schema import (
# 合约计仓/杠杆/自动单等已从 env UI 移除;永续划转等见系统设置开关(默认关) # 合约计仓/杠杆/自动单等已从 env UI 移除;永续划转等见系统设置开关(默认关)
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = { _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [ "okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权/对冲共用)"), ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权共用)"),
("OKX_API_KEY", "API Key", "账户 API(永续腿+期权共用)"), ("OKX_API_KEY", "API Key", "账户 API(期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(永续腿+期权共用)"), ("OKX_API_SECRET", "API Secret", "账户 API(期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"), ("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
( (
"OKX_SHOW_PERP_FUNDS", "OKX_SHOW_PERP_FUNDS",
"显示永续资金", "显示USDT资金/交易账户",
"默认关闭;开启后顶栏显示 USDT 资金/交易账户。币本位期权仍会显示交易账户以便买币", "默认关闭;开启后顶栏显示 USDT 资金与交易账户。币本位期权买币仍会显示交易账户可用额",
), ),
], ],
"binance": [ "binance": [
@@ -49,7 +49,7 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
], ],
} }
# 仅保留与期权/对冲产品相关的共享区块(永续执行/风控/冷静期/划转/当日资金已从 UI 移除) # 共享区块(企业微信等);对冲配置已从 UI 移除
_SHARED_SECTIONS: list[dict[str, Any]] = [ _SHARED_SECTIONS: list[dict[str, Any]] = [
{ {
"title": "企业微信", "title": "企业微信",
@@ -58,83 +58,66 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"), ("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
], ],
}, },
{
"title": "中控对接",
"fields": [
(
"HUB_BRIDGE_TOKEN",
"中控 Bridge Token",
"首次打开本页自动生成;复制到 crypto_okx_hub「添加实例」即可;也可手动改后保存",
),
(
"HUB_EMBED_FRAME_ANCESTORS",
"允许嵌入的中控 Origin",
"选填。一般可留空;仅当中控 iframe 打不开时再填中控地址(空格分隔),或调试填 *",
),
],
},
] ]
_MODE_SECTION: dict[str, Any] = {
"title": "期权/对冲模式",
"exchanges": frozenset({"okx"}),
"fields": [
(
"OKX_TRADE_MODE",
"交易模式",
"二选一:单独期权 / 期期对冲。选单独期权时隐藏对冲导航与对冲配置;选期期对冲时不可单独开期权。永期对冲(含合约腿)已从配置页移除",
),
],
}
_OPTIONS_SECTION: dict[str, Any] = { _OPTIONS_SECTION: dict[str, Any] = {
"title": "期权账户", "title": "期权账户",
"exchanges": frozenset({"okx"}), "exchanges": frozenset({"okx"}),
"fields": [ "fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), ("OKX_OPTIONS_ENABLED", "启用期权模块", "与上方 OKX_API_* 同源;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
( ("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "默认 1;币本位复利/固定预算共用"),
"OKX_OPTIONS_MARGIN_MODE",
"单笔期权本位",
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
( (
"OKX_OPTIONS_COIN_COMPOUND", "OKX_OPTIONS_COIN_COMPOUND",
"币本位按交易户USDT复利", "按交易户USDT复利",
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲", "默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
), ),
( (
"OKX_OPTIONS_COIN_BUDGET_USDT", "OKX_OPTIONS_COIN_BUDGET_USDT",
"币本位固定预算(USDT)", "固定预算(USDT)",
"仅币本位且复利关闭时生效", "仅复利关闭时生效",
), ),
( (
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"币本位单笔上限开关", "单笔上限开关",
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U", "默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
), ),
( (
"OKX_OPTIONS_COIN_MAX_USDT", "OKX_OPTIONS_COIN_MAX_USDT",
"币本位单笔上限(USDT)", "单笔上限(USDT)",
"仅上限开关开启时生效", "仅上限开关开启时生效",
), ),
( (
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"币本位现货买入缓冲", "现货买入缓冲",
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换", "相对权利金倍数,默认 1.05(=多买5%);也可写 0.05 表示+5%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
), ),
( (
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "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", "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"期权链展示天数", "期权链展示天数",
@@ -143,7 +126,7 @@ _OPTIONS_SECTION: dict[str, Any] = {
( (
"OKX_OPTIONS_MAX_DTE_DAYS", "OKX_OPTIONS_MAX_DTE_DAYS",
"开仓最大剩余天数", "开仓最大剩余天数",
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)", "默认 2;开仓时拒绝更远到期(与链展示天数独立)",
), ),
( (
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
@@ -153,17 +136,12 @@ _OPTIONS_SECTION: dict[str, Any] = {
( (
"OKX_OPTIONS_CLOSE_GATE_MODE", "OKX_OPTIONS_CLOSE_GATE_MODE",
"目标平仓门控模式", "目标平仓门控模式",
"premium=可回收≥权利金×倍数(币本位默认×1.05);net_pnl=净盈亏折USDT≥阈值", "premium=可回收≥权利金×倍数(默认×1.05);net_pnl=净盈亏折USDT≥阈值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"门控权利金倍数(USDC)",
"仅 premium 模式;USDC 权利金仓默认 2",
), ),
( (
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"门控权利金倍数(币本位)", "门控权利金倍数",
"仅 premium 模式;ETH/BTC 权利金仓默认 1.05", "仅 premium 模式;默认 1.05",
), ),
( (
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT", "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
@@ -173,98 +151,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
( (
"OKX_OPTIONS_CLOSE_HOLD_SECONDS", "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 未写明时展示实际生效值(同风控说明页) # 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
_RUNTIME_ENV_DEFAULTS: dict[str, str] = { _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_CONTROL_ENABLED": "true", "RISK_CONTROL_ENABLED": "true",
@@ -276,46 +167,31 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"AUTO_TRANSFER_FROM": "funding", "AUTO_TRANSFER_FROM": "funding",
"AUTO_TRANSFER_TO": "swap", "AUTO_TRANSFER_TO": "swap",
"TRANSFER_CCY": "USDT", "TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "false",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false",
"OKX_SHOW_PERP_FUNDS": "false", "OKX_SHOW_PERP_FUNDS": "false",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true", "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14", "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
"OKX_OPTIONS_MAX_DTE_DAYS": "2", "OKX_OPTIONS_MAX_DTE_DAYS": "2",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "1",
"OKX_OPTIONS_MARGIN_MODE": "coin", "OKX_OPTIONS_BUDGET_BUFFER": "1",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER": "1.05",
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium", "OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT": "2",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05", "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT": "0", "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT": "0",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120", "OKX_OPTIONS_CLOSE_HOLD_SECONDS": "20",
"OKX_TRADE_MODE": "options", "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: def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key == "OKX_TRADE_MODE": 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: 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() return get_okx_trade_mode()
except Exception: except Exception:
pass return "options"
if key == "OKX_OPTIONS_MARGIN_MODE":
return "coin"
if key in file_values: if key in file_values:
file_val = str(file_values.get(key) or "").strip() file_val = str(file_values.get(key) or "").strip()
if file_val: if file_val:
@@ -350,13 +226,6 @@ def _build_field(
meta = schema.get(key) or {} meta = schema.get(key) or {}
schema_default = meta.get("default") or "" schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default) 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) masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default) ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key) options = select_options_for(key)
@@ -381,86 +250,34 @@ def _build_field(
if options: if options:
cur = (out["current"] or out["default"] or "").strip() cur = (out["current"] or out["default"] or "").strip()
opt_vals = {o["value"] for o in options} 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: if cur and cur not in opt_vals:
options = [{"value": cur, "label": cur}] + options options = [{"value": cur, "label": cur}] + options
out["options"] = options out["options"] = options
return out 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( def ui_sections_for_exchange(
exchange_key: str, exchange_key: str,
*, *,
mode: str | None = None, mode: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
del mode # 对冲模式已移除;保留参数兼容旧调用
ex = (exchange_key or "").strip().lower() ex = (exchange_key or "").strip().lower()
sections: list[dict[str, Any]] = [] sections: list[dict[str, Any]] = []
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"]) live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
sections.append({"title": "交易所与实盘", "fields": live_fields}) sections.append({"title": "交易所与实盘", "fields": live_fields})
sections.extend(_SHARED_SECTIONS) sections.extend(_SHARED_SECTIONS)
if ex in _MODE_SECTION.get("exchanges", frozenset()): if ex == "okx":
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode sections.append({"title": "期权账户", "fields": list(_OPTIONS_SECTION["fields"])})
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})
return sections return sections
def ui_allowed_keys(exchange_key: str) -> frozenset[str]: def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
"""可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入).""" """可写键=当前可见字段。.env 里遗留对冲键仍保留在文件中,但 UI 不可再写入."""
keys: set[str] = set() keys: set[str] = set()
for sec in ui_sections_for_exchange(exchange_key): for sec in ui_sections_for_exchange(exchange_key):
for item in sec["fields"]: for item in sec["fields"]:
keys.add(item[0]) keys.add(item[0])
ex = (exchange_key or "").strip().lower()
if ex == "okx":
keys.add("OKX_TRADE_MODE")
# 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
for item in _HEDGE_PLAN_SECTION["fields"]:
keys.add(item[0])
for item in _OPTIONS_SECTION["fields"]:
keys.add(item[0])
return frozenset(keys) return frozenset(keys)
@@ -469,13 +286,18 @@ def build_env_ui_payload(
example_path: str, example_path: str,
env_path: str, env_path: str,
) -> list[dict[str, Any]]: ) -> 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) schema = _schema_field_map(example_path)
env_lines = read_env_lines(env_path) env_lines = read_env_lines(env_path)
values = env_get_all(env_lines) values = env_get_all(env_lines)
groups: list[dict[str, Any]] = [] groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange( for sec in ui_sections_for_exchange(exchange_key):
exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
):
fields = [ fields = [
_build_field(key, label, note, schema, values) _build_field(key, label, note, schema, values)
for key, label, note in sec["fields"] for key, label, note in sec["fields"]
@@ -489,24 +311,7 @@ def build_env_ui_payload(
return groups return groups
# 币本位专属 / USDC 专属(配置页互斥隐藏) # 币本位专用:按复利/上限/门控模式标记 hidden
_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",
})
def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") -> str: def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") -> str:
for f in fields: for f in fields:
if f.get("key") == key: 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]]: def _mark_options_section_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按本位/门控模式/全仓复利标记 hidden(供 SSR/前端;切换开关仍可再显示).""" """按门控模式/复利/单笔上限标记 hidden;USDC/永续相关项始终隐藏."""
margin = (_field_current(fields, "OKX_OPTIONS_MARGIN_MODE", "coin") or "coin").lower()
is_coin = margin != "usdc"
gate_mode = (_field_current(fields, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") or "premium").lower() 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_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")) coin_max_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"))
compound_cap_on = _env_truthy( retired = {
_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false") "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]] = [] out: list[dict[str, Any]] = []
for f in fields: for f in fields:
key = str(f.get("key") or "") key = str(f.get("key") or "")
hide = False hide = False
if is_coin and key in _USDC_ONLY_ENV_KEYS: if key in retired:
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":
hide = True hide = True
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium": elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium":
hide = True hide = True
elif key == "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT" and gate_mode != "net_pnl": elif key == "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT" and gate_mode != "net_pnl":
hide = True 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: elif key == "OKX_OPTIONS_COIN_BUDGET_USDT" and coin_compound_on:
hide = True hide = True
elif key == "OKX_OPTIONS_COIN_MAX_USDT" and not coin_max_on: 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 = "", env_path: str = "",
) -> dict[str, str]: ) -> dict[str, str]:
"""手动补开为开启时,强制把自动平写成 false(与运行时一致).""" """兼容旧调用名;对冲已移除,原样返回."""
out = dict(clean or {}) del env_path
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL") return dict(clean or {})
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
-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
+66 -3
View File
@@ -710,7 +710,12 @@ def options_header_balance_pack(
margin_mode = normalize_options_margin_mode() margin_mode = normalize_options_margin_mode()
except Exception: except Exception:
margin_mode = "usdc" margin_mode = "usdc"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" 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" coin_key = "btc" if underly == "BTC" else "eth"
return { return {
"trading_usdc": _round(bal.get("trading_usdc")), "trading_usdc": _round(bal.get("trading_usdc")),
@@ -794,6 +799,37 @@ def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str,
return out 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( def build_option_chain(
ex: ccxt.okx, ex: ccxt.okx,
underlying: str, underlying: str,
@@ -814,7 +850,7 @@ def build_option_chain(
family = inst_family_for_underlying(u, margin_mode=margin_mode) family = inst_family_for_underlying(u, margin_mode=margin_mode)
except Exception: except Exception:
family = f"{u}-USD_UM" family = f"{u}-USD"
uly = f"{u}-USD" uly = f"{u}-USD"
idx = index_px if index_px is not None else fetch_index_price(ex, uly) idx = index_px if index_px is not None else fetch_index_price(ex, uly)
now_ms = time.time() * 1000 now_ms = time.time() * 1000
@@ -842,6 +878,7 @@ def build_option_chain(
except Exception as e2: except Exception as e2:
instruments_err = str(e2) or e2.__class__.__name__ instruments_err = str(e2) or e2.__class__.__name__
tickers = fetch_option_tickers(ex, family) tickers = fetch_option_tickers(ex, family)
summaries = fetch_option_summaries(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {} expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0 skipped_no_index = 0
for meta in instruments: for meta in instruments:
@@ -867,6 +904,7 @@ def build_option_chain(
continue continue
inst_id = str(meta.get("instId") or "") inst_id = str(meta.get("instId") or "")
t = tickers.get(inst_id) or {} t = tickers.get(inst_id) or {}
s = summaries.get(inst_id) or {}
q = _resolve_chain_quote( q = _resolve_chain_quote(
ticker=t, ticker=t,
meta=meta, meta=meta,
@@ -879,6 +917,7 @@ def build_option_chain(
mark = q["mark_px"] mark = q["mark_px"]
ask_sz = q["ask_sz"] ask_sz = q["ask_sz"]
bid_sz = q["bid_sz"] bid_sz = q["bid_sz"]
delta = option_delta_from_summary(s, ticker=t)
expiry_be = expiry_breakeven_from_ask( expiry_be = expiry_breakeven_from_ask(
opt_type=opt_type, opt_type=opt_type,
strike=strike, strike=strike,
@@ -899,6 +938,7 @@ def build_option_chain(
"ask_sz": ask_sz, "ask_sz": ask_sz,
"bid_sz": bid_sz, "bid_sz": bid_sz,
"mark_px": mark, "mark_px": mark,
"delta": delta,
"ask_estimated": q["ask_estimated"], "ask_estimated": q["ask_estimated"],
"expiry_be_px": expiry_be, "expiry_be_px": expiry_be,
"dist_expiry_be": strike_distance_to_be(strike, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be),
@@ -1147,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: if avg is None or avg <= 0:
avg = fill_px avg = fill_px
state = str(o.get("state") or "").strip().lower() 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 { return {
"ok": True, "ok": True,
"ord_id": str(o.get("ordId") or ord_id), "ord_id": str(o.get("ordId") or ord_id),
@@ -1157,6 +1199,9 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
"avg_px": avg, "avg_px": avg,
"side": str(o.get("side") or "").lower(), "side": str(o.get("side") or "").lower(),
"ord_type": str(o.get("ordType") or ""), "ord_type": str(o.get("ordType") or ""),
# OKX fee 扣费多为负;此处保留原始值,结算侧取绝对值
"fee": fee,
"fee_ccy": fee_ccy,
"raw": o, "raw": o,
} }
except Exception as e: except Exception as e:
@@ -1502,6 +1547,7 @@ def format_option_history_row(
"premium_ccy": premium_ccy, "premium_ccy": premium_ccy,
"margin_mode": row_mode, "margin_mode": row_mode,
"realized_pnl": realized, "realized_pnl": realized,
"realized_pnl_usdt": _safe_float(raw.get("_sim_realized_pnl_usdt")),
"realized_pnl_fmt": format_premium_amount(realized, ccy=premium_ccy), "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, "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
"status": "closed", "status": "closed",
@@ -1595,6 +1641,9 @@ def resolve_option_close_from_history(
u_i, row = item u_i, row = item
c_ms = _safe_float(row.get("cTime")) c_ms = _safe_float(row.get("cTime"))
parts: list[float] = [] 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: if want_sheets is not None:
hist_sheets = _safe_float(row.get("closeTotalPos")) hist_sheets = _safe_float(row.get("closeTotalPos"))
@@ -1609,7 +1658,7 @@ def resolve_option_close_from_history(
parts.append(float(abs(int(c_ms) - int(open_ms)))) parts.append(float(abs(int(c_ms) - int(open_ms))))
if close_ms is not None: if close_ms is not None:
parts.append(float(abs(u_i - int(close_ms)))) 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))
# 同距时偏向更晚平仓 # 同距时偏向更晚平仓
parts.append(float(-u_i)) parts.append(float(-u_i))
@@ -1803,6 +1852,7 @@ def format_position_row(
) -> dict[str, Any]: ) -> dict[str, Any]:
from lib.options.options_pricing_lib import ( from lib.options.options_pricing_lib import (
close_breakeven_idx, close_breakeven_idx,
equivalent_contract_leverage,
expiry_breakeven_px, expiry_breakeven_px,
idx_distance_to_be, idx_distance_to_be,
strike_distance_to_be, strike_distance_to_be,
@@ -1828,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 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_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: try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
@@ -1888,6 +1941,16 @@ def format_position_row(
"close_be_px": close_be, "close_be_px": close_be,
"dist_expiry_be": strike_distance_to_be(strike, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be),
"dist_close_be": idx_distance_to_be(idx_px, close_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, "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, price_tick_from_market,
round_ohlcv_bars_to_tick, round_ohlcv_bars_to_tick,
) )
from lib.trade.order_monitor_display_lib import ( def apply_order_price_display_fields(payload, **kwargs):
apply_order_live_price_display, return payload
apply_order_price_display_fields,
)
def apply_order_live_price_display(payload, *args, **kwargs):
return payload
def resolve_kline_price_tick( 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()
+14 -117
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]: def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
"""根据进行中对冲计划腿判定来源;默认纯期权. 返回 (source, label, plan_id).""" """对冲已移除:持仓一律记为纯期权."""
default = ("option", OPTIONS_SOURCE_LABELS["option"], None) return ("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
def _format_profit_exit_mult(mult: Any) -> str: 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: 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() 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] = [] parts: list[str] = []
tgt = _safe_float(p.get("target_index")) tgt = _safe_float(p.get("target_index"))
if tgt is not None and tgt > 0: if tgt is not None and tgt > 0:
@@ -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 "-" inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
# 看板期权列:优先买一净盈亏,残档回退交易所 upl # 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数)
pnl = None pnl = None
try: 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: except Exception:
pnl = None pnl = None
pos = _safe_float(p.get("pos")) 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]: def _format_key_item(kd: dict[str, Any]) -> dict[str, Any]:
sym = kd.get("exchange_symbol") or kd.get("symbol") or "-" sym = kd.get("exchange_symbol") or kd.get("symbol") or "-"
direction = str(kd.get("direction") or "long").lower() 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]]: def collect_orders(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "order_monitors"): del conn
return [] 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]]: def collect_keys(conn) -> list[dict[str, Any]]:
@@ -330,33 +261,8 @@ def collect_trends(conn) -> list[dict[str, Any]]:
def collect_rolls(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"): del conn
return [] 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:
return []
def collect_options_items( def collect_options_items(
@@ -372,18 +278,15 @@ def collect_options_items(
return [] return []
pe_map: dict[str, dict[str, Any]] = {} pe_map: dict[str, dict[str, Any]] = {}
tgt_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: if conn is not None:
try: try:
from lib.options.options_profit_exit_lib import profit_exit_by_inst from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import targets_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) pe_map = profit_exit_by_inst(conn)
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
hedge_map = active_options_targets_by_inst(conn)
except Exception: except Exception:
pe_map, tgt_map, hedge_map = {}, {}, {} pe_map, tgt_map = {}, {}
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for p in raw: for p in raw:
if not isinstance(p, dict): if not isinstance(p, dict):
@@ -397,11 +300,6 @@ def collect_options_items(
if pe: if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled") row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult") 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)) out.append(_format_options_item(row, conn=conn))
return out return out
@@ -531,8 +429,6 @@ def build_instance_dashboard_payload(
orders = collect_orders(conn) orders = collect_orders(conn)
keys = collect_keys(conn) keys = collect_keys(conn)
options_items = collect_options_items(fetch_options_positions, conn=conn) options_items = collect_options_items(fetch_options_positions, conn=conn)
hedge_items = collect_hedge_plans(conn) # 始终展示进行中计划,与当前交易模式无关
# hedge_enabled 仅影响「新建」入口,不隐藏已有仓
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
return { return {
"ok": True, "ok": True,
@@ -563,9 +459,10 @@ def build_instance_dashboard_payload(
}, },
"hedge_plan": { "hedge_plan": {
"title": "对冲计划", "title": "对冲计划",
"count": len(hedge_items), "count": 0,
"items": hedge_items, "items": [],
"visible": len(hedge_items) > 0, "visible": False,
"tab": "hedge_plan", "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] = { DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_dashboard": False, "show_nav_dashboard": False,
"show_nav_account_ledger": False, "show_nav_account_ledger": False,
"show_nav_key_monitor": True, "show_nav_key_monitor": True, # 关键位监控保留
"show_nav_trade": False, # 实盘下单界面已移除 "show_nav_trade": False, # 永续实盘下单已下线
"show_nav_strategy": False, "show_nav_strategy": False,
"show_nav_strategy_records": False, "show_nav_strategy_records": False,
"show_nav_records": False, # 永续交易记录与复盘已移除 "show_nav_records": False, # 永续交易记录与复盘已移除
"show_nav_stats": False, "show_nav_stats": True, # 数据统计(复盘 KPI + 资金曲线);可在系统设置关闭
"show_nav_risk_policy": True, "show_nav_risk_policy": True,
"show_nav_live_guide": True,
"show_nav_system_guide": False, "show_nav_system_guide": False,
"show_nav_env_config": True, "show_nav_env_config": True,
"show_nav_options": True, "show_nav_options": True,
"show_nav_options_review": 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_export": True,
"show_settings_password": True, "show_settings_password": True,
"show_settings_options_swap": True, "show_settings_options_swap": False, # USDC 币种兑换已下线
"show_settings_options_transfer": True, "show_settings_options_transfer": True,
} }
@@ -37,13 +37,13 @@ DISPLAY_LABELS: dict[str, str] = {
"show_nav_strategy": "策略交易", "show_nav_strategy": "策略交易",
"show_nav_strategy_records": "策略交易记录", "show_nav_strategy_records": "策略交易记录",
"show_nav_records": "交易记录与复盘", "show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析", "show_nav_stats": "数据统计",
"show_nav_risk_policy": "风控说明", "show_nav_risk_policy": "风控说明",
"show_nav_live_guide": "实盘说明",
"show_nav_system_guide": "系统说明", "show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置", "show_nav_env_config": "env配置",
"show_nav_options": "期权", "show_nav_options": "期权",
"show_nav_options_review": "期权复盘", "show_nav_options_review": "期权复盘",
"show_nav_hedge_plan": "对冲计划",
"show_settings_transfer": "永续资金划转", "show_settings_transfer": "永续资金划转",
"show_settings_export": "数据导出", "show_settings_export": "数据导出",
"show_settings_password": "账户密码修改", "show_settings_password": "账户密码修改",
@@ -61,11 +61,11 @@ NAV_TAB_ALLOWED: dict[str, str] = {
"records": "show_nav_records", "records": "show_nav_records",
"stats": "show_nav_stats", "stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy", "risk_policy": "show_nav_risk_policy",
"live_guide": "show_nav_live_guide",
"system_guide": "show_nav_system_guide", "system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config", "env_config": "show_nav_env_config",
"options": "show_nav_options", "options": "show_nav_options",
"options_review": "show_nav_options_review", "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: def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
prefs = normalize_display_prefs(display or {}) prefs = normalize_display_prefs(display or {})
t = (tab or "").strip() t = (tab or "").strip()
if t in ("trade", "records", "stats"): if t in ("trade", "records", "hedge_plan"):
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除 return False # 永续下单/记录已下线;关键位监控保留
key = NAV_TAB_ALLOWED.get(t) key = NAV_TAB_ALLOWED.get(t)
if not key: if not key:
return True return True
@@ -124,18 +124,17 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
"show_nav_dashboard", "show_nav_dashboard",
"show_nav_account_ledger", "show_nav_account_ledger",
"show_nav_key_monitor", "show_nav_key_monitor",
"show_nav_stats",
"show_nav_risk_policy", "show_nav_risk_policy",
"show_nav_live_guide",
"show_nav_system_guide", "show_nav_system_guide",
"show_nav_env_config", "show_nav_env_config",
"show_nav_options", "show_nav_options",
"show_nav_options_review", "show_nav_options_review",
"show_nav_hedge_plan",
] ]
settings_keys = [ settings_keys = [
"show_settings_transfer",
"show_settings_export", "show_settings_export",
"show_settings_password", "show_settings_password",
"show_settings_options_swap",
"show_settings_options_transfer", "show_settings_options_transfer",
] ]
return [ 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_history=page == "key_monitor",
key_list=page in ("key_monitor", "trade") or is_strategy, key_list=page in ("key_monitor", "trade") or is_strategy,
orders=page == "trade" or is_strategy, orders=page == "trade" or is_strategy,
stats_bundle=page == "stats", stats_bundle=False, # 旧永续统计已弃用;数据统计页走复盘 API + 资金曲线
strategy=is_strategy, strategy=is_strategy,
orphan_live=page == "trade" and is_shell, orphan_live=page == "trade" and is_shell,
) )
@@ -161,10 +161,18 @@ def trading_account_label(
parts.append(f"{float(usdt):.2f} USDT") parts.append(f"{float(usdt):.2f} USDT")
except (TypeError, ValueError): except (TypeError, ValueError):
pass 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: if eth_txt is not None:
parts.append(f"{eth_txt} ETH") 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: if btc_txt is not None:
parts.append(f"{btc_txt} BTC") parts.append(f"{btc_txt} BTC")
return "\n".join(parts) if parts else "—" return "\n".join(parts) if parts else "—"
+14 -9
View File
@@ -16,8 +16,9 @@ EMBED_TABS: tuple[str, ...] = (
"key_monitor", "key_monitor",
"options", "options",
"options_review", "options_review",
"hedge_plan", "stats",
"risk_policy", "risk_policy",
"live_guide",
"system_guide", "system_guide",
"env_config", "env_config",
"settings", "settings",
@@ -27,14 +28,15 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "options", "/": "options",
"/trade": "options", # 实盘下单界面已移除 "/trade": "options", # 实盘下单界面已移除
"/records": "options_review", # 永续交易记录与复盘已移除 "/records": "options_review", # 永续交易记录与复盘已移除
"/stats": "options", "/stats": "stats",
"/dashboard": "dashboard", "/dashboard": "dashboard",
"/account_ledger": "account_ledger", "/account_ledger": "account_ledger",
"/key_monitor": "key_monitor", "/key_monitor": "key_monitor",
"/options": "options", "/options": "options",
"/options/review": "options_review", "/options/review": "options_review",
"/hedge-plan": "hedge_plan", "/hedge-plan": "options", # 对冲计划已移除,旧链接落到期权
"/risk_policy": "risk_policy", "/risk_policy": "risk_policy",
"/live_guide": "live_guide",
"/system_guide": "system_guide", "/system_guide": "system_guide",
"/env_config": "env_config", "/env_config": "env_config",
"/settings": "settings", "/settings": "settings",
@@ -53,8 +55,9 @@ def order_rule_tips_template(exchange_key: str) -> str:
def include_transfer_block(exchange_key: str) -> bool: 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: def ui_open_guard_enabled(exchange_key: str) -> bool:
@@ -79,16 +82,18 @@ def embed_shell_enabled() -> bool:
_SETTINGS_SUB_TABS = frozenset( _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): 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(): if not embed_shell_enabled():
return None return None
if (request.args.get("embed") or "").strip() == "1":
return None
if (request.path or "").rstrip("/") == "/embed": if (request.path or "").rstrip("/") == "/embed":
return None return None
q = {k: v for k, v in request.args.items()} 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": "模拟资金"}) tabs.append({"key": "sim_funds", "title": "模拟资金"})
if disp.get("show_settings_password", True): if disp.get("show_settings_password", True):
tabs.append({"key": "password", "title": "账户密码"}) tabs.append({"key": "password", "title": "账户密码"})
if inst.get("show_transfer") and disp.get("show_settings_transfer", False): # 永续划转 / USDC 币种兑换已下线
tabs.append({"key": "transfer", "title": "永续划转"})
if disp.get("show_settings_export", True): if disp.get("show_settings_export", True):
tabs.append({"key": "export", "title": "数据导出"}) tabs.append({"key": "export", "title": "数据导出"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True): tabs.append({"key": "backup", "title": "备份恢复"})
_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": "币种兑换"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True): if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True):
tabs.append({"key": "options_transfer", "title": "期权划转"}) tabs.append({"key": "options_transfer", "title": "期权划转"})
return tabs 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 from lib.instance.instance_system_guide_lib import system_guide_template_context
return 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"): if p not in ("settings", "risk_policy", "env_config"):
return {} return {}
display = kwargs.pop("display", None) display = kwargs.pop("display", None)
+85 -20
View File
@@ -1,13 +1,13 @@
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启.""" """实例系统设置 API:导航开关,env 读写,改密,PM2 重启,备份恢复."""
from __future__ import annotations from __future__ import annotations
import os import os
from functools import wraps from functools import wraps
from typing import Any, Callable 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 ( from lib.env.env_ui_manifest import (
build_env_ui_payload, build_env_ui_payload,
filter_updates_for_ui, filter_updates_for_ui,
@@ -44,6 +44,17 @@ def _api_login_required(hub_token_write_allowed: bool = False):
# crypto_okx 可无 lib.hub;鉴权与中控 request_allowed 对齐 # crypto_okx 可无 lib.hub;鉴权与中控 request_allowed 对齐
allowed = bool(auth_disabled or logged_in) 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: if not allowed and hub_hdr and bridge and hub_hdr == bridge:
allowed = True allowed = True
if not allowed: if not allowed:
@@ -72,10 +83,14 @@ def register_instance_settings_routes(
exchange_key: str, exchange_key: str,
username: str, username: str,
password: str, password: str,
db_path: str | None = None,
upload_folder: str | None = None,
) -> None: ) -> None:
env_path = os.path.join(base_dir, ".env") env_path = os.path.join(base_dir, ".env")
example_path = os.path.join(base_dir, ".env.example") example_path = os.path.join(base_dir, ".env.example")
api_auth = _api_login_required() 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"]) @app.route("/api/settings/display", methods=["GET", "POST"])
@api_auth @api_auth
@@ -118,23 +133,8 @@ def register_instance_settings_routes(
if not clean: if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
if "OKX_OPTIONS_MARGIN_MODE" in clean: if "OKX_OPTIONS_MARGIN_MODE" in clean:
try: # 币本位专用机:忽略任何写入,强制 coin
from lib.options.options_margin_mode_lib import normalize_options_margin_mode clean["OKX_OPTIONS_MARGIN_MODE"] = "coin"
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
lines = read_env_lines(env_path)
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
if old_mode != new_mode:
conn_m = get_db()
try:
block = mode_switch_block_msg(conn_m, None)
if block:
return jsonify({"ok": False, "msg": block}), 400
finally:
conn_m.close()
except Exception as e:
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
changed = apply_env_updates(env_path, clean) changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path) groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups) reload_info = apply_env_reload(env_path, get_db, changed, groups)
@@ -179,6 +179,71 @@ def register_instance_settings_routes(
def api_admin_health(): def api_admin_health():
return jsonify({"ok": True, "status": "up"}) 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: def tab_allowed_fn(tab: str) -> bool:
prefs = get_display_prefs(get_db) prefs = get_display_prefs(get_db)
return tab_allowed(tab, prefs) 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", "journal_form_save.js": "application/javascript; charset=utf-8",
"instance_page.css": "text/css; charset=utf-8", "instance_page.css": "text/css; charset=utf-8",
"instance_embed.js": "application/javascript; 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_mobile_nav.js": "application/javascript; charset=utf-8",
"instance_stats.js": "application/javascript; charset=utf-8", "instance_stats.js": "application/javascript; charset=utf-8",
"instance_live.js": "application/javascript; charset=utf-8", "instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.js": "application/javascript; charset=utf-8", "instance_settings_prefs.js": "application/javascript; charset=utf-8",
"instance_backup.js": "application/javascript; charset=utf-8",
"instance_dashboard.js": "application/javascript; charset=utf-8", "instance_dashboard.js": "application/javascript; charset=utf-8",
"account_ledger.js": "application/javascript; charset=utf-8", "account_ledger.js": "application/javascript; charset=utf-8",
"options_expiry_countdown.js": "application/javascript; charset=utf-8", "options_expiry_countdown.js": "application/javascript; charset=utf-8",
@@ -40,7 +42,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"options_settings.js": "application/javascript; charset=utf-8", "options_settings.js": "application/javascript; charset=utf-8",
"options_position_cards.js": "application/javascript; charset=utf-8", "options_position_cards.js": "application/javascript; charset=utf-8",
"options_review.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", "order_entry_model.js": "application/javascript; charset=utf-8",
"focus_chart_page.js": "application/javascript; charset=utf-8", "focus_chart_page.js": "application/javascript; charset=utf-8",
"focus_chart_page.css": "text/css; 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) set_trading_mode(get_db, raw)
except Exception: except Exception:
pass 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 from lib.env.env_schema import updates_need_restart
return {"restart_required": updates_need_restart(groups, changed_keys)} 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) => { const pushCoin = (v, ccy) => {
if (v === null || v === undefined || v === "") return; if (v === null || v === undefined || v === "") return;
const n = Number(v); 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+$/, ""); const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`); parts.push(`${txt || "0"} ${ccy}`);
}; };
@@ -1200,7 +1202,23 @@ function accountSnapshotFundingMissing(data){
const hasTrading = data.current_capital != null && data.current_capital !== ""; const hasTrading = data.current_capital != null && data.current_capital !== "";
return !hasFunding && !hasTotal && !hasTrading; 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){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin"; const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
@@ -1212,9 +1230,13 @@ function applyAccountSnapshot(data){
applyOptionsFundsVisibility(!coinMode); applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} else if("funding_usdt" in data){
setFundsFieldText("total-capital", "—");
} }
if(data.total_funds != null && data.total_funds !== ""){ if(data.total_funds != null && data.total_funds !== ""){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); 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))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText( setFundsFieldText(
@@ -1226,6 +1248,16 @@ function applyAccountSnapshot(data){
data.options_margin_mode 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)){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel( const optFunding = formatOptionsFundingLabel(
@@ -1315,20 +1347,20 @@ function applyAccountSnapshot(data){
} }
function refreshAccountSnapshot(opts){ function refreshAccountSnapshot(opts){
const options = 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=>{ fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data); applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){ if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount += 1; clearAccountSnapshotMissRetry();
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount); return;
}else if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount = 0;
} }
scheduleAccountSnapshotMissRetry();
}).catch(()=>{ }).catch(()=>{
if(!options.silent && accountSnapshotRetryCount < 3){ scheduleAccountSnapshotMissRetry();
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
}
}); });
} }
@@ -127,8 +127,8 @@
{% include 'options_panel.html' %} {% include 'options_panel.html' %}
{% elif page == 'options_review' %} {% elif page == 'options_review' %}
{% include 'options_review_panel.html' %} {% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %} {% elif page == 'stats' %}
{% include 'hedge_plan_panel.html' %} {% include 'options_data_stats_panel.html' %}
{% endif %} {% endif %}
@@ -145,6 +145,10 @@
{% include 'system_guide_panel.html' %} {% include 'system_guide_panel.html' %}
{% endif %} {% endif %}
{% if page == 'live_guide' %}
{% include 'live_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %} {% if page == 'settings' %}
{% include 'settings_panel.html' %} {% include 'settings_panel.html' %}
{% endif %} {% endif %}
+16 -16
View File
@@ -3,12 +3,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <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> <script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=21"> <link rel="stylesheet" href="/static/instance_page.css?v=26">
<link rel="stylesheet" href="/static/instance_theme.css?v=124"> <link rel="stylesheet" href="/static/instance_theme.css?v=129">
<script src="/static/account_risk_badge.js?v=4"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<script src="/static/open_submit_gate.js?v=1"></script> <script src="/static/open_submit_gate.js?v=1"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
@@ -35,7 +36,7 @@
> >
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>加密货币|OKX 期权与对冲</h1> <h1>加密货币|OKX 期权</h1>
</div> </div>
<nav class="top-nav embed-top-nav" aria-label="实例导航"> <nav class="top-nav embed-top-nav" aria-label="实例导航">
<div class="top-nav-links"> <div class="top-nav-links">
@@ -48,12 +49,13 @@
{% if options_nav_visible and display.show_nav_options_review %} {% 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> <a href="/options/review" data-embed-tab="options_review" class="{% if initial_tab == 'options_review' %}active{% endif %}">期权复盘</a>
{% endif %} {% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %} <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>
<a href="/hedge-plan" data-embed-tab="hedge_plan" class="{% if initial_tab == 'hedge_plan' %}active{% endif %}">对冲计划</a>
{% endif %}
{% if display.show_nav_risk_policy %} {% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a> <a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %} {% 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> <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 %} {% 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> <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> <div id="embed-flash" class="flash" style="display:none" role="status"></div>
{% include 'instance_header_panel.html' %} {% 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' %} {% include 'instance_top_bar.html' %}
{% endif %} {% endif %}
@@ -97,9 +99,6 @@
{% if options_nav_visible and display.show_nav_options %} {% 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> <a href="/options" class="inst-m-tab{% if initial_tab == 'options' %} active{% endif %}" data-embed-tab="options">期权</a>
{% endif %} {% 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> <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> <button type="button" class="inst-m-tab" data-embed-tab="more" id="inst-m-tab-more" aria-haspopup="dialog" aria-expanded="false">更多</button>
</nav> </nav>
@@ -115,12 +114,13 @@
{% if options_nav_visible and display.show_nav_options_review %} {% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" data-embed-tab="options_review">期权复盘</a> <a href="/options/review" data-embed-tab="options_review">期权复盘</a>
{% endif %} {% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %} <a href="/stats" data-embed-tab="stats"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
<a href="/hedge-plan" data-embed-tab="hedge_plan">对冲计划</a>
{% endif %}
{% if display.show_nav_risk_policy %} {% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy">风控说明</a> <a href="/risk_policy" data-embed-tab="risk_policy">风控说明</a>
{% endif %} {% 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> <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 %} {% if display.show_nav_env_config %}
<a href="/env_config" data-embed-tab="env_config">env配置</a> <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>
<script src="/static/order_entry_model.js?v=5"></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/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/key_monitor_form.js?v=2"></script>
<script src="/static/instance_stats.js?v=5"></script> <script src="/static/instance_stats.js?v=5"></script>
{% include 'embed_boot_scripts.html' %} {% include 'embed_boot_scripts.html' %}
@@ -157,7 +157,7 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </script>
<script src="/static/instance_settings_prefs.js?v=24"></script> <script src="/static/instance_settings_prefs.js?v=24"></script>
<script src="/static/instance_live.js?v=7"></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> <script src="/static/instance_mobile_nav.js?v=2"></script>
</body> </body>
</html> </html>
+11 -5
View File
@@ -16,11 +16,7 @@
</div> </div>
{% if env_config_groups %} {% if env_config_groups %}
{% set ns = namespace(mode_idx=0) %} <div class="env-config-body card" data-env-ssr="1" id="env-config-body">
{% for group in env_config_groups %}
{% if '期权/对冲模式' in (group.title or '') %}{% set ns.mode_idx = loop.index0 %}{% endif %}
{% endfor %}
<div class="env-config-body card" data-env-ssr="1" id="env-config-body" data-env-mode-section-idx="{{ ns.mode_idx }}">
{% for group in env_config_groups %} {% for group in env_config_groups %}
<input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}> <input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
{% endfor %} {% endfor %}
@@ -77,6 +73,7 @@
onfocus="this.removeAttribute('readonly')" onfocus="this.removeAttribute('readonly')"
> >
{% else %} {% else %}
<div class="env-field-input-wrap{% if field.key == 'HUB_BRIDGE_TOKEN' %} env-field-input-wrap--copy{% endif %}">
<input <input
class="env-field-input" class="env-field-input"
id="env-f-{{ field.key }}" id="env-f-{{ field.key }}"
@@ -92,6 +89,15 @@
data-bwignore="true" data-bwignore="true"
data-form-type="other" 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 %} {% endif %}
</div> </div>
{% endfor %} {% 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>
+78 -87
View File
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <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> <script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
@@ -18,8 +18,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest"> <link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=21"> <link rel="stylesheet" href="/static/instance_page.css?v=26">
<link rel="stylesheet" href="/static/instance_theme.css?v=118"> <link rel="stylesheet" href="/static/instance_theme.css?v=129">
</head> </head>
<body <body
@@ -153,45 +153,46 @@
<div class="header"> <div class="header">
<h1>加密货币|交易监控 + AI复盘一体化</h1> <h1>加密货币|交易监控 + AI复盘一体化</h1>
</div> </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="/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="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a> <a href="/key_monitor" 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" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} 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 %} {% 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 %} {% endif %}
{% if not intraday_discipline and display.show_nav_strategy_records %} {% 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 %} {% endif %}
{% if display.show_nav_records %} {% if display.show_nav_records %}
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a> <a href="/records" data-embed-tab="records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
{% endif %}
{% if display.show_nav_stats %}
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
{% endif %} {% endif %}
{% if options_nav_visible and display.show_nav_options %} {% 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 %} {% endif %}
{% if options_nav_visible and display.show_nav_options_review %} {% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a> <a href="/options/review" data-embed-tab="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>
{% endif %} {% 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 %} {% 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 %} {% 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 %} {% 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 %} {% 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> </div>
{% include 'nav_spot_tickers.html' %}
</nav>
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %} {% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %} {% 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' %} {% include 'instance_top_bar.html' %}
{% endif %} {% endif %}
@@ -205,16 +206,8 @@
{% elif page == 'trade' %} {% elif page == 'trade' %}
<div class="dual-panel-grid" style="grid-column:1/-1"> <div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card"> <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:8px">实盘下单监控</h2>
<h2 style="margin-bottom:0">实盘下单监控</h2> <p class="muted">永续下单已下线,请使用「期权」页。</p>
{% 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' %}
</div> </div>
<div class="card"> <div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2> <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 class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
</span> </span>
{% endif %} {% 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> <span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div> </div>
<div class="pos-head-actions"> <div class="pos-head-actions">
@@ -365,8 +357,8 @@
{% include 'options_panel.html' %} {% include 'options_panel.html' %}
{% elif page == 'options_review' %} {% elif page == 'options_review' %}
{% include 'options_review_panel.html' %} {% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %} {% elif page == 'stats' %}
{% include 'hedge_plan_panel.html' %} {% include 'options_data_stats_panel.html' %}
{% endif %} {% endif %}
@@ -387,48 +379,14 @@
{% include 'system_guide_panel.html' %} {% include 'system_guide_panel.html' %}
{% endif %} {% endif %}
{% if page == 'live_guide' %}
{% include 'live_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %} {% if page == 'settings' %}
{% include 'settings_panel.html' %} {% include 'settings_panel.html' %}
{% endif %} {% 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>
<div class="modal" id="imgModal" onclick="closeModal()"> <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>
<script src="/static/order_entry_model.js?v=5"></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/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/strategy_roll.js?v=6"></script>
<script src="/static/instance_stats.js?v=5"></script> <script src="/static/instance_stats.js?v=5"></script>
<script> <script>
@@ -1639,7 +1597,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
const pushCoin = (v, ccy) => { const pushCoin = (v, ccy) => {
if (v == null || v === "") return; if (v == null || v === "") return;
const n = Number(v); 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+$/, ""); const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`); parts.push(`${txt || "0"} ${ccy}`);
}; };
@@ -1681,7 +1641,23 @@ function accountSnapshotFundingMissing(data){
const hasTrading = data.current_capital != null && data.current_capital !== ""; const hasTrading = data.current_capital != null && data.current_capital !== "";
return !hasFunding && !hasTotal && !hasTrading; 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){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin"; const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
@@ -1693,9 +1669,13 @@ function applyAccountSnapshot(data){
applyOptionsFundsVisibility(!coinMode); applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} else if("funding_usdt" in data){
setFundsFieldText("total-capital", "—");
} }
if(data.total_funds != null && data.total_funds !== ""){ if(data.total_funds != null && data.total_funds !== ""){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); 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))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText( setFundsFieldText(
@@ -1707,6 +1687,16 @@ function applyAccountSnapshot(data){
data.options_margin_mode 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)){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel( const optFunding = formatOptionsFundingLabel(
@@ -1805,20 +1795,20 @@ function applyAccountSnapshot(data){
} }
function refreshAccountSnapshot(opts){ function refreshAccountSnapshot(opts){
const options = 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=>{ fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data); applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){ if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount += 1; clearAccountSnapshotMissRetry();
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount); return;
}else if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount = 0;
} }
scheduleAccountSnapshotMissRetry();
}).catch(()=>{ }).catch(()=>{
if(!options.silent && accountSnapshotRetryCount < 3){ scheduleAccountSnapshotMissRetry();
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
}
}); });
} }
@@ -2082,6 +2072,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
</script> </script>
<script src="/static/records_review_page.js?v=4"></script> <script src="/static/records_review_page.js?v=4"></script>
<script src="/static/options_expiry_countdown.js?v=1"></script> <script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/nav_spot_tickers.js?v=3"></script>
<script src="/static/instance_dashboard.js?v=5"></script> <script src="/static/instance_dashboard.js?v=5"></script>
<script src="/static/account_ledger.js?v=1"></script> <script src="/static/account_ledger.js?v=1"></script>
<script> <script>
@@ -28,10 +28,12 @@
{% if trade_policy.badge_text %} {% if trade_policy.badge_text %}
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span> <span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
{% endif %} {% 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> <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> </div>
{% include 'instance_theme_toggle.html' %} {% include 'instance_theme_toggle.html' %}
{% if show_logout|default(true) %}
<a href="/logout" class="instance-logout-btn" title="退出登录">退出</a>
{% endif %}
</div> </div>
</div> </div>
<div class="instance-header-stats-wrap instance-desktop-only"> <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> <script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 关键位放大</title> <title>{{ exchange_display }} | 关键位放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=50"> <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"> <link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head> </head>
<body class="focus-page" data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}"> <body class="focus-page" data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}">
@@ -140,8 +140,10 @@
{% endif %} {% endif %}
</div> </div>
<form id="key-form" action="/add_key" method="post" class="form-row"> <form id="key-form" action="/add_key" method="post" class="form-row">
{% from 'trade_policy_fields.html' import trade_policy_symbol with context %} <select name="symbol" id="key-symbol" required class="trade-policy-symbol-select" title="仅 BTC/ETH,与期权标的一致">
{{ trade_policy_symbol('symbol', 'key-symbol') }} <option value="BTC/USDT" selected>BTC/USDT</option>
<option value="ETH/USDT">ETH/USDT</option>
</select>
<select name="type" id="key-type-select" required> <select name="type" id="key-type-select" required>
<option value="关键支撑阻力" selected>关键支撑阻力</option> <option value="关键支撑阻力" selected>关键支撑阻力</option>
</select> </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' %} {% elif tab.key == 'password' %}
{% include 'password_settings_panel.html' %} {% include 'password_settings_panel.html' %}
{% elif tab.key == 'transfer' %} {% elif tab.key == 'transfer' %}
<h2>永续资金划转</h2> <h2>资金划转</h2>
<p class="settings-subcard-desc">账户内:资金账户与交易账户之间划转 USDT.</p> <p class="settings-subcard-desc muted">永续账户划转已下线;期权/模拟盘请使用对应页内划转.</p>
{% include 'instance_transfer_panel.html' %}
{% elif tab.key == 'export' %} {% elif tab.key == 'export' %}
<h2>数据导出</h2> <h2>数据导出</h2>
<p class="settings-subcard-desc muted">CSV · v{{ instance_settings.data_export_version }}</p> <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_monitors">关键位(当前)</a>
<a href="/export/key_monitor_history">关键位历史</a> <a href="/export/key_monitor_history">关键位历史</a>
</div> </div>
{% elif tab.key == 'backup' %}
{% include 'backup_settings_panel.html' %}
{% elif tab.key == 'options_swap' %} {% elif tab.key == 'options_swap' %}
<h2>币种兑换</h2> <h2>币种兑换</h2>
{% include 'options_settings_swap.html' %} {% 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
+218 -15
View File
@@ -11,9 +11,11 @@ from lib.options.options_close_gate_lib import (
update_close_gate, update_close_gate,
) )
from lib.options.options_pricing_lib import ( from lib.options.options_pricing_lib import (
PREMIUM_DECIMALS_COIN,
estimate_close_by_bids, estimate_close_by_bids,
fetch_option_mark_px, fetch_option_mark_px,
is_stub_bid_px, is_stub_bid_px,
round_premium_amount,
total_premium, total_premium,
) )
@@ -306,7 +308,10 @@ def close_option_by_bid1(
px = float(order.get("px", locked_bid_px)) px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "") oid = str((order.get("data") or {}).get("ordId") or "")
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) time.sleep(0.6)
invalidate_option_positions_cache() invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex) raw2 = cfg["fetch_option_positions"](ex)
@@ -325,37 +330,211 @@ def close_option_by_bid1(
reduced = max(0, before_avail - after_avail) reduced = max(0, before_avail - after_avail)
remaining_pos = after_avail remaining_pos = after_avail
fully_closed = remaining_pos < 1 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: if fully_closed:
clear_close_gate(inst_id) clear_close_gate(inst_id)
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: 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) 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( 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' WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC ORDER BY id ASC
""", """,
(inst_id,), (inst_id,),
).fetchall() ).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_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 = 0.0
allocated_cfee = 0.0
allocated_ofee = 0.0
for i, row in enumerate(open_rows): for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0) paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1: if i == len(open_rows) - 1:
recv = round(prem_recv - allocated, 4) 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:
row_ofee = round(total_open_fee - allocated_ofee, 8) if total_open_fee > 0 else 0.0
elif total_paid > 0: elif total_paid > 0:
recv = round(prem_recv * (paid / total_paid), 4) share = paid / total_paid
recv = round_premium_amount(
total_recv * share, decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv allocated += recv
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: else:
recv = round(prem_recv / len(open_rows), 4) recv = round_premium_amount(
total_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv allocated += recv
pnl = round(recv - paid, 4) 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 = "" 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: if signal_note and i == len(open_rows) - 1:
note_sql = """, note_sql = """,
signal_note = CASE signal_note = CASE
@@ -363,15 +542,17 @@ def close_option_by_bid1(
ELSE signal_note ELSE signal_note
END""" END"""
params.append(signal_note) params.append(signal_note)
from lib.options.options_time_lib import options_app_now_str
params.append(options_app_now_str())
params.append(int(row["id"])) params.append(int(row["id"]))
conn.execute( conn.execute(
f""" f"""
UPDATE options_trades UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?, SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = ? 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} {note_sql}
WHERE id = ? WHERE id = ?
""", """,
@@ -380,6 +561,27 @@ def close_option_by_bid1(
conn.commit() conn.commit()
finally: finally:
conn.close() 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: elif require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性 # 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id) mark_close_gate_passed(inst_id)
@@ -391,11 +593,12 @@ def close_option_by_bid1(
"bid": px, "bid": px,
"locked_bid_px": locked_bid_px, "locked_bid_px": locked_bid_px,
"submitted_sheets": level_sheets, "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, "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"), "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, "fully_closed": fully_closed,
"msg": ( "msg": (
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张" 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: 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: 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_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() _lock = threading.Lock()
# inst_id -> gate state # inst_id -> gate state
+380 -32
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]: def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances 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) trading_f = float(trading or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
trading_f = 0.0 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) return compute_coin_budget_usdt(trading_f, buffer=buf)
@@ -69,13 +118,33 @@ def open_coin_option_buy_full(
parts = inst_id.split("-") parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper() 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"]() conn = cfg["get_db"]()
try: try:
init_options_tables(conn) init_options_tables(conn)
try:
conn.commit()
except Exception:
pass
block = _spot_bridge.bridge_blocks_new_open_msg(conn) block = _spot_bridge.bridge_blocks_new_open_msg(conn)
if block: if block:
return {"ok": False, "msg": block, "can_open": False} return {"ok": False, "msg": block, "can_open": False}
# 网络检查前再提交,避免握锁拉持仓/行情
try:
conn.commit()
except Exception:
pass
compound_block = compound_full_single_position_block_msg( compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions") ex, fetch_positions=cfg.get("fetch_option_positions")
) )
@@ -142,6 +211,21 @@ def open_coin_option_buy_full(
buy_usdt = float(plan["buy_usdt"]) buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"]) 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,不全额兑换预算 # 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0 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) 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, inst_id=inst_id,
message="已买币,待开期权", message="已买币,待开期权",
) )
try:
conn.commit()
except Exception:
pass
# 等余额落账 # 等余额落账
time.sleep(1.5) time.sleep(1.5)
try: try:
@@ -183,14 +271,59 @@ def open_coin_option_buy_full(
} }
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0)) coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0: if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示 # 落账延迟:短重试取增量;绝不把整户余额当成「本次买入」
coin_bought = float(coin_after) for _ in range(3):
if coin_bought <= 0: time.sleep(0.6)
rb = _spot_bridge.rollback_bought_coin_to_usdt( try:
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效" from lib.exchange.okx_options_lib import invalidate_options_balance_cache
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info} 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,
}
_spot_bridge.update_bridge(conn, bridge_id, coin_bought=float(coin_bought)) _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( sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask), 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")) fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError): except (TypeError, ValueError):
fill_timeout = 12.0 fill_timeout = 12.0
# 等待成交可能十余秒:先提交并释放业务连接,避免锁死中控/卖币
try:
conn.commit()
except Exception:
pass
try:
conn.close()
except Exception:
pass
conn = None
fill = wait_option_order_full_fill( fill = wait_option_order_full_fill(
ex, ex,
inst_id=inst_id, inst_id=inst_id,
@@ -296,6 +440,9 @@ def open_coin_option_buy_full(
timeout_sec=fill_timeout, timeout_sec=fill_timeout,
cancel_on_timeout=True, cancel_on_timeout=True,
) )
conn = cfg["get_db"]()
init_options_tables(conn)
if not fill.get("ok"): if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0) filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0: if filled_n <= 0:
@@ -313,11 +460,73 @@ def open_coin_option_buy_full(
premium_paid = round(float(ask) * eth_amount, 8) premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying) 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( _spot_bridge.update_bridge(
conn, conn,
bridge_id, bridge_id,
status=BRIDGE_HOLDING, status=BRIDGE_HOLDING,
inst_id=inst_id, inst_id=inst_id,
coin_bought=float(need_coin),
message="期权持仓中", message="期权持仓中",
) )
@@ -337,6 +546,9 @@ def open_coin_option_buy_full(
bridge_id=bridge_id, bridge_id=bridge_id,
budget_usdt=buy_usdt, budget_usdt=buy_usdt,
premium_ccy=premium_ccy, 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_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult, profit_exit_mult=profit_exit_mult,
) )
@@ -393,10 +605,11 @@ def open_coin_option_buy_full(
"fill": fill, "fill": fill,
} }
finally: finally:
try: if conn is not None:
conn.close() try:
except Exception: conn.close()
pass except Exception:
pass
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
@@ -405,14 +618,40 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe = 1 if kwargs.get("profit_exit_enabled") else 0 pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0) pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle" 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( cur = conn.execute(
""" """
INSERT INTO options_trades( INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id, open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt, margin_mode, premium_ccy, bridge_id, budget_usdt,
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 profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
""", """,
( (
kwargs["inst_id"], kwargs["inst_id"],
@@ -429,6 +668,11 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
kwargs.get("premium_ccy") or "ETH", kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"), kwargs.get("bridge_id"),
kwargs.get("budget_usdt"), kwargs.get("budget_usdt"),
open_fee,
fee_ccy,
open_index_px,
tusdt_before,
initial_premium_usdt,
pe, pe,
pe_mult, pe_mult,
pe_state, pe_state,
@@ -446,32 +690,136 @@ def maybe_sell_spot_after_close(
inst_id: str, inst_id: str,
close_result: dict[str, Any] | None = None, close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.""" """期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.
持仓查询失败/空响应时不卖,等下次监控再查(与 sweep_spot_after_options_flat 一致).
"""
del close_result # 预留字段;卖回只认交易所持仓实况
if margin_mode_from_inst_id(inst_id) != "coin": if margin_mode_from_inst_id(inst_id) != "coin":
return None return None
# 仍有仓则不卖
try: try:
rows = cfg["fetch_option_positions"](ex) or [] rows = cfg["fetch_option_positions"](ex)
for p in rows: except Exception as e:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id: return {"ok": False, "msg": f"持仓查询失败,暂不卖币: {e}", "skipped": True}
continue if rows is None:
try: return {"ok": False, "msg": "持仓查询失败: 空响应,暂不卖币", "skipped": True}
if abs(float(p.get("pos") or 0)) > 1e-12: for p in rows or []:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"} if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
except (TypeError, ValueError): continue
pass try:
except Exception: if abs(float(p.get("pos") or 0)) > 1e-12:
pass return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
parts = inst_id.split("-") parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper() 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: 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) res = result or {}
return _spot_bridge.sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id) if res.get("ok") and res.get("sell_complete", True) and not res.get("incomplete"):
finally: 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: try:
conn.close() _spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception: except Exception:
pass 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: try:
init_options_tables(conn) init_options_tables(conn)
try: try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_target_lib import targets_by_inst from lib.options.options_target_lib import targets_by_inst
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
except Exception: except Exception:
tgt_map = {} tgt_map = {}
hedge_target_map = {}
for p in raw: for p in raw:
if not isinstance(p, dict): 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_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id") row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon 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) rows.append(row)
finally: finally:
conn.close() conn.close()
+140
View File
@@ -95,6 +95,19 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ON options_target_monitors(status) 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 ( for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
@@ -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 premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER", "ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL", "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: try:
conn.execute(ddl) conn.execute(ddl)
@@ -116,6 +146,12 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ensure_bridge_table(conn) ensure_bridge_table(conn)
except Exception: except Exception:
pass 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) 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) 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: def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
"""同合约所有 open 腿张数合计.""" """同合约所有 open 腿张数合计."""
inst = (inst_id or "").strip() 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: if not row or int(row["n"] or 0) < 1:
return None return None
return int(row["total"] or 0) 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
+46 -16
View File
@@ -29,6 +29,25 @@ def enrich_position_row_display(
if premium_override is not None: if premium_override is not None:
row["premium_paid"] = premium_override row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy) 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 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: if raw_live is None:
return [] return []
prem_by_inst: dict[str, float | None] = {}
hidden_keys: set[str] = set()
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_options_tables(conn) init_options_tables(conn)
@@ -57,25 +78,34 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
} }
for p in raw_live: for p in raw_live:
inst = str(p.get("instId") or "").strip() inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None if inst:
row = enrich_position_row_display( prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
cfg, try:
ex, conn.commit()
p, except Exception:
meta_cache=meta_cache, pass
premium_override=premium_override,
)
open_ms = None
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
try:
if ctime is not None and str(ctime).strip():
open_ms = int(float(ctime))
except (TypeError, ValueError):
open_ms = None
items.append(format_live_option_history_row(row, open_ms=open_ms))
finally: finally:
conn.close() 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,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
open_ms = None
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
try:
if ctime is not None and str(ctime).strip():
open_ms = int(float(ctime))
except (TypeError, ValueError):
open_ms = None
items.append(format_live_option_history_row(row, open_ms=open_ms))
hist_raw = fetch_all_option_positions_history(ex, limit=200) hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw: for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip() inst_id = str(raw.get("instId") or "").strip()
+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) raw = cfg["fetch_option_positions"](ex)
if raw is None: if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} 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]] = [] target_monitors: list[dict[str, Any]] = []
try: try:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn) target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_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(): for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"): if pe.get("profit_exit_enabled"):
target_monitors.append( 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_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state") p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle") 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: try:
from lib.instance.instance_dashboard_lib import ( from lib.instance.instance_dashboard_lib import (
_format_options_target, _format_options_target,
@@ -85,13 +79,14 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn.close() conn.close()
except Exception: except Exception:
target_monitors = [] target_monitors = []
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
upl_total = 0.0 upl_total = 0.0
has_upl = False has_upl = False
for p in positions: for p in positions:
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl # 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
p["display_pnl_usdt"] = pnl
if pnl is None: if pnl is None:
continue continue
has_upl = True has_upl = True
@@ -138,7 +133,10 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
except Exception: except Exception:
open_bridges = [] open_bridges = []
return { from lib.options.options_underly_lib import underly_cfg_snapshot
_uly_snap = underly_cfg_snapshot()
out = {
"ok": True, "ok": True,
"enabled": True, "enabled": True,
"positions": positions, "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(), "max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode, "options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC", "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, "coin_budget": coin_budget,
"bridge_status": bridge_status, "bridge_status": bridge_status,
"open_bridges": open_bridges, "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: except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)} return {"ok": False, "enabled": True, "msg": str(e)}
+18 -26
View File
@@ -24,30 +24,21 @@ def _env_float(name: str, default: float) -> float:
def normalize_options_margin_mode(raw: Any = None) -> str: def normalize_options_margin_mode(raw: Any = None) -> str:
"""返回 usdc | coin;未配置时默认币本位.""" """币本位专用机:始终 coin;历史 usdc 配置读取时亦归一为 coin."""
if raw is None: del raw
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
v = str(raw or MODE_COIN).strip().lower()
if v in ("usdc", "usdc_margin", "usd_margin", "u本位", "u"):
return MODE_USDC
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
return MODE_COIN
# 空串或未知值:默认币本位
if not v:
return MODE_COIN
return MODE_COIN return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool: 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: 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" u = (underlying or "ETH").strip().upper() or "ETH"
mode = normalize_options_margin_mode(margin_mode) return f"{u}-USD"
if mode == MODE_COIN:
return f"{u}-USD"
return f"{u}-USD_UM"
def margin_mode_from_inst_id(inst_id: str) -> str: 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: def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
if normalize_options_margin_mode(margin_mode) == MODE_COIN: """币本位权利金为标的币;历史 USDC 行(margin_mode=usdc)仍标 USDC."""
return (underlying or "ETH").strip().upper() or "ETH" if str(margin_mode or "").strip().lower() == MODE_USDC:
return "USDC" return "USDC"
return (underlying or "ETH").strip().upper() or "ETH"
def spot_quote_inst_id(underlying: str) -> str: 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)) 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) 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: if buf <= 0:
buf = 0.95 buf = 1.0
fixed = ( fixed = (
float(fixed_budget_usdt) float(fixed_budget_usdt)
if fixed_budget_usdt is not None 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: def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
""" """
现货买入相对权利金的倍数缓冲. 现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%). env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.05(=多买 5%).
也可写 0.10 表示 +10%. 也可写 0.05 表示 +5%.
""" """
if raw is None: 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: else:
try: try:
v = float(raw) v = float(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
v = 1.10 v = 1.05
if v <= 0: if v <= 0:
return 1.10 return 1.05
if v < 1.0: if v < 1.0:
return 1.0 + v return 1.0 + v
return v return v
+219 -25
View File
@@ -129,12 +129,16 @@ def run_options_profit_alerts(
premium_ccy=str(bucket.get("premium_ccy") or pos.get("premium_ccy") or ""), premium_ccy=str(bucket.get("premium_ccy") or pos.get("premium_ccy") or ""),
) )
try: try:
# 推送前先提交,避免握锁等 webhook
try:
conn.commit()
except Exception:
pass
send_wechat(msg) send_wechat(msg)
conn.execute( conn.execute(
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})", f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
tuple(bucket["ids"]), tuple(bucket["ids"]),
) )
# 立即提交,避免后续目标/翻倍/同步异常回滚后每轮重推
try: try:
conn.commit() conn.commit()
except Exception: except Exception:
@@ -183,7 +187,8 @@ def backfill_closed_options_realized_pnl_from_history(
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote, 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 FROM options_trades
WHERE status = 'closed' WHERE status = 'closed'
ORDER BY id DESC ORDER BY id DESC
@@ -194,8 +199,16 @@ def backfill_closed_options_realized_pnl_from_history(
if not rows: if not rows:
return 0 return 0
from lib.options.options_pnl_settle_lib import (
resolve_close_index_px,
settle_exchange_realized_pnl,
)
groups: dict[str, list[Any]] = {} groups: dict[str, list[Any]] = {}
for row in rows: for row in rows:
# 已冻结 U 的本地结算不再被交易所历史覆盖
if _safe_float(row["realized_pnl_usdt"]) is not None:
continue
inst = str(row["inst_id"] or "").strip() inst = str(row["inst_id"] or "").strip()
if not inst or inst not in by_inst: if not inst or inst not in by_inst:
continue continue
@@ -229,6 +242,8 @@ def backfill_closed_options_realized_pnl_from_history(
total_paid = 0.0 total_paid = 0.0
for r in group: for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0) total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
underly = str(group[0]["underlying"] or "").strip()
close_idx = resolve_close_index_px(None, inst_id=inst, underlying=underly)
allocated = 0.0 allocated = 0.0
for i, r in enumerate(group): for i, r in enumerate(group):
paid = float(_safe_float(r["premium_paid"]) or 0.0) paid = float(_safe_float(r["premium_paid"]) or 0.0)
@@ -250,17 +265,37 @@ def backfill_closed_options_realized_pnl_from_history(
ord_ok = (not matched_pos) or (local_ord == matched_pos) ord_ok = (not matched_pos) or (local_ord == matched_pos)
if pnl_ok and quote_ok and ord_ok: if pnl_ok and quote_ok and ord_ok:
continue 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( conn.execute(
""" """
UPDATE options_trades UPDATE options_trades
SET realized_pnl = ?, SET realized_pnl = ?,
premium_received = ?, premium_received = ?,
close_quote = COALESCE(?, close_quote), close_quote = COALESCE(?, close_quote),
close_ord_id = COALESCE(?, close_ord_id) close_ord_id = COALESCE(?, close_ord_id),
WHERE 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 updated += 1
return updated return updated
@@ -279,13 +314,21 @@ def sync_open_options_trades(
""" """
rows = conn.execute( 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 FROM options_trades
WHERE status = 'open' WHERE status = 'open'
""" """
).fetchall() ).fetchall()
updated = 0 updated = 0
closed_inst_ids: list[str] = []
now_ms = int(time.time() * 1000) 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: for row in rows:
inst_id = str(row["inst_id"] or "") inst_id = str(row["inst_id"] or "")
if not inst_id or inst_id in live_inst_ids: if not inst_id or inst_id in live_inst_ids:
@@ -308,8 +351,6 @@ def sync_open_options_trades(
close_quote = close_info.get("close_quote") close_quote = close_info.get("close_quote")
realized_pnl = close_info.get("realized_pnl") realized_pnl = close_info.get("realized_pnl")
close_ord_id = close_info.get("pos_id") 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") close_ms = close_info.get("close_ms")
if close_ms: if close_ms:
closed_at = ms_to_options_app_str(close_ms) closed_at = ms_to_options_app_str(close_ms)
@@ -326,6 +367,44 @@ def sync_open_options_trades(
if not closed_at: if not closed_at:
closed_at = options_app_now_str() 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( conn.execute(
""" """
UPDATE options_trades UPDATE options_trades
@@ -335,6 +414,10 @@ def sync_open_options_trades(
realized_pnl = ?, realized_pnl = ?,
close_ord_id = COALESCE(?, close_ord_id), close_ord_id = COALESCE(?, close_ord_id),
closed_at = COALESCE(?, closed_at), 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 signal_note = CASE
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '') WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
THEN '到期结算' THEN '到期结算'
@@ -348,11 +431,16 @@ def sync_open_options_trades(
realized_pnl, realized_pnl,
close_ord_id, close_ord_id,
closed_at, closed_at,
settled.get("close_index_px"),
settled.get("open_index_px"),
pnl_usdt,
prem_ccy,
close_reason, close_reason,
int(row["id"]), int(row["id"]),
), ),
) )
updated += 1 updated += 1
closed_inst_ids.append(inst_id)
if notify_cfg is not None: if notify_cfg is not None:
try: try:
from lib.options.options_notify_lib import notify_options_close from lib.options.options_notify_lib import notify_options_close
@@ -371,9 +459,80 @@ def sync_open_options_trades(
) )
except Exception: except Exception:
pass pass
if notify_cfg is not None:
_notify_recent_unsent_expiry_closes(conn, notify_cfg)
# 卖币不在此处做:调用方须先 commit/close 本连接,再调 sell_spot_after_options_sync,
# 否则监控线程握锁时中控快照会超时,表现为全部离线。
return updated 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( def reconcile_live_open_trades(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
@@ -440,6 +599,7 @@ def options_monitor_loop(
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None, profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_cfg: dict[str, Any] | None = None, profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None, stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
cond_order_tick_fn: Callable[[sqlite3.Connection], None] | None = None,
stop_event: Any = None, stop_event: Any = None,
) -> None: ) -> None:
if not enabled: if not enabled:
@@ -448,9 +608,25 @@ def options_monitor_loop(
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)(): if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
break break
try: try:
conn = get_db() # 持仓拉取不握 DB,避免与模拟盘钱包写互相锁
try: try:
positions = fetch_positions() 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( run_options_profit_alerts(
conn, conn,
positions, positions,
@@ -459,25 +635,37 @@ def options_monitor_loop(
account_label=account_label, account_label=account_label,
ticker_bid_fn=ticker_bid_fn, ticker_bid_fn=ticker_bid_fn,
) )
pe_cfg = dict(profit_exit_cfg or {}) conn.commit()
pe_cfg.setdefault("send_wechat", send_wechat) except Exception:
pe_cfg.setdefault("account_label", account_label) pass
if target_close_fn is not None: finally:
from lib.options.options_target_lib import run_options_target_closes 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( run_options_target_closes(
conn, get_db,
positions, positions,
close_fn=target_close_fn, close_fn=target_close_fn,
send_wechat=send_wechat, send_wechat=send_wechat,
account_label=account_label, account_label=account_label,
cfg=pe_cfg, cfg=pe_cfg,
) )
if profit_exit_close_fn is not None: except Exception:
from lib.options.options_profit_exit_lib import run_options_profit_exits 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( run_options_profit_exits(
conn, get_db,
positions, positions,
close_fn=profit_exit_close_fn, close_fn=profit_exit_close_fn,
send_wechat=send_wechat, send_wechat=send_wechat,
@@ -485,12 +673,18 @@ def options_monitor_loop(
cfg=pe_cfg, cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"), ex=pe_cfg.get("exchange_options"),
) )
if sync_trades_fn is not None: except Exception:
sync_trades_fn(conn) pass
conn.commit()
finally: if sync_trades_fn is not None:
conn.close() try:
# 平仓限价挂单超时撤单(独立于 DB 事务) 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: if stale_pending_fn is not None:
try: try:
stale_pending_fn() stale_pending_fn()
+44
View File
@@ -58,6 +58,38 @@ def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
pass 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: def notify_options_send(cfg: dict[str, Any], content: str) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat") send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send): if not callable(send):
@@ -200,6 +232,12 @@ def notify_options_open(
trade_id=trade_id, trade_id=trade_id,
premium_ccy=ccy, premium_ccy=ccy,
) )
# 推送前先提交,避免握锁等 webhook 导致模拟盘 database is locked
if conn is not None:
try:
conn.commit()
except Exception:
pass
ok = notify_options_send(cfg, msg) ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None: if ok and conn is not None and trade_id is not None:
conn.execute( conn.execute(
@@ -304,6 +342,12 @@ def notify_options_close(
trade_id=head.get("id") if len(rows) == 1 else None, trade_id=head.get("id") if len(rows) == 1 else None,
premium_ccy=ccy, premium_ccy=ccy,
) )
# 推送企业微信前先提交,避免握锁等待 webhook(可达数秒)导致卖币/快照 database is locked
if conn is not None:
try:
conn.commit()
except Exception:
pass
ok = notify_options_send(cfg, msg) ok = notify_options_send(cfg, msg)
if ok and conn is not None: if ok and conn is not None:
for r in pending or rows: 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