112 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
140 changed files with 22011 additions and 15399 deletions
+19 -45
View File
@@ -80,6 +80,8 @@ LIVE_TRADING_ENABLED=true
SIM_DEFAULT_MODE=sim
SIM_INITIAL_EQUITY_USDT=10000
SIM_INITIAL_USDC=0
# 实盘初始资金(U);中控收益率=已结算盈亏/该值。不填则中控回退最早余额采样
LIVE_INITIAL_EQUITY_USDT=
SIM_FEE_RATE=0.0005
# =============================================================================
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2)
@@ -102,11 +104,11 @@ OKX_POSITION_INST_TYPE=SWAP
EXCHANGE_DISPLAY_NAME=OKX
# 企业微信推送里展示的账户备注
# OKX_ACCOUNT_LABEL=
# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧
# 顶栏是否显示 USDT 资金/交易账户(热更);币本位买币仍会显示交易账户可用额
OKX_SHOW_PERP_FUNDS=true
# =============================================================================
# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
# 期权模块(币本位专用;与上方 OKX_API_* 同源;修改启用开关后须重启 PM2)
# 详见 docs/期权方案.md 与 docs/期权用法.md
# =============================================================================
OKX_OPTIONS_ENABLED=true
@@ -116,7 +118,7 @@ OKX_OPTIONS_ENABLED=true
# OKX_OPTIONS_API_PASSPHRASE=
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
# 仅币本位期权(USDT买币桥);USDC 期权已下线,此项恒视为 coin
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10
@@ -125,19 +127,14 @@ OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
# 交易模式固定单独期权(历史 perp_options / options_options 读取时归一为 options)
OKX_TRADE_MODE=options
# 永期对冲改为: OKX_TRADE_MODE=perp_options
# 期期对冲改为: OKX_TRADE_MODE=options_options
# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
# 期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更
OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0
# 期权标的白名单(逗号分隔,仅 ETH/BTC)。只配一个则交易页只显示该标的;开仓/链/报价/条件单均校验。资金页不受影响。
OKX_OPTIONS_UNDERLYINGS=ETH,BTC
# 默认选中标的;须落在白名单内,否则取白名单第一项
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部
OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true
@@ -148,46 +145,15 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
# 开仓成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# 目标平仓门控(目标位触发后按买一平仓前需满足)
OKX_OPTIONS_CLOSE_GATE_MODE=premium
# premium:可回收≥权利金×倍数;net_pnl:净盈亏(买一可回收−权利金)折USDT≥下限
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT=0
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
# =============================================================================
# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md)
# =============================================================================
# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读)
HEDGE_PLAN_ENABLED=false
HEDGE_PLAN_SHOW_PERP_OPTIONS=false
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=false
HEDGE_PLAN_LIVE_ORDER=false
# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换)
HEDGE_PLAN_OPTION_PRIMARY=true
HEDGE_PLAN_OPEN_ORDER=options_first
HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true
HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false
HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true
# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平
HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true
# 期期「做多/做空」拆分口径:budget=按权利金预算(默认);sheets=先算同张数总张数(2n)再按比例拆
HEDGE_PLAN_OO_BIAS_SPLIT_BY=budget
# 期期「做多/做空」主腿占比(0~1,默认 0.7=7:3);做多主腿=Call,做空主腿=Put
HEDGE_PLAN_OO_BIAS_RATIO=0.7
# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开
HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true
# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效
HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true
# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更
MAX_ACTIVE_HEDGE_PLANS=1
HEDGE_PLAN_MONITOR_POLL_SECONDS=15
# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false)
HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=false
# =============================================================================
# =============================================================================
#
@@ -313,3 +279,11 @@ WECHAT_TIMEOUT_SECONDS=10
APP_TIMEZONE=Asia/Shanghai
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
# =============================================================================
# 中控对接(crypto_okx_hub;也可在「系统设置 → env 配置 → 中控对接」查看)
# =============================================================================
# 首次部署/打开 env 配置时若为空会自动生成;复制到中控「添加实例」的 Bridge Token
HUB_BRIDGE_TOKEN=
# 选填。允许嵌入本站的中控 Origin(空格分隔);一般留空即可;iframe 被挡时再填,调试可 *
HUB_EMBED_FRAME_ANCESTORS=
+9
View File
@@ -0,0 +1,9 @@
Copyright (c) 2026 马建军. All rights reserved.
本仓库源代码及相关文档的著作权归马建军所有。
未经著作权人书面许可,不得复制、修改、分发、销售或以其他方式使用本软件源码。
Author: 马建军
QQ: 253921841
Email: 253921841@qq.com
Website: https://hyf2.cc
+5782 -7547
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()}"
)
+63
View File
@@ -0,0 +1,63 @@
/**
* 中控 iframe 嵌入鉴权:跨站时第三方 Cookie 常被拦截,改用 embed_sso 头维持登录.
* 须在其它业务脚本之前加载.
*/
(function () {
"use strict";
var KEY = "hub_embed_sso";
try {
var q = new URLSearchParams(location.search || "");
var fromUrl = (q.get("embed_sso") || "").trim();
if (fromUrl) {
sessionStorage.setItem(KEY, fromUrl);
q.delete("embed_sso");
var next = location.pathname;
var rest = q.toString();
if (rest) next += "?" + rest;
next += location.hash || "";
history.replaceState(null, "", next);
}
} catch (_) {}
function currentToken() {
try {
return (sessionStorage.getItem(KEY) || "").trim();
} catch (_) {
return "";
}
}
function withAuthHeaders(init) {
var tok = currentToken();
if (!tok) return init;
init = init ? Object.assign({}, init) : {};
var headers;
try {
headers = new Headers(init.headers || {});
} catch (_) {
headers = new Headers();
}
if (!headers.has("X-Hub-Embed-SSO")) {
headers.set("X-Hub-Embed-SSO", tok);
}
init.headers = headers;
if (!init.credentials) init.credentials = "same-origin";
return init;
}
if (typeof window.fetch === "function") {
var origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
return origFetch(input, withAuthHeaders(init));
};
}
window.HubEmbedAuth = {
token: currentToken,
clear: function () {
try {
sessionStorage.removeItem(KEY);
} catch (_) {}
},
};
})();
+133
View File
@@ -0,0 +1,133 @@
/**
* 系统设置 · 备份恢复
*/
(function (global) {
function statusEl() {
return document.getElementById("backup-status");
}
function setStatus(msg, isErr) {
const el = statusEl();
if (!el) return;
el.textContent = msg || "";
el.style.color = isErr ? "#fb7185" : "";
}
function fmtSize(n) {
const v = Number(n) || 0;
if (v < 1024) return v + " B";
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
return (v / (1024 * 1024)).toFixed(2) + " MB";
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function loadList() {
const body = document.getElementById("backup-list-body");
if (!body) return;
body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
try {
const r = await fetch("/api/settings/backup/list", { credentials: "same-origin" });
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载失败");
const dir = document.getElementById("backup-dir-label");
if (dir && j.backup_dir) dir.textContent = j.backup_dir;
const items = j.items || [];
if (!items.length) {
body.innerHTML = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
return;
}
body.innerHTML = items
.map(function (it) {
const name = escapeHtml(it.name || "");
return (
"<tr>" +
"<td><code style=\"font-size:.74rem\">" +
name +
"</code></td>" +
"<td>" +
escapeHtml(it.mtime_bj || "—") +
"</td>" +
"<td>" +
escapeHtml(fmtSize(it.size)) +
"</td>" +
"<td style=\"white-space:nowrap\">" +
'<a class="btn-secondary" style="padding:3px 8px;font-size:.74rem;text-decoration:none" href="/api/settings/backup/download/' +
encodeURIComponent(it.name) +
'">下载</a> ' +
'<button type="button" class="btn-del" style="padding:3px 8px;font-size:.74rem" data-backup-restore="' +
encodeURIComponent(it.name) +
'">恢复</button>' +
"</td>" +
"</tr>"
);
})
.join("");
} catch (e) {
body.innerHTML =
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
}
}
async function createBackup() {
setStatus("备份中…");
try {
const r = await fetch("/api/settings/backup/create", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "备份失败");
setStatus("已生成 " + (j.name || "备份"));
await loadList();
} catch (e) {
setStatus(e.message || "备份失败", true);
}
}
async function restoreBackup(name) {
if (!name) return;
if (!confirm("确定用该备份恢复?\n将覆盖 .env、数据库与复盘图片,建议恢复后重启。")) return;
setStatus("恢复中…");
try {
const r = await fetch("/api/settings/backup/restore", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "恢复失败");
setStatus(j.msg || "恢复完成");
} catch (e) {
setStatus(e.message || "恢复失败", true);
}
}
function boot() {
const root = document.querySelector("[data-backup-root]");
if (!root || root.dataset.bound === "1") return;
root.dataset.bound = "1";
const createBtn = document.getElementById("backup-create-btn");
const refreshBtn = document.getElementById("backup-refresh-btn");
if (createBtn) createBtn.addEventListener("click", createBackup);
if (refreshBtn) refreshBtn.addEventListener("click", loadList);
root.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-backup-restore]");
if (!btn) return;
const name = decodeURIComponent(btn.getAttribute("data-backup-restore") || "");
void restoreBackup(name);
});
void loadList();
}
global.InstanceBackup = { boot: boot, loadList: loadList };
})(typeof window !== "undefined" ? window : globalThis);
+5
View File
@@ -16,6 +16,7 @@
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
live_guide: "/live_guide",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
@@ -154,6 +155,9 @@
}
}
}
if (tab === "settings" && global.InstanceBackup && typeof global.InstanceBackup.boot === "function") {
global.InstanceBackup.boot();
}
}
if (!revisit) {
if (typeof global.refreshAccountSnapshot === "function") {
@@ -358,6 +362,7 @@
const hideTopBar =
tab === "settings" ||
tab === "risk_policy" ||
tab === "live_guide" ||
tab === "system_guide" ||
tab === "env_config" ||
tab === "options_review";
+1
View File
@@ -102,6 +102,7 @@
records: "/records",
stats: "/stats",
risk_policy: "/risk_policy",
live_guide: "/live_guide",
system_guide: "/system_guide",
env_config: "/env_config",
settings: "/settings",
+17 -6
View File
@@ -4,14 +4,24 @@
.order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem}
.order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center}
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 12px}
/* 1920 左右用满:约 96vw / 1840,两侧留白更小 */
.container{width:100%;max-width:min(1840px,96vw);margin:0 auto;padding:0 clamp(6px,1vw,14px)}
/* 普通屏:铺满可用宽度;带鱼屏再锁 1800(见下方 media) */
.container{width:100%;max-width:none;margin:0 auto;padding:0 clamp(6px,1vw,14px)}
body[data-embed-shell="1"] .container{
width:100%;
max-width:min(1800px, 100%);
margin-left:auto;
margin-right:auto;
padding:0 clamp(6px,1vw,14px);
}
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
.top-nav{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:10px 12px;margin-bottom:12px}
.top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;min-width:0}
.top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;align-items:center;min-width:0}
.top-nav-links > a{flex:0 0 auto;white-space:nowrap}
/* 兜底:若链接未包在 .top-nav-links 内,勿被 1fr|auto|1fr 拉成三列变形 */
.top-nav > a{justify-self:center;align-self:center;width:max-content;max-width:100%}
.top-nav-spot-tickers{grid-column:3;justify-self:end;display:flex;flex-direction:column;gap:3px;min-width:0}
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
@@ -165,11 +175,12 @@
.panel-scroll,.pos-list{max-height:420px}
.records-card .table-wrap{max-height:620px;overflow:auto}
}
/* 带鱼屏(宽高比≥21:9):整页固定 1920 居中;不依赖视口宽度(避免 Win 缩放后失效) */
/* 带鱼屏:独立打开与中控内嵌内容均锁 1800 居中留白;工作台外壳由中控全屏 */
@media (min-aspect-ratio: 21/9) {
.container{
.container,
body[data-embed-shell="1"] .container{
width:100%;
max-width:min(1920px, calc(100vw - 24px));
max-width:min(1800px, calc(100vw - 24px));
margin-left:auto;
margin-right:auto;
}
+77 -29
View File
@@ -46,9 +46,8 @@
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
live_guide: "show_nav_live_guide",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
@@ -59,6 +58,11 @@
.forEach((a) => {
const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0];
if (tab === "more") return;
if (tab === "hedge_plan" || tab === "hedge-plan") {
a.classList.add("nav-hidden");
a.style.display = "none";
return;
}
const key = map[tab];
if (!key) return;
const show = navPrefShow(display, key);
@@ -70,6 +74,7 @@
function pageNavAllowed(tab) {
const d = DISPLAY();
if (tab === "hedge_plan" || tab === "hedge-plan") return false;
const map = {
dashboard: "show_nav_dashboard",
account_ledger: "show_nav_account_ledger",
@@ -82,9 +87,8 @@
options: "show_nav_options",
"options-review": "show_nav_options_review",
options_review: "show_nav_options_review",
"hedge-plan": "show_nav_hedge_plan",
hedge_plan: "show_nav_hedge_plan",
risk_policy: "show_nav_risk_policy",
live_guide: "show_nav_live_guide",
system_guide: "show_nav_system_guide",
env_config: "show_nav_env_config",
};
@@ -295,7 +299,21 @@
}
input.dataset.envKey = field.key;
input.className = "env-field-input";
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) {
row.hidden = true;
row.style.display = "none";
@@ -397,41 +415,25 @@
function syncOptionsSectionVisibility(body) {
if (!body) return;
var margin = (envFieldValue(body, "OKX_OPTIONS_MARGIN_MODE", "coin") || "coin").toLowerCase();
var isCoin = margin !== "usdc";
// 仅币本位期权:USDC/永续相关 env 始终隐藏
var isCoin = true;
var gateMode = (envFieldValue(body, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") || "premium").toLowerCase();
var compoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"));
var coinCompoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_COMPOUND", "true"));
var coinMaxOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"));
var compoundCapOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"));
COIN_ONLY_ENV_KEYS.forEach(function (key) {
setEnvRowHidden(envFieldRowByKey(body, key), !isCoin);
});
USDC_ONLY_ENV_KEYS.forEach(function (key) {
setEnvRowHidden(envFieldRowByKey(body, key), isCoin);
setEnvRowHidden(envFieldRowByKey(body, key), true);
});
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_MARGIN_MODE"), true);
setEnvRowHidden(envFieldRowByKey(body, "OKX_SHOW_PERP_FUNDS"), true);
// 门控子项:按模式显隐(仍受本位过滤)
if (isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium");
} else {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT"), gateMode !== "premium");
}
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium");
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT"), gateMode !== "net_pnl");
if (!isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"), compoundOn);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED"), !compoundOn);
setEnvRowHidden(
envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC"),
!(compoundOn && compoundCapOn)
);
}
if (isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
}
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
}
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() {
if (document.documentElement.dataset.prefsDelegateBound === "1") return;
document.documentElement.dataset.prefsDelegateBound = "1";
document.addEventListener("click", (ev) => {
const target = ev.target;
if (!(target instanceof Element)) return;
const copyBtn = target.closest("[data-env-copy]");
if (copyBtn) {
ev.preventDefault();
void copyEnvFieldValue(copyBtn.getAttribute("data-env-copy") || "", copyBtn);
return;
}
if (target.closest("#display-prefs-save")) {
ev.preventDefault();
void saveDisplayPrefs();
+158
View File
@@ -626,6 +626,39 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
margin-left: auto;
}
.instance-logout-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 12px;
font-size: 0.82rem;
line-height: 1.2;
color: var(--inst-muted, #9aa3b2);
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.instance-logout-btn:hover {
color: var(--inst-text, #e8ecf4);
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.04);
}
html[data-theme="light"] .instance-logout-btn {
color: #5b6472;
border-color: rgba(0, 0, 0, 0.12);
}
html[data-theme="light"] .instance-logout-btn:hover {
color: #1f2937;
border-color: rgba(0, 0, 0, 0.22);
background: rgba(0, 0, 0, 0.03);
}
.instance-header-toolbar-filter label {
display: inline-flex;
align-items: center;
@@ -661,9 +694,15 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
gap: 8px;
flex-wrap: wrap;
justify-content: center;
align-items: center;
min-width: 0;
}
.top-nav-links > a {
flex: 0 0 auto;
white-space: nowrap;
}
.top-nav-spot-tickers {
display: flex;
flex-direction: column;
@@ -2684,6 +2723,24 @@ html[data-theme="light"] .journal-detail-img-thumb {
box-sizing: border-box;
}
.env-field-input-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.env-field-input-wrap .env-field-input {
flex: 1 1 auto;
min-width: 0;
}
.env-field-input-wrap--copy .env-copy-btn {
flex: 0 0 auto;
margin-top: 0;
white-space: nowrap;
}
.env-config-loading-wrap {
padding: 24px;
text-align: center;
@@ -3315,6 +3372,15 @@ html[data-theme="light"] .settings-side-export-label {
html[data-theme="light"] .opt-chain-lev {
color: #1a4a8a;
}
.opt-chain-delta {
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 600;
color: #9ad5ff;
}
html[data-theme="light"] .opt-chain-delta {
color: #0b5cad;
}
.opt-be-dist-up {
color: #5ee89a;
}
@@ -3344,6 +3410,21 @@ html[data-theme="light"] .opt-be-dist-down {
background: rgba(74, 124, 255, 0.45);
box-shadow: inset 0 0 0 1px rgba(140, 175, 255, 0.6);
}
.options-page-wrap .opt-pick-btn:disabled,
.options-page-wrap .opt-pick-btn.is-locked {
opacity: 0.42;
cursor: not-allowed;
color: #8b95a8;
background: rgba(20, 28, 44, 0.55);
border-color: rgba(80, 90, 110, 0.5);
box-shadow: none;
}
html[data-theme="light"] .options-page-wrap .opt-pick-btn:disabled,
html[data-theme="light"] .options-page-wrap .opt-pick-btn.is-locked {
color: #7a8799;
background: rgba(148, 163, 184, 0.28);
border-color: rgba(148, 163, 184, 0.55);
}
.opt-strike-row.opt-row-selected td {
background: rgba(74, 124, 255, 0.1);
}
@@ -5483,12 +5564,89 @@ html[data-theme="light"] .opt-source-badge--oo {
flex-wrap: wrap;
justify-content: flex-end;
}
.opt-cond-order-card {
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(14, 42, 68, 0.72);
}
.opt-cond-order-card[hidden] {
display: none !important;
}
.opt-cond-order-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.opt-cond-order-head strong {
font-size: 0.9rem;
}
.opt-cond-lock-note {
margin-left: auto;
font-size: 0.72rem;
color: #9ec0ff;
}
.opt-cond-st {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
}
.opt-cond-st.is-armed {
color: #7dd3fc;
background: rgba(56, 189, 248, 0.16);
}
.opt-cond-st.is-trig {
color: #fbbf24;
background: rgba(251, 191, 36, 0.16);
}
.opt-cond-st.is-open {
color: #6ee7b7;
background: rgba(52, 211, 153, 0.16);
}
.opt-cond-order-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px 12px;
}
.opt-cond-kv .k {
display: block;
font-size: 0.68rem;
color: #8ea0c0;
}
.opt-cond-kv .v {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.opt-cond-order-msg {
margin-top: 8px;
font-size: 0.72rem;
color: #c9d6f5;
}
html[data-theme="light"] .opt-cond-order-card {
border-color: rgba(14, 116, 144, 0.28);
background: rgba(224, 242, 254, 0.7);
}
html[data-theme="light"] .opt-cond-lock-note,
html[data-theme="light"] .opt-cond-kv .k,
html[data-theme="light"] .opt-cond-order-msg {
color: #35506e;
}
.opt-spot-sell-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* display:flex 会盖掉 UA [hidden] */
.opt-spot-sell-head[hidden] {
display: none !important;
}
.opt-spot-sell-avail {
font-size: 0.78rem;
color: #c9d6f5;
+2
View File
@@ -171,6 +171,8 @@
function patchHubNavLinks(theme) {
if (!isHubLinked()) return;
// embed 壳由 InstanceEmbed 软切 tab,勿改写为 /xxx?embed=1(会绕开壳落到 index 顶栏变形)
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
const t = normalize(theme || get());
document
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
+342
View File
@@ -0,0 +1,342 @@
/**
* 实例数据统计:复盘 KPI + 已实现盈亏曲线.
*/
(function (global) {
"use strict";
var currentRange = "7d";
var booted = false;
function $(id) {
return document.getElementById(id);
}
function root() {
return $("options-data-stats");
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function fmtHold(sec) {
if (sec == null) return "—";
var s = Math.max(0, Number(sec) || 0);
if (s < 3600) return Math.round(s / 60) + "m";
if (s < 86400) return (s / 3600).toFixed(1) + "h";
return (s / 86400).toFixed(1) + "d";
}
function pnlClass(v) {
var n = Number(v);
if (Number.isNaN(n) || n === 0) return "";
return n > 0 ? "ods-pnl-pos" : "ods-pnl-neg";
}
function closeReasonLabel(r) {
var map = {
perp_tp: "永续止盈",
perp_sl: "永续止损",
oo_expiry_loss: "期期到期亏损",
oo_expiry_win: "期期到期盈利",
target_win_leg: "期期平盈利腿",
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
};
return map[r] || String(r || "未填");
}
function sourceType() {
var el = root();
return (el && el.getAttribute("data-source-type")) || "option_spot";
}
function setStatus(text) {
var el = $("ods-status");
if (el) el.textContent = text || "";
}
function renderGroup(title, items) {
if (!items || !items.length) return "";
var lines = items
.slice(0, 8)
.map(function (g) {
var keyLabel =
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
return (
'<div class="ods-stat-row">' +
'<span class="ods-stat-key">' +
escapeHtml(keyLabel) +
" · " +
g.count +
"笔</span>" +
'<span class="ods-stat-val">' +
fmtPnl(g.pnl_sum) +
" / 胜" +
(g.win_rate || 0) +
"%</span>" +
"</div>"
);
})
.join("");
return (
'<div class="ods-stat-card"><div class="ods-stat-card-title">' +
title +
"</div>" +
lines +
"</div>"
);
}
function loadReviewStats() {
var kpi = $("ods-kpi");
var groups = $("ods-stats-groups");
if (!kpi || !groups) return Promise.resolve();
// 口径 B:期权历史币盈亏×卖回价(/api/options/stats),与复盘账户差分独立
return fetch("/api/options/stats", { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.ok) {
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
groups.innerHTML = "";
return;
}
var unit = data.pnl_unit || "U";
var net = data.net_realized_pnl;
var avg =
data.total_closed > 0 && net != null
? Number(net) / Number(data.total_closed)
: null;
kpi.innerHTML = [
["已平笔数", data.total_closed || 0],
["胜率", (data.win_rate || 0) + "%"],
["盈亏比", data.profit_loss_ratio != null ? data.profit_loss_ratio : "—"],
["累计盈亏", fmtPnl(net)],
["平均盈利", data.avg_win != null ? fmtPnl(data.avg_win) : "—"],
["平均亏损", data.avg_loss != null ? fmtPnl(-Math.abs(Number(data.avg_loss))) : "—"],
["平均持有", fmtHold(data.avg_hold_sec)],
]
.map(function (pair) {
var cls = "";
if (pair[0] === "累计盈亏") cls = pnlClass(net);
if (pair[0] === "平均盈利") cls = "pnl-pos";
if (pair[0] === "平均亏损") cls = "pnl-neg";
return (
'<div class="ods-kpi-tile"><div class="ods-kpi-label">' +
pair[0] +
'</div><div class="ods-kpi-value' +
(cls ? " " + cls : "") +
'">' +
pair[1] +
"</div></div>"
);
})
.join("");
groups.innerHTML =
'<div class="muted" style="font-size:.76rem">数据统计口径:期权历史盈亏×平仓卖回价(与复盘账户差分独立)。单位 ' +
unit +
(avg != null ? " · 笔均 " + fmtPnl(avg) : "") +
"</div>";
})
.catch(function () {
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
groups.innerHTML = "";
});
}
function fmtCash(v) {
if (v == null || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(2) + "U";
}
function drawCurve(points) {
var svg = $("ods-curve-svg");
if (!svg) return;
var W = 640;
var H = 240;
var padL = 48;
var padR = 12;
var padT = 16;
var padB = 28;
var stroke = getComputedStyle(document.body).getPropertyValue("--accent") || "#5aa0ff";
stroke = (stroke || "").trim() || "#5aa0ff";
var grid = "rgba(127,127,127,.22)";
var muted = "rgba(160,160,180,.75)";
if (!points || !points.length) {
svg.innerHTML =
'<text x="320" y="120" text-anchor="middle" fill="' +
muted +
'" font-size="13">暂无已实现盈亏数据</text>';
return;
}
var vals = points.map(function (p) {
return Number(p.v);
});
var ts = points.map(function (p) {
return Number(p.t);
});
var minV = Math.min.apply(null, vals);
var maxV = Math.max.apply(null, vals);
if (maxV - minV < 1e-6) {
minV -= 1;
maxV += 1;
}
var minT = ts[0];
var maxT = ts[ts.length - 1];
if (maxT <= minT) maxT = minT + 1;
function xOf(t) {
return padL + ((t - minT) / (maxT - minT)) * (W - padL - padR);
}
function yOf(v) {
return padT + (1 - (v - minV) / (maxV - minV)) * (H - padT - padB);
}
var path = points
.map(function (p, i) {
var x = xOf(p.t);
var y = yOf(p.v);
return (i ? "L" : "M") + x.toFixed(1) + "," + y.toFixed(1);
})
.join(" ");
var yTicks = [minV, (minV + maxV) / 2, maxV];
var gridHtml = yTicks
.map(function (v) {
var y = yOf(v);
return (
'<line x1="' +
padL +
'" y1="' +
y.toFixed(1) +
'" x2="' +
(W - padR) +
'" y2="' +
y.toFixed(1) +
'" stroke="' +
grid +
'" stroke-width="1"/>' +
'<text x="' +
(padL - 6) +
'" y="' +
(y + 4).toFixed(1) +
'" text-anchor="end" fill="' +
muted +
'" font-size="10">' +
Number(v).toFixed(0) +
"</text>"
);
})
.join("");
var last = points[points.length - 1];
svg.innerHTML =
gridHtml +
'<path d="' +
path +
'" fill="none" stroke="' +
stroke +
'" stroke-width="2" vector-effect="non-scaling-stroke"/>' +
'<circle cx="' +
xOf(last.t).toFixed(1) +
'" cy="' +
yOf(last.v).toFixed(1) +
'" r="3.5" fill="' +
stroke +
'"/>';
}
function loadCashCurve() {
var meta = $("ods-curve-meta");
return fetch("/api/instance/cash_curve?range=" + encodeURIComponent(currentRange), {
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data || !data.ok) {
if (meta) meta.textContent = "曲线加载失败";
drawCurve([]);
return;
}
var pts = data.points || [];
drawCurve(pts);
if (meta) {
meta.textContent =
"已实现 " +
(data.trade_count != null ? data.trade_count + " 笔 · " : "") +
pts.length +
" 点 · 累计 " +
fmtCash(data.latest != null ? data.latest : pts.length ? pts[pts.length - 1].v : null);
}
})
.catch(function () {
if (meta) meta.textContent = "曲线加载失败";
drawCurve([]);
});
}
function reloadAll() {
setStatus("加载中…");
return Promise.all([loadReviewStats(), loadCashCurve()]).then(function () {
setStatus("");
});
}
function bindOnce() {
if (booted || !root()) return;
booted = true;
var btn = $("ods-reload-btn");
if (btn) {
btn.addEventListener("click", function () {
reloadAll();
});
}
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (b) {
b.addEventListener("click", function () {
currentRange = b.getAttribute("data-range") || "7d";
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
x.classList.toggle("active", x === b);
});
loadCashCurve();
});
});
// 默认 7 日更有曲线感;若用户点今日再切
var def7 = document.querySelector('#options-data-stats .ods-range-btn[data-range="7d"]');
if (def7) {
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
x.classList.toggle("active", x === def7);
});
currentRange = "7d";
}
reloadAll();
}
function bootIfPresent() {
if (root()) bindOnce();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootIfPresent);
} else {
bootIfPresent();
}
// embed 软切 tab 时脚本可能再次插入
global.__OPTIONS_DATA_STATS_BOOT__ = bootIfPresent;
})(typeof window !== "undefined" ? window : this);
+258 -26
View File
@@ -8,8 +8,30 @@
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {});
const allowedUnderlys = (function () {
const raw = String(root.dataset.allowedUnderlys || "ETH,BTC");
const list = raw
.split(",")
.map(function (s) {
return String(s || "")
.trim()
.toUpperCase();
})
.filter(function (u) {
return u === "ETH" || u === "BTC";
});
return list.length ? list : ["ETH", "BTC"];
})();
const bootUnderly = (function () {
const d = String(root.dataset.defaultUnderly || "ETH")
.trim()
.toUpperCase();
if (allowedUnderlys.indexOf(d) >= 0) return d;
return allowedUnderlys[0] || "ETH";
})();
const state = {
underlying: root.dataset.defaultUnderly || "ETH",
underlying: bootUnderly,
optType: "C",
moneyFilter: "all",
chainView: "list",
@@ -19,7 +41,7 @@
budgetBuffer: (function () {
const raw = root.dataset.budgetBuffer;
const n = raw != null && raw !== "" ? Number(raw) : NaN;
return !Number.isNaN(n) && n > 0 ? n : 0.95;
return !Number.isNaN(n) && n > 0 ? n : 1;
})(),
chain: panelCache.chain || null,
selectedInst: null,
@@ -30,6 +52,7 @@
targetDraftByInst: {},
/** 翻倍倍数草稿,避免轮询重绘把正在输入的值刷回 1 */
profitExitDraftByInst: {},
condOrderLocked: false,
};
let lastGoodPositions = null;
@@ -78,7 +101,21 @@
async function apiJson(url, opts) {
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
return r.json();
const text = await r.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
if (/\/login/i.test(r.url || "") || /^\s*<!doctype/i.test(text) || /^\s*<html/i.test(text)) {
throw new Error("未登录或会话过期,请从中控重新打开工作台");
}
const snip = String(text || "").replace(/\s+/g, " ").slice(0, 100);
throw new Error("接口返回非 JSON(HTTP " + r.status + ")" + (snip ? ": " + snip : ""));
}
if (r.status === 401) {
throw new Error((data && data.msg) || "未登录或会话过期,请从中控重新打开工作台");
}
return data;
}
function orderPanel() {
@@ -89,14 +126,35 @@
return document.getElementById("opt-order-panel-host");
}
function applyCondPickLock() {
const locked = !!state.condOrderLocked;
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
btn.disabled = locked;
btn.classList.toggle("is-locked", locked);
if (locked) {
btn.classList.remove("active");
btn.title = "中控条件单占用中,禁止手动开仓";
if (!btn.dataset.origText) btn.dataset.origText = "选择";
btn.textContent = btn.dataset.origText;
} else {
btn.removeAttribute("title");
}
});
const openBtn = document.getElementById("opt-open-btn");
if (openBtn && locked) {
openBtn.disabled = true;
openBtn.title = "中控条件单占用中,禁止手动开仓";
}
}
function syncPickButtons(instId) {
document.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
const on = !!instId && btn.getAttribute("data-inst") === instId;
const on = !state.condOrderLocked && !!instId && btn.getAttribute("data-inst") === instId;
btn.classList.toggle("active", on);
btn.disabled = false;
if (!btn.dataset.origText) btn.dataset.origText = "选择";
btn.textContent = on ? "已选" : btn.dataset.origText;
});
applyCondPickLock();
}
function parkOrderPanel() {
@@ -282,9 +340,10 @@
const el = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
if (el) return el.value;
const any = document.querySelector('input[name="opt-size-mode"]:checked');
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "sheets";
if (any && any.value === "compound_full" && !compoundFullEnabled()) return "budget_full";
if (any && any.value === "budget_full" && compoundFullEnabled()) return "compound_full";
return "sheets";
// 默认按可用余额打满(复利开时等价全仓复利)
return compoundFullEnabled() ? "compound_full" : "budget_full";
}
function applyCompoundModeUi(compoundOn) {
@@ -336,13 +395,13 @@
compoundRadio.checked = false;
compoundRadio.disabled = true;
}
// currentSizeMode 会把残留 compound 映射成 sheets,须实际勾选,避免无选中无法开仓
// 无有效选中时默认「按可用余额打满」
const checkedOk = document.querySelector('input[name="opt-size-mode"]:checked:not(:disabled)');
if (!checkedOk) {
const sheetsRadio = document.querySelector('input[name="opt-size-mode"][value="sheets"]');
if (sheetsRadio) {
sheetsRadio.disabled = false;
sheetsRadio.checked = true;
const fullRadio = document.querySelector('input[name="opt-size-mode"][value="budget_full"]');
if (fullRadio) {
fullRadio.disabled = false;
fullRadio.checked = true;
}
}
}
@@ -418,7 +477,15 @@
}
function strikeTableColspan() {
return 9;
return state.chainView === "t" ? 9 : 10;
}
function fmtDelta(v) {
if (v === null || v === undefined || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const sign = n > 0 ? "+" : "";
return sign + n.toFixed(3);
}
function syncChainViewUI() {
@@ -637,7 +704,16 @@
function pickBtnHtml(instId) {
if (!instId) return "—";
return '<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + instId + '">选择</button>';
const locked = !!state.condOrderLocked;
return (
'<button type="button" class="btn-secondary opt-pick-btn' +
(locked ? " is-locked" : "") +
'" data-inst="' +
instId +
'"' +
(locked ? ' disabled title="中控条件单占用中,禁止手动开仓"' : "") +
">选择</button>"
);
}
function syncMoneyFilterButtons() {
@@ -1031,6 +1107,25 @@
return "约 " + Number(v).toFixed(1) + "×";
}
function calcPosLeverage(p) {
const direct = Number(p && (p.contract_leverage != null ? p.contract_leverage : p.leverage));
if (Number.isFinite(direct) && direct > 0) return direct;
const idx = Number(p && p.idx_px);
const amt = Number(p && p.eth_amount);
const prem = Number(p && p.premium_paid);
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
return null;
}
if (isCoinPos(p)) {
return Math.round((amt / prem) * 10) / 10;
}
return Math.round((idx * amt) / prem * 10) / 10;
}
function fmtPosLeverage(p) {
return fmtLeverage(calcPosLeverage(p));
}
/** 链上展示:USDC=指数÷卖一(美元);币本位卖一为币报价 → 1÷卖一. */
function calcAskLeverage(indexPx, askPx) {
if (indexPx == null || askPx == null) return null;
@@ -1153,6 +1248,11 @@
function finishStrikeRender(tbody, prevSelected, matchedSelected) {
bindStrikePickButtons(tbody);
applyCondPickLock();
if (state.condOrderLocked) {
state.selectedInst = null;
return;
}
if (matchedSelected && prevSelected) {
selectContract(prevSelected, null, true);
} else if (!matchedSelected) {
@@ -1216,6 +1316,7 @@
"<td><code>" + c.inst_id + "</code></td>" +
"<td class=\"opt-px-sz\">" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "</td>" +
'<td class="opt-chain-lev">' + fmtChainLeverage(chainLev) + "</td>" +
'<td class="opt-chain-delta" title="Delta">' + fmtDelta(c.delta) + "</td>" +
"<td class=\"opt-px-sz\">" + fmtPxSz(c.bid, c.bid_sz) + "</td>" +
"<td>" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "</td>" +
'<td class="' + distBeClass(c.dist_expiry_be) + '">' + fmtDist(c.dist_expiry_be) + "</td>" +
@@ -1393,6 +1494,10 @@
}
async function selectContract(instId, pickBtn, silent) {
if (state.condOrderLocked) {
applyCondPickLock();
return null;
}
const seq = ++selectSeq;
state.selectedInst = instId;
placeOrderPanelAfter(instId);
@@ -1637,6 +1742,10 @@
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
'<span class="pos-meta-item" title="名义价值÷权利金,测算用">杠杆倍数: ' + fmtPosLeverage(p) + "</span>" +
'<span class="pos-meta-item" title="OKX deltaBS(单位 Delta)">Delta: ' +
fmtDelta(p.delta != null ? p.delta : p.delta_bs) +
"</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
: "") +
@@ -1645,6 +1754,9 @@
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">Delta</span><span class="pos-value" title="OKX deltaBS">' +
fmtDelta(p.delta != null ? p.delta : p.delta_bs) +
"</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
@@ -2243,26 +2355,53 @@
return n.toFixed(8).replace(/\.?0+$/, "");
}
function spotSellMinSz(ss, uly) {
const u = String(uly || "ETH").toUpperCase();
const fromApi = u === "BTC" ? Number(ss && ss.min_sz_btc) : Number(ss && ss.min_sz_eth);
if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
return u === "BTC" ? 0.00001 : 0.0001;
}
function shouldShowSpotSellBanner(d, positions) {
if (!isCoinMarginMode()) return false;
const ss = d && d.spot_sell;
if (!ss) return false;
const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); });
if (hasCoinPos) return false;
if (ss.bridge_status === "pending_sell_spot") return true;
// 仅当交易户有「可卖」(>= 现货 minSz)残留时展示;粉尘不展示、不提示重试
const ss = (d && d.spot_sell) || {};
const eth = Number(ss.trading_eth || 0);
const btc = Number(ss.trading_btc || 0);
return eth > 1e-12 || btc > 1e-12;
const ethOk = eth >= spotSellMinSz(ss, "ETH");
const btcOk = btc >= spotSellMinSz(ss, "BTC");
if (ethOk || btcOk) return true;
const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); });
const coinMode =
isCoinMarginMode() ||
hasCoinPos ||
(d && d.options_margin_mode === "coin");
// 待卖回桥且仍有可卖数量时才露按钮(粉尘桥后端会关掉)
if (coinMode && ss.bridge_status === "pending_sell_spot") {
const bu = String(ss.bridge_underlying || "").toUpperCase();
const amt = bu === "BTC" ? btc : eth;
return amt >= spotSellMinSz(ss, bu || "ETH");
}
return false;
}
function spotSellUnderlying(ss) {
if (!ss) return (state.underlying || "ETH").toUpperCase();
if (ss.bridge_underlying) return String(ss.bridge_underlying).toUpperCase();
const selected = (state.underlying || "ETH").toUpperCase();
if (!ss) return selected;
const bridgeUly = String(ss.bridge_underlying || "").trim().toUpperCase();
const eth = Number(ss.trading_eth || 0);
const btc = Number(ss.trading_btc || 0);
if (btc > eth && btc > 1e-12) return "BTC";
if (eth > 1e-12) return "ETH";
return (ss.default_underly || state.underlying || "ETH").toUpperCase();
const ethMin = spotSellMinSz(ss, "ETH");
const btcMin = spotSellMinSz(ss, "BTC");
// 待卖回桥优先;否则优先当前选中标的上的可卖残留
if (bridgeUly === "BTC" || bridgeUly === "ETH") {
if (ss.bridge_status === "pending_sell_spot") return bridgeUly;
}
if (selected === "BTC" && btc >= btcMin) return "BTC";
if (selected === "ETH" && eth >= ethMin) return "ETH";
if (btc >= btcMin && btc >= eth) return "BTC";
if (eth >= ethMin) return "ETH";
if (bridgeUly === "BTC" || bridgeUly === "ETH") return bridgeUly;
return (ss.default_underly || selected || "ETH").toUpperCase();
}
function paintSpotSellBanner(d, positions) {
@@ -2287,6 +2426,90 @@
head.hidden = false;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function condStatusLabel(st) {
const map = { armed: "待命", triggering: "触发中", open: "持仓中" };
return map[st] || st || "已接收";
}
function condStatusClass(st) {
if (st === "open") return "is-open";
if (st === "triggering") return "is-trig";
return "is-armed";
}
function paintCondOrderLock(d) {
const card = document.getElementById("opt-cond-order-card");
if (!card) return;
const order = d && d.cond_order;
const locked = !!(d && d.cond_order_locked);
const wasLocked = !!state.condOrderLocked;
state.condOrderLocked = locked || !!order;
if (state.condOrderLocked && !wasLocked) closeOrderDialog();
applyCondPickLock();
if (!order && !locked) {
card.hidden = true;
return;
}
const st = order ? String(order.status || "") : "";
const stEl = document.getElementById("opt-cond-order-st");
if (stEl) {
stEl.textContent = condStatusLabel(st);
stEl.className = "opt-cond-st " + condStatusClass(st);
}
const grid = document.getElementById("opt-cond-order-grid");
if (grid) {
if (!order) {
grid.innerHTML = "";
} else {
const put = String(order.opt_type || "").toUpperCase() !== "C";
const side = put ? "上穿 ≥" : "下穿 ≤";
const exitParts = [];
if (order.profit_exit_enabled) {
exitParts.push("翻倍×" + (order.profit_exit_mult != null ? order.profit_exit_mult : ""));
}
if (order.target_index != null && order.target_index !== "") {
exitParts.push("目标 " + order.target_index);
}
const items = [
["标的", (order.underlying || "") + " " + (put ? "Put" : "Call")],
["触发", side + " " + (order.trigger_px != null ? order.trigger_px : "—")],
["偏离", order.deviation_pct != null ? order.deviation_pct + "%" : "—"],
["最短到期", order.min_expiry_hours != null ? order.min_expiry_hours + "h" : "—"],
["出场", exitParts.join(" · ") || "—"],
["当前指数", order.last_index != null && order.last_index !== "" ? String(order.last_index) : "—"],
];
if (order.opened_inst_id) {
items.push(["已开合约", String(order.opened_inst_id)]);
}
grid.innerHTML = items
.map(function (it) {
return (
'<div class="opt-cond-kv"><span class="k">' +
escHtml(it[0]) +
'</span><span class="v">' +
escHtml(it[1]) +
"</span></div>"
);
})
.join("");
}
}
const msgEl = document.getElementById("opt-cond-order-msg");
if (msgEl) {
const msg = (order && order.message) || (d && d.cond_order_msg) || "";
msgEl.textContent = msg ? String(msg) : "";
}
card.hidden = false;
}
async function retrySpotSell() {
const btn = document.getElementById("opt-spot-sell-retry-btn");
const uly = (state.spotSellUnderlying || state.underlying || "ETH").toUpperCase();
@@ -2412,6 +2635,13 @@
const list = resolvePositionsList(d);
paintPositions(list);
paintSpotSellBanner(d, list);
paintCondOrderLock(d);
if (typeof window.OptionsFundsSetTransferBlocked === "function") {
window.OptionsFundsSetTransferBlocked(
!!(d && d.transfer_blocked),
(d && d.transfer_block_msg) || "持仓或待卖回期间不可划转"
);
}
const fromPos = list.reduce(function (targets, p) {
if (!p) return targets;
if (p.target_index != null) {
@@ -2757,6 +2987,8 @@
btn.classList.add("active");
state.underlying = btn.getAttribute("data-uly");
loadChain();
// 切换 ETH/BTC 时重刷持仓区,以便「重试卖回」标的与当前选择同步
refreshAllPositions();
});
});
@@ -88,6 +88,38 @@
return "";
}
function isCoinPos(p) {
const mode = String((p && p.margin_mode) || "").toLowerCase();
if (mode === "coin") return true;
if (mode === "usdc") return false;
const ccy = String((p && p.premium_ccy) || "").toUpperCase();
if (ccy === "USDC") return false;
if (ccy === "BTC" || ccy === "ETH") return true;
const inst = String((p && p.inst_id) || "");
return inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0;
}
function calcPosLeverage(p) {
const direct = Number(p && (p.contract_leverage != null ? p.contract_leverage : p.leverage));
if (Number.isFinite(direct) && direct > 0) return direct;
const idx = Number(p && p.idx_px);
const amt = Number(p && p.eth_amount);
const prem = Number(p && p.premium_paid);
if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) {
return null;
}
if (isCoinPos(p)) {
return Math.round((amt / prem) * 10) / 10;
}
return Math.round((idx * amt) / prem * 10) / 10;
}
function fmtPosLeverage(p) {
const lev = calcPosLeverage(p);
if (lev == null || Number.isNaN(Number(lev))) return "—";
return "约 " + Number(lev).toFixed(1) + "×";
}
function fmtPxSz(px, sz, tickSz) {
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
let price = fmtOptionPx(px, tickSz);
@@ -224,6 +256,7 @@
'<span class="pos-meta-item">持仓来源: ' + sourceText(p) + "</span>" +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
'<span class="pos-meta-item" title="名义价值÷权利金,测算用">杠杆倍数: ' + fmtPosLeverage(p) + "</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: ' + expiryCdHtml(expAttr) + "</span>"
: "") +
File diff suppressed because it is too large Load Diff
+28
View File
@@ -353,4 +353,32 @@
intMsg: "opt-page-int-msg",
});
}
/** 持仓/待卖回时划转按钮置灰(规格). */
window.OptionsFundsSetTransferBlocked = function (blocked, msg) {
const idsList = [
["opt-page-int-btn", "opt-page-int-all-btn", "opt-page-int-amount", "opt-page-int-msg"],
["opt-set-int-btn", "opt-set-int-all-btn", "opt-set-int-amount", "opt-set-int-msg"],
];
idsList.forEach(function (ids) {
const btn = document.getElementById(ids[0]);
const allBtn = document.getElementById(ids[1]);
const amount = document.getElementById(ids[2]);
const msgEl = document.getElementById(ids[3]);
[btn, allBtn].forEach(function (el) {
if (!el) return;
el.disabled = !!blocked;
el.style.opacity = blocked ? "0.45" : "";
el.title = blocked ? String(msg || "持仓中不可划转") : "";
});
if (amount) amount.disabled = !!blocked;
if (blocked && msgEl && msg) {
msgEl.textContent = String(msg);
msgEl.classList.add("text-warn");
} else if (!blocked && msgEl && /持仓|划转|卖回/.test(String(msgEl.textContent || ""))) {
msgEl.textContent = "";
msgEl.classList.remove("text-warn");
}
});
};
})();
+66 -7
View File
@@ -1,5 +1,6 @@
/**
* 表单币种输入:防抖 + 定时刷新,展示交易所最新价(/api/order_defaults).
* BTC/ETH 在 order_defaults 无价时回退 /api/nav_spot_tickers(与顶栏同源).
*/
(function (global) {
"use strict";
@@ -65,6 +66,41 @@
el.title = "交易所最新价(约 " + pollMs() / 1000 + "s 刷新)";
}
function symbolBase(sym) {
return String(sym || "")
.trim()
.toUpperCase()
.split("/")[0]
.split(":")[0];
}
function fetchNavLast(sym) {
const base = symbolBase(sym);
if (base !== "BTC" && base !== "ETH") {
return Promise.resolve(null);
}
return fetch("/api/nav_spot_tickers")
.then(function (r) {
return r.json().catch(function () {
return null;
});
})
.then(function (d) {
if (!d || !d.ok || !Array.isArray(d.tickers)) return null;
for (let i = 0; i < d.tickers.length; i++) {
const t = d.tickers[i] || {};
if (String(t.base || "").toUpperCase() === base && t.last != null) {
const n = Number(t.last);
return Number.isFinite(n) && n > 0 ? n : null;
}
}
return null;
})
.catch(function () {
return null;
});
}
function bindOne(el) {
if (!el || bound.has(el)) return;
bound.add(el);
@@ -87,6 +123,15 @@
pollTimer = setInterval(refresh, pollMs());
}
function applyPrice(sym, px) {
if (px === null || typeof px === "undefined") {
paint(el, sym, null, "无法读取交易所价格");
return;
}
paint(el, sym, px, "");
if (!pollTimer) startPoll();
}
function refresh() {
const symEl = $(symId);
const sym = symbolValue(symEl);
@@ -116,21 +161,35 @@
el.classList.remove("symbol-live-price--loading");
const data = res.data || {};
if (res.status >= 400 || !data || !data.ok) {
paint(el, sym, null, (data && data.msg) || "读取失败");
return;
return fetchNavLast(sym).then(function (navPx) {
if (seq !== fetchSeq) return;
if (navPx != null) {
applyPrice(sym, navPx);
return;
}
paint(el, sym, null, (data && data.msg) || "读取失败");
});
}
const px = data.last_price != null ? data.last_price : data.price;
if (px === null || typeof px === "undefined") {
paint(el, data.symbol || sym, null, "无法读取交易所价格");
return;
return fetchNavLast(sym).then(function (navPx) {
if (seq !== fetchSeq) return;
applyPrice(data.symbol || sym, navPx);
});
}
paint(el, data.symbol || sym, px, "");
if (!pollTimer) startPoll();
applyPrice(data.symbol || sym, px);
})
.catch(function () {
if (seq !== fetchSeq) return;
el.classList.remove("symbol-live-price--loading");
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_SESSION_DAYS",
"WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_SHOW_PERP_FUNDS",
"HUB_BRIDGE_TOKEN",
"HUB_EMBED_FRAME_ANCESTORS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER",
"OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT",
@@ -107,33 +102,15 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_OPTIONS_CLOSE_GATE_MODE",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPTION_PRIMARY",
"HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"HEDGE_PLAN_OO_BIAS_RATIO",
"HEDGE_PLAN_BUDGET_BUFFER",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
})
SENSITIVE_EXACT = frozenset({
"APP_PASSWORD",
"FLASK_SECRET_KEY",
"HUB_BRIDGE_TOKEN",
"OPENAI_API_KEY",
})
@@ -164,26 +141,13 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("spot", "spot 现货"),
),
"TRANSFER_CCY": (("USDT", "USDT"),),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
"OKX_TRADE_MODE": (
("options", "单独期权"),
("options_options", "期期对冲"),
),
"OKX_OPTIONS_MARGIN_MODE": (
("coin", "币本位(USDT买币桥)"),
("usdc", "USDC(USDⓈ权利金)"),
),
"OKX_OPTIONS_CLOSE_GATE_MODE": (
("premium", "权利金倍数"),
("net_pnl", "净盈亏(USDT)"),
),
"HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
("false", "保险模式"),
),
}
_SELECT_ALIASES: dict[str, dict[str, str]] = {
@@ -248,8 +212,6 @@ def _field_type(key: str, value: str) -> str:
return "bool"
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
"OKX_SHOW_PERP_FUNDS",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
):
return "bool"
try:
+76 -291
View File
@@ -20,15 +20,15 @@ from lib.env.env_schema import (
# 合约计仓/杠杆/自动单等已从 env UI 移除;永续划转等见系统设置开关(默认关)
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权/对冲共用)"),
("OKX_API_KEY", "API Key", "账户 API(永续腿+期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(永续腿+期权共用)"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单(期权共用)"),
("OKX_API_KEY", "API Key", "账户 API(期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
(
"OKX_SHOW_PERP_FUNDS",
"显示永续资金",
"默认关闭;开启后顶栏显示 USDT 资金/交易账户。币本位期权仍会显示交易账户以便买币",
"显示USDT资金/交易账户",
"默认关闭;开启后顶栏显示 USDT 资金与交易账户。币本位期权买币仍会显示交易账户可用额",
),
],
"binance": [
@@ -49,7 +49,7 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
],
}
# 仅保留与期权/对冲产品相关的共享区块(永续执行/风控/冷静期/划转/当日资金已从 UI 移除)
# 共享区块(企业微信等);对冲配置已从 UI 移除
_SHARED_SECTIONS: list[dict[str, Any]] = [
{
"title": "企业微信",
@@ -58,83 +58,66 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
],
},
{
"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] = {
"title": "期权账户",
"exchanges": frozenset({"okx"}),
"fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ENABLED", "启用期权模块", "与上方 OKX_API_* 同源;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
(
"OKX_OPTIONS_MARGIN_MODE",
"单笔期权本位",
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "默认 1;币本位复利/固定预算共用"),
(
"OKX_OPTIONS_COIN_COMPOUND",
"币本位按交易户USDT复利",
"按交易户USDT复利",
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
),
(
"OKX_OPTIONS_COIN_BUDGET_USDT",
"币本位固定预算(USDT)",
"仅币本位且复利关闭时生效",
"固定预算(USDT)",
"仅复利关闭时生效",
),
(
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"币本位单笔上限开关",
"单笔上限开关",
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
),
(
"OKX_OPTIONS_COIN_MAX_USDT",
"币本位单笔上限(USDT)",
"单笔上限(USDT)",
"仅上限开关开启时生效",
),
(
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"币本位现货买入缓冲",
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
"现货买入缓冲",
"相对权利金倍数,默认 1.05(=多买5%);也可写 0.05 表示+5%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)",
"仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
"默认 1=一次一仓;0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
(
"OKX_OPTIONS_UNDERLYINGS",
"标的白名单",
"逗号分隔,仅 ETH/BTC;只配一个则交易页只显示该标的(开仓/链/报价/条件单校验);资金页不受影响;默认 ETH,BTC",
),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH;须在白名单内,否则取白名单第一项"),
(
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"期权链展示天数",
@@ -143,7 +126,7 @@ _OPTIONS_SECTION: dict[str, Any] = {
(
"OKX_OPTIONS_MAX_DTE_DAYS",
"开仓最大剩余天数",
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
"默认 2;开仓时拒绝更远到期(与链展示天数独立)",
),
(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
@@ -153,17 +136,12 @@ _OPTIONS_SECTION: dict[str, Any] = {
(
"OKX_OPTIONS_CLOSE_GATE_MODE",
"目标平仓门控模式",
"premium=可回收≥权利金×倍数(币本位默认×1.05);net_pnl=净盈亏折USDT≥阈值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"门控权利金倍数(USDC)",
"仅 premium 模式;USDC 权利金仓默认 2",
"premium=可回收≥权利金×倍数(默认×1.05);net_pnl=净盈亏折USDT≥阈值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"门控权利金倍数(币本位)",
"仅 premium 模式;ETH/BTC 权利金仓默认 1.05",
"门控权利金倍数",
"仅 premium 模式;默认 1.05",
),
(
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
@@ -173,98 +151,11 @@ _OPTIONS_SECTION: dict[str, Any] = {
(
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"门控持续秒数",
"条件达标后需持续满足的秒数,默认 120",
"条件达标后需持续满足的秒数,默认 20",
),
],
}
# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
(
"MAX_ACTIVE_HEDGE_PLANS",
"对冲组数上限",
"默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
(
"HEDGE_PLAN_BUDGET_BUFFER",
"对冲预算缓冲比例",
"默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
),
(
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"半腿失败时自动平期权",
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
),
]
_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
(
"HEDGE_PLAN_OPTION_PRIMARY",
"永期模式(以期权为主/保险)",
"默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
(
"HEDGE_PLAN_ITM_MAX_DIST_USD",
"永期实值最大深度(U)",
"默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
),
(
"HEDGE_PLAN_MIN_OPTION_HOURS",
"对冲期权最低剩余小时",
"默认 8;测算/启动时若传 hours_to_expiry 则校验",
),
(
"HEDGE_PLAN_MIN_OPTION_LEVERAGE",
"对冲期权最低杠杆(S/ask)",
"默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
),
]
_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
(
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
),
(
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"期期做多做空拆分口径",
"默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
),
(
"HEDGE_PLAN_OO_BIAS_RATIO",
"期期做多做空主腿占比",
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
),
]
# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
_HEDGE_PLAN_SECTION: dict[str, Any] = {
"title": "对冲计划",
"exchanges": frozenset({"okx"}),
"fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
*_HEDGE_COMMON_FIELDS,
*_HEDGE_PO_FIELDS,
*_HEDGE_OO_FIELDS,
],
}
# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_CONTROL_ENABLED": "true",
@@ -276,46 +167,31 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"AUTO_TRANSFER_FROM": "funding",
"AUTO_TRANSFER_TO": "swap",
"TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "false",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false",
"OKX_SHOW_PERP_FUNDS": "false",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
"OKX_OPTIONS_MAX_DTE_DAYS": "2",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
"OKX_OPTIONS_MARGIN_MODE": "coin",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "1",
"OKX_OPTIONS_BUDGET_BUFFER": "1",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER": "1.05",
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT": "2",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT": "0",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "20",
"OKX_TRADE_MODE": "options",
"HEDGE_PLAN_ENABLED": "false",
"MAX_ACTIVE_HEDGE_PLANS": "1",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
"HEDGE_PLAN_OPTION_PRIMARY": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
}
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key == "OKX_TRADE_MODE":
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
if file_val:
from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode
return normalize_okx_trade_mode(file_val) or file_val
try:
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
pass
return "options"
if key == "OKX_OPTIONS_MARGIN_MODE":
return "coin"
if key in file_values:
file_val = str(file_values.get(key) or "").strip()
if file_val:
@@ -350,13 +226,6 @@ def _build_field(
meta = schema.get(key) or {}
schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default)
# 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
)
if _env_truthy(manual):
val = "false"
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key)
@@ -381,86 +250,34 @@ def _build_field(
if options:
cur = (out["current"] or out["default"] or "").strip()
opt_vals = {o["value"] for o in options}
# 永期对冲不再作为可选模式;已选历史值也不塞回下拉,避免继续强化合约 UI
if key == "OKX_TRADE_MODE" and cur == "perp_options":
cur = ""
out["current"] = "options"
out["default"] = "options"
out["note"] = (
(out.get("note") or "")
+ "(当前 .env 仍为永期对冲:请改选「单独期权」或「期期对冲」后保存并重启)"
).strip()
if cur and cur not in opt_vals:
options = [{"value": cur, "label": cur}] + options
out["options"] = options
return out
def _okx_mode_for_env_ui() -> str:
try:
from lib.options.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
return "options"
def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
fields = list(_OPTIONS_SECTION["fields"])
if mode != "options":
fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
return fields
def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
if mode == "perp_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
if mode == "options_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
return []
def ui_sections_for_exchange(
exchange_key: str,
*,
mode: str | None = None,
) -> list[dict[str, Any]]:
del mode # 对冲模式已移除;保留参数兼容旧调用
ex = (exchange_key or "").strip().lower()
sections: list[dict[str, Any]] = []
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
sections.append({"title": "交易所与实盘", "fields": live_fields})
sections.extend(_SHARED_SECTIONS)
if ex in _MODE_SECTION.get("exchanges", frozenset()):
from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode
m = normalize_okx_trade_mode(mode) if mode else ""
if not m:
m = _okx_mode_for_env_ui()
sections.append(_MODE_SECTION)
sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
# 永期对冲配置块不再展示(含合约腿);期期仍展示
if m != "perp_options":
hedge_fields = _hedge_fields_for_mode(m)
if hedge_fields:
title = "对冲计划·期期"
sections.append({"title": title, "fields": hedge_fields})
if ex == "okx":
sections.append({"title": "期权账户", "fields": list(_OPTIONS_SECTION["fields"])})
return sections
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
"""可写键=当前模式可见字段 + 模式切换键 + 遗留对冲开关(兼容旧脚本写入)."""
"""可写键=当前可见字段。.env 里遗留对冲键仍保留在文件中,但 UI 不可再写入."""
keys: set[str] = set()
for sec in ui_sections_for_exchange(exchange_key):
for item in sec["fields"]:
keys.add(item[0])
ex = (exchange_key or "").strip().lower()
if ex == "okx":
keys.add("OKX_TRADE_MODE")
# 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
for item in _HEDGE_PLAN_SECTION["fields"]:
keys.add(item[0])
for item in _OPTIONS_SECTION["fields"]:
keys.add(item[0])
return frozenset(keys)
@@ -469,13 +286,18 @@ def build_env_ui_payload(
example_path: str,
env_path: str,
) -> list[dict[str, Any]]:
# 首次打开 env 配置时若 Token 为空则自动生成,便于复制到中控
try:
from lib.hub.hub_bridge_auth import ensure_hub_bridge_token
ensure_hub_bridge_token(env_path)
except Exception:
pass
schema = _schema_field_map(example_path)
env_lines = read_env_lines(env_path)
values = env_get_all(env_lines)
groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange(
exchange_key, mode=values.get("OKX_TRADE_MODE") or ""
):
for sec in ui_sections_for_exchange(exchange_key):
fields = [
_build_field(key, label, note, schema, values)
for key, label, note in sec["fields"]
@@ -489,24 +311,7 @@ def build_env_ui_payload(
return groups
# 币本位专属 / USDC 专属(配置页互斥隐藏)
_COIN_ONLY_ENV_KEYS = frozenset({
"OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
})
_USDC_ONLY_ENV_KEYS = frozenset({
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
})
# 币本位专用:按复利/上限/门控模式标记 hidden
def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") -> str:
for f in fields:
if f.get("key") == key:
@@ -515,37 +320,30 @@ def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") ->
def _mark_options_section_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按本位/门控模式/全仓复利标记 hidden(供 SSR/前端;切换开关仍可再显示)."""
margin = (_field_current(fields, "OKX_OPTIONS_MARGIN_MODE", "coin") or "coin").lower()
is_coin = margin != "usdc"
"""按门控模式/复利/单笔上限标记 hidden;USDC/永续相关项始终隐藏."""
gate_mode = (_field_current(fields, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") or "premium").lower()
compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"))
coin_compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_COMPOUND", "true"))
coin_max_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"))
compound_cap_on = _env_truthy(
_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false")
)
retired = {
"OKX_OPTIONS_MARGIN_MODE",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"OKX_SHOW_PERP_FUNDS",
}
out: list[dict[str, Any]] = []
for f in fields:
key = str(f.get("key") or "")
hide = False
if is_coin and key in _USDC_ONLY_ENV_KEYS:
hide = True
elif (not is_coin) and key in _COIN_ONLY_ENV_KEYS:
hide = True
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT" and gate_mode != "premium":
if key in retired:
hide = True
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium":
hide = True
elif key == "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT" and gate_mode != "net_pnl":
hide = True
elif key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
hide = True
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED" and not compound_on:
hide = True
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC" and not (compound_on and compound_cap_on):
hide = True
elif key == "OKX_OPTIONS_COIN_BUDGET_USDT" and coin_compound_on:
hide = True
elif key == "OKX_OPTIONS_COIN_MAX_USDT" and not coin_max_on:
@@ -609,19 +407,6 @@ def coerce_hedge_partial_close_with_manual(
*,
env_path: str = "",
) -> dict[str, str]:
"""手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
out = dict(clean or {})
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
if manual is None and env_path:
try:
from lib.env.env_file_lib import env_get_all, read_env_lines
file_vals = env_get_all(read_env_lines(env_path))
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
)
except Exception:
manual = "true"
if _env_truthy(str(manual or "")):
out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
return out
"""兼容旧调用名;对冲已移除,原样返回."""
del env_path
return dict(clean or {})
-56
View File
@@ -1,56 +0,0 @@
"""Gate.io 资金划转(crypto_monitor_gate 共用)."""
from __future__ import annotations
from typing import Any, Callable, Optional
INVALID_KEY_HINT = (
".常见原因:① GATE_API_SECRET 错误或 .env 里多了空格/换行;② IP 白名单未包含当前服务器出口 IP;"
"③ Gate「交易账户」类 API Key 若不支持钱包接口则无法走账户内划转 POST /wallet/transfers(需在官网确认该 Key 类型是否开放划转);"
"④ Key 已重置或权限变更.你已勾选现货/统一账户仍报错时,优先核对 Secret 与白名单."
)
def execute_transfer_usdt(
exchange,
amount: float,
from_account: str,
to_account: str,
*,
transfer_ccy: str = "USDT",
ensure_live_ready: Callable[[], tuple[bool, str]],
ensure_markets_loaded: Optional[Callable[[], None]] = None,
) -> tuple[bool, str, Any]:
if amount <= 0:
return False, "划转金额必须大于0", None
ccy = (transfer_ccy or "USDT").strip().upper() or "USDT"
ok_live, reason = ensure_live_ready()
if not ok_live:
return False, reason, None
if ensure_markets_loaded:
try:
ensure_markets_loaded()
except Exception:
pass
try:
resp = exchange.transfer(ccy, float(amount), from_account, to_account)
return True, "划转成功", resp
except Exception as e:
msg = str(e)
if "INVALID_KEY" in msg or "Invalid key" in msg:
msg += INVALID_KEY_HINT
return False, msg, None
def count_auto_transfer_blockers(conn, *, count_order_monitors: Callable[[Any], int]) -> int:
"""自动划转持仓守卫:order_monitors active + 趋势回调已开仓计划."""
n = int(count_order_monitors(conn) or 0)
if n > 0:
return n
try:
row = conn.execute(
"SELECT COUNT(*) FROM trend_pullback_plans "
"WHERE status='active' AND COALESCE(first_order_done, 0) != 0"
).fetchone()
return int(row[0] or 0) if row else 0
except Exception:
return n
+62 -2
View File
@@ -710,7 +710,12 @@ def options_header_balance_pack(
margin_mode = normalize_options_margin_mode()
except Exception:
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"
return {
"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
def fetch_option_summaries(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
"""GET /api/v5/public/opt-summary — 含 delta / deltaBS 等希腊字母.
市场 tickers 不含 Delta,期权链展示必须走本接口.
"""
out: dict[str, dict[str, Any]] = {}
try:
rows = ex.public_get_public_opt_summary({"instFamily": inst_family}).get("data") or []
for r in rows:
if isinstance(r, dict) and r.get("instId"):
out[str(r["instId"])] = r
except Exception:
pass
return out
def option_delta_from_summary(
summary: dict[str, Any] | None = None,
*,
ticker: dict[str, Any] | None = None,
) -> float | None:
"""优先 Black-Scholes 单位 Delta(deltaBS),其次 delta;兼容 ticker 偶发字段."""
for src in (summary, ticker):
if not isinstance(src, dict):
continue
for key in ("deltaBS", "delta", "deltaBs", "delta_bs"):
v = _safe_float(src.get(key))
if v is not None:
return v
return None
def build_option_chain(
ex: ccxt.okx,
underlying: str,
@@ -814,7 +850,7 @@ def build_option_chain(
family = inst_family_for_underlying(u, margin_mode=margin_mode)
except Exception:
family = f"{u}-USD_UM"
family = f"{u}-USD"
uly = f"{u}-USD"
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
now_ms = time.time() * 1000
@@ -842,6 +878,7 @@ def build_option_chain(
except Exception as e2:
instruments_err = str(e2) or e2.__class__.__name__
tickers = fetch_option_tickers(ex, family)
summaries = fetch_option_summaries(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0
for meta in instruments:
@@ -867,6 +904,7 @@ def build_option_chain(
continue
inst_id = str(meta.get("instId") or "")
t = tickers.get(inst_id) or {}
s = summaries.get(inst_id) or {}
q = _resolve_chain_quote(
ticker=t,
meta=meta,
@@ -879,6 +917,7 @@ def build_option_chain(
mark = q["mark_px"]
ask_sz = q["ask_sz"]
bid_sz = q["bid_sz"]
delta = option_delta_from_summary(s, ticker=t)
expiry_be = expiry_breakeven_from_ask(
opt_type=opt_type,
strike=strike,
@@ -899,6 +938,7 @@ def build_option_chain(
"ask_sz": ask_sz,
"bid_sz": bid_sz,
"mark_px": mark,
"delta": delta,
"ask_estimated": q["ask_estimated"],
"expiry_be_px": expiry_be,
"dist_expiry_be": strike_distance_to_be(strike, expiry_be),
@@ -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:
avg = fill_px
state = str(o.get("state") or "").strip().lower()
fee = _safe_float(o.get("fee"))
fee_ccy = str(o.get("feeCcy") or o.get("fee_ccy") or "").strip().upper()
return {
"ok": True,
"ord_id": str(o.get("ordId") or ord_id),
@@ -1157,6 +1199,9 @@ def fetch_option_order(ex: ccxt.okx, *, inst_id: str, ord_id: str) -> dict[str,
"avg_px": avg,
"side": str(o.get("side") or "").lower(),
"ord_type": str(o.get("ordType") or ""),
# OKX fee 扣费多为负;此处保留原始值,结算侧取绝对值
"fee": fee,
"fee_ccy": fee_ccy,
"raw": o,
}
except Exception as e:
@@ -1502,6 +1547,7 @@ def format_option_history_row(
"premium_ccy": premium_ccy,
"margin_mode": row_mode,
"realized_pnl": realized,
"realized_pnl_usdt": _safe_float(raw.get("_sim_realized_pnl_usdt")),
"realized_pnl_fmt": format_premium_amount(realized, ccy=premium_ccy),
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
"status": "closed",
@@ -1806,6 +1852,7 @@ def format_position_row(
) -> dict[str, Any]:
from lib.options.options_pricing_lib import (
close_breakeven_idx,
equivalent_contract_leverage,
expiry_breakeven_px,
idx_distance_to_be,
strike_distance_to_be,
@@ -1831,6 +1878,9 @@ def format_position_row(
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
)
delta_pa = _safe_float(pos.get("deltaPA"))
delta_bs = _safe_float(pos.get("deltaBS"))
if delta_bs is None:
delta_bs = _safe_float(pos.get("delta"))
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
@@ -1891,6 +1941,16 @@ def format_position_row(
"close_be_px": close_be,
"dist_expiry_be": strike_distance_to_be(strike, expiry_be),
"dist_close_be": idx_distance_to_be(idx_px, close_be),
"contract_leverage": equivalent_contract_leverage(
index_px=idx_px,
eth_amount=eth_amount,
total_premium=premium_paid,
margin_mode=row_mode,
),
# 展示用单位 Delta(BS);delta_pa 为仓位美元敏感度
"delta": delta_bs,
"delta_bs": delta_bs,
"delta_pa": delta_pa,
"raw": pos,
}
-116
View File
@@ -1,116 +0,0 @@
"""
OKX 挂单聚合:普通委托 + 算法单(conditional / oco / trigger).
交易所 App「止盈止损」页多为 orders-algo-pending,仅 fetch_open_orders 默认拿不到.
"""
from __future__ import annotations
from typing import Any
def _order_dedupe_key(order: dict) -> str:
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
return str(order.get("id") or info.get("algoId") or info.get("ordId") or "")
def _okx_algo_cancel_id(order_id: str) -> str:
oid = str(order_id or "")
if ":" in oid:
return oid.split(":", 1)[0]
return oid
def _okx_order_needs_stop_cancel_param(order: dict) -> bool:
"""OKX 条件/算法单撤单须 params.stop=True,否则 cancel_order 走普通单接口会静默失败."""
if not isinstance(order, dict):
return False
info = order.get("info") or {}
if not isinstance(info, dict):
info = {}
if order.get("stopLossPrice") is not None or order.get("takeProfitPrice") is not None:
return True
if info.get("algoId") or info.get("slTriggerPx") or info.get("tpTriggerPx"):
return True
typ = str(order.get("type") or info.get("ordType") or "").lower()
for token in ("conditional", "oco", "trigger", "move_order_stop", "iceberg"):
if token in typ:
return True
return False
def fetch_okx_all_open_orders(ex, exchange_symbol: str) -> list[dict]:
"""合并 OKX 普通挂单与算法挂单(去重)."""
if not exchange_symbol:
return []
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
seen: set[str] = set()
out: list[dict] = []
def add_batch(batch: list | None) -> None:
for o in batch or []:
if not isinstance(o, dict):
continue
k = _order_dedupe_key(o)
if not k or k in seen:
continue
seen.add(k)
out.append(o)
try:
add_batch(ex.fetch_open_orders(sym))
except Exception:
pass
for params in (
{"ordType": "conditional"},
{"ordType": "oco"},
{"trigger": True},
):
try:
add_batch(ex.fetch_open_orders(sym, params=dict(params)))
except Exception:
pass
return out
def cancel_okx_all_open_orders(ex, exchange_symbol: str) -> int:
"""
撤销某合约全部挂单(普通 + 条件/算法).
OKX 止盈止损在 orders-algo-pending,必须用 stop=True 才能撤掉.
"""
if not exchange_symbol:
return 0
ex.load_markets()
sym = exchange_symbol
try:
sym = ex.market(exchange_symbol)["symbol"]
except Exception:
pass
n = 0
for o in fetch_okx_all_open_orders(ex, sym):
oid = _order_dedupe_key(o)
if not oid:
continue
cancel_id = _okx_algo_cancel_id(oid)
params = {"stop": True} if _okx_order_needs_stop_cancel_param(o) else None
try:
ex.cancel_order(cancel_id, sym, params)
n += 1
continue
except Exception:
pass
try:
ex.cancel_order(oid, sym, params)
n += 1
except Exception:
pass
try:
ex.cancel_all_orders(sym)
except Exception:
pass
return n
+1
View File
@@ -0,0 +1 @@
"""Hub bridge package (中控对接)."""
+141
View File
@@ -0,0 +1,141 @@
"""中控 Bridge 鉴权与 SSO(短期登录链)."""
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
import time
from typing import Optional
from urllib.parse import urlencode
def hub_bridge_token() -> str:
return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
def extract_hub_token_from_request(headers) -> str:
raw = (headers.get("X-Hub-Token") or "").strip()
if raw:
return raw
auth = (headers.get("Authorization") or "").strip()
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return ""
def hub_bridge_token_ok(headers) -> bool:
expected = hub_bridge_token()
if not expected:
return False
got = extract_hub_token_from_request(headers)
if not got:
return False
return hmac.compare_digest(got, expected)
def make_sso_token(bridge_token: str, *, ttl_sec: int = 120) -> str:
"""exp.hex_hmac — 中控与客户端共用 bridge_token 签名."""
exp = int(time.time()) + max(30, int(ttl_sec))
payload = str(exp)
sig = hmac.new(
bridge_token.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"{payload}.{sig}"
def verify_sso_token(token: str, bridge_token: Optional[str] = None) -> bool:
expected = (bridge_token if bridge_token is not None else hub_bridge_token()).strip()
raw = (token or "").strip()
if not expected or not raw or "." not in raw:
return False
payload, sig = raw.rsplit(".", 1)
try:
exp = int(payload)
except ValueError:
return False
if exp < int(time.time()):
return False
want = hmac.new(
expected.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, want)
def build_embed_open_url(
base_url: str,
*,
bridge_token: str,
embed_path: str = "/embed",
ttl_sec: int = 120,
) -> str:
"""中控生成带 SSO 的客户端打开地址."""
root = (base_url or "").rstrip("/")
path = embed_path or "/embed"
if not path.startswith("/"):
path = "/" + path
token = make_sso_token(bridge_token, ttl_sec=ttl_sec)
q = urlencode({"token": token, "next": path})
return f"{root}/hub_sso?{q}"
def extract_embed_sso(request) -> str:
"""中控 iframe 内用的短期会话令牌(Cookie 被拦时的后备)."""
raw = (request.headers.get("X-Hub-Embed-SSO") or "").strip()
if raw:
return raw
return (request.args.get("embed_sso") or "").strip()
def hub_embed_sso_ok(request) -> bool:
tok = extract_embed_sso(request)
return bool(tok and verify_sso_token(tok))
def make_embed_session_token(bridge_token: str | None = None, *, ttl_sec: int = 604800) -> str:
"""嵌入会话令牌(默认 7 天),算法与 SSO 相同."""
secret = (bridge_token if bridge_token is not None else hub_bridge_token()).strip()
return make_sso_token(secret, ttl_sec=ttl_sec)
def frame_ancestors_header_value() -> Optional[str]:
"""
HUB_EMBED_FRAME_ANCESTORS: 空格分隔 Origin,如 https://hub.local http://192.168.1.10:5100
未配置则不改响应头(保持现状);配 * 则允许任意父页(仅内网慎用).
"""
raw = (os.getenv("HUB_EMBED_FRAME_ANCESTORS") or "").strip()
if not raw:
return None
if raw == "*":
return "*"
parts = [p.strip() for p in raw.split() if p.strip()]
return " ".join(parts) if parts else None
def ensure_hub_bridge_token(env_path: str) -> str:
"""若 .env 中 HUB_BRIDGE_TOKEN 为空则生成并写入;返回当前 token."""
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
path = (env_path or "").strip()
if not path or not os.path.isfile(path):
existing = hub_bridge_token()
if existing:
return existing
token = secrets.token_urlsafe(32)
os.environ["HUB_BRIDGE_TOKEN"] = token
return token
lines = read_env_lines(path)
cur = (env_get(lines, "HUB_BRIDGE_TOKEN") or "").strip()
if cur:
os.environ["HUB_BRIDGE_TOKEN"] = cur
return cur
token = secrets.token_urlsafe(32)
apply_env_updates(path, {"HUB_BRIDGE_TOKEN": token})
os.environ["HUB_BRIDGE_TOKEN"] = token
return token
+646
View File
@@ -0,0 +1,646 @@
"""中控 Bridge:只读快照 + 受限写(买一全平) + SSO 登录入口."""
from __future__ import annotations
from typing import Any
from flask import Flask, jsonify, redirect, request, session
from urllib.parse import quote
from lib.hub.hub_bridge_auth import (
frame_ancestors_header_value,
hub_bridge_token,
hub_bridge_token_ok,
make_embed_session_token,
verify_sso_token,
)
def _safe_next_path(raw: str, default: str = "/embed") -> str:
p = (raw or "").strip() or default
if not p.startswith("/"):
p = "/" + p
if "://" in p or p.startswith("//"):
return default
return p
def _enrich_hub_protocol(snap: dict[str, Any], *, app_module: Any) -> dict[str, Any]:
"""对齐中控设计文档 version=1 字段,同时保留原 snapshot 键."""
out = dict(snap or {})
out["version"] = 1
mode = "live"
try:
from lib.sim.mode_lib import is_sim_mode
get_db = getattr(app_module, "get_db", None)
if callable(get_db) and is_sim_mode(get_db):
mode = "sim"
except Exception:
mode = "live"
label = ""
try:
from lib.sim.mode_lib import exchange_mode_label
get_db = getattr(app_module, "get_db", None)
if callable(get_db):
label = exchange_mode_label(get_db=get_db, exchange_key="okx") or ""
except Exception:
label = ""
if not label:
label = "okx_sim" if mode == "sim" else "okx_live"
positions = out.get("positions") if isinstance(out.get("positions"), list) else []
slim_pos = []
premium_total_usdt = 0.0
has_premium = False
for p in positions:
if not isinstance(p, dict):
continue
preview = p.get("close_preview") if isinstance(p.get("close_preview"), dict) else {}
# 仅在实例已附带 close_preview 时判定残档;缺字段=未同步,勿假装「无买一/残档」
preview_present = bool(preview) and (
"bid_invalid" in preview
or "levels" in preview
or "covered_sheets" in preview
or "liquidity_ok" in preview
)
levels = preview.get("levels") if isinstance(preview.get("levels"), list) else []
top = levels[0] if levels and isinstance(levels[0], dict) else {}
bid_px = top.get("px")
if bid_px is None:
bid_px = preview.get("raw_bid_px")
bid_sz = top.get("available_sheets")
try:
uncovered = int(float(preview.get("uncovered_sheets") or 0)) if preview_present else 0
except (TypeError, ValueError):
uncovered = 0
try:
covered = int(float(preview.get("covered_sheets") or 0)) if preview_present else 0
except (TypeError, ValueError):
covered = 0
bid_invalid = bool(preview.get("bid_invalid")) if preview_present else False
bid_invalid_reason = preview.get("bid_invalid_reason") if preview_present else None
# 残档/无效买一,或买一深度盖不住持仓 → 流动性不足;未同步时不标黄残档
if not preview_present:
liq_ok = None
else:
liq_ok = (not bid_invalid) and uncovered <= 0 and (
preview.get("liquidity_ok") is not False
)
if not levels and not bid_invalid and covered <= 0:
# 已拉盘口但无可用买一档
liq_ok = False
bid_invalid = True
if not bid_invalid_reason:
bid_invalid_reason = "无买一"
pnl_usdt = p.get("display_pnl_usdt")
if pnl_usdt is None:
try:
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
pnl_usdt = display_pnl_usdt_from_option_row(p)
except Exception:
pnl_usdt = p.get("net_pnl_usdt")
premium_paid = p.get("premium_paid")
premium_usdt = None
try:
from lib.options.options_positions_lib import premium_paid_usdt_from_option_row
premium_usdt = premium_paid_usdt_from_option_row(p)
except Exception:
premium_usdt = None
if premium_usdt is not None:
has_premium = True
premium_total_usdt += float(premium_usdt)
contract_leverage = p.get("contract_leverage")
if contract_leverage is None:
try:
from lib.options.options_pricing_lib import equivalent_contract_leverage
contract_leverage = equivalent_contract_leverage(
index_px=p.get("idx_px") or p.get("index_px"),
eth_amount=p.get("eth_amount"),
total_premium=premium_paid,
margin_mode=p.get("margin_mode"),
)
except Exception:
contract_leverage = None
slim_pos.append(
{
"inst_id": p.get("inst_id"),
"opt_type": p.get("opt_type"),
"sheets": p.get("sheets") or p.get("pos"),
"pnl_usdt": pnl_usdt,
"premium_paid": premium_paid,
"premium_paid_usdt": round(float(premium_usdt), 4) if premium_usdt is not None else None,
"premium_ccy": p.get("premium_ccy"),
"contract_leverage": contract_leverage,
"delta": p.get("delta") if p.get("delta") is not None else p.get("delta_bs"),
"mark": p.get("mark") or p.get("mark_px"),
"underlying": p.get("underlying"),
"source_label": p.get("source_label"),
"target_index": p.get("target_index"),
"profit_exit_enabled": p.get("profit_exit_enabled"),
"profit_exit_mult": p.get("profit_exit_mult"),
"profit_exit_state": p.get("profit_exit_state"),
"target_monitor_text": p.get("target_monitor_text"),
"bid_px": bid_px,
"bid_sz": bid_sz,
"covered_sheets": covered if preview_present else None,
"uncovered_sheets": uncovered if preview_present else None,
"bid_invalid": bid_invalid,
"bid_invalid_reason": bid_invalid_reason,
"liquidity_ok": liq_ok,
}
)
_allowed = out.get("options_allowed_underlys")
if not isinstance(_allowed, list) or not _allowed:
_u0 = str(out.get("options_underly") or "ETH").strip().upper() or "ETH"
_allowed = [_u0] if _u0 in ("ETH", "BTC") else ["ETH", "BTC"]
else:
_allowed = [
str(x).strip().upper()
for x in _allowed
if str(x).strip().upper() in ("ETH", "BTC")
] or ["ETH", "BTC"]
out["instance"] = {
"label": label,
"mode": mode,
"options_enabled": bool(out.get("enabled")),
"account_label": out.get("account_label") or "",
"margin_mode": out.get("options_margin_mode") or "coin",
"underly": out.get("options_underly") or "ETH",
"allowed_underlys": _allowed,
}
out["options_allowed_underlys"] = _allowed
funding_usdt = out.get("funding_usdt")
trading_usdt = out.get("trading_usdt")
funding_usdc = out.get("funding_usdc")
trading_usdc = out.get("trading_usdc")
out["funds"] = {
"funding_usdt": funding_usdt,
"trading_usdt": trading_usdt,
"funding_usdc": funding_usdc,
"trading_usdc": trading_usdc,
}
def _f(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
# 账户可用余额:优先 USDT 资+交;都空则回退 USDC(≈U)
cash_parts = [_f(trading_usdt), _f(funding_usdt)]
if all(x is None for x in cash_parts):
cash_parts = [_f(trading_usdc), _f(funding_usdc)]
cash_known = [x for x in cash_parts if x is not None]
cash_usdt = round(sum(cash_known), 4) if cash_known else None
premium_usdt = round(premium_total_usdt, 4) if has_premium else (0.0 if not slim_pos else None)
# 占比分母 = 当前余额 + 已付权利金(权利金已从余额扣出,需加回才是完整账户资金)
equity_usdt = None
if cash_usdt is not None or premium_usdt is not None:
equity_usdt = round(float(cash_usdt or 0.0) + float(premium_usdt or 0.0), 4)
ratio_pct = None
if premium_usdt is not None and equity_usdt is not None and equity_usdt > 1e-9:
ratio_pct = round(float(premium_usdt) / float(equity_usdt) * 100.0, 2)
out["pnl"] = {
"unrealized_usdt": out.get("upl_total_usdc"),
}
initial_cash_usdt = None
try:
import os
if mode == "sim":
initial_cash_usdt = float(os.getenv("SIM_INITIAL_EQUITY_USDT") or 10000)
else:
for key in (
"LIVE_INITIAL_EQUITY_USDT",
"OPTIONS_INITIAL_EQUITY_USDT",
"HUB_INITIAL_CASH_USDT",
):
raw = (os.getenv(key) or "").strip()
if not raw:
continue
initial_cash_usdt = float(raw)
break
if initial_cash_usdt is not None and initial_cash_usdt <= 0:
initial_cash_usdt = None
elif initial_cash_usdt is not None:
initial_cash_usdt = round(float(initial_cash_usdt), 4)
except (TypeError, ValueError):
initial_cash_usdt = None
out["premium"] = {
"paid_usdt": premium_usdt,
"cash_usdt": cash_usdt,
"equity_usdt": equity_usdt,
"ratio_pct": ratio_pct,
"initial_cash_usdt": initial_cash_usdt,
}
out["positions_brief"] = slim_pos
out["cond_order_locked"] = bool(out.get("cond_order_locked"))
active_cond = out.get("cond_orders_active")
out["cond_orders_active"] = active_cond if isinstance(active_cond, list) else []
try:
from lib.options.options_time_lib import options_app_now_str
out["ts"] = options_app_now_str()
except Exception:
from datetime import datetime
out["ts"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
quotes = {"tickers": [], "updated_at": out.get("ts") or ""}
try:
from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers
ex = getattr(app_module, "exchange", None)
reset_hour = int(getattr(app_module, "TRADING_DAY_RESET_HOUR", 8) or 8)
tickers = build_nav_spot_tickers(ex, reset_hour=reset_hour) if ex is not None else []
quotes = {
"tickers": tickers if isinstance(tickers, list) else [],
"updated_at": out.get("ts") or "",
}
except Exception:
pass
out["quotes"] = quotes
return out
def register_hub_bridge_routes(app: Flask, app_module: Any) -> None:
@app.route("/api/hub/options/snapshot")
def api_hub_options_snapshot():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
fn = hub_ctx.get("options_snapshot_fn")
if not callable(fn):
return jsonify(
{"ok": False, "msg": "期权快照未注册", "version": 1, "enabled": False}
), 503
try:
snap = fn() or {}
except Exception as e:
return jsonify({"ok": False, "msg": str(e), "version": 1, "enabled": True}), 500
return jsonify(_enrich_hub_protocol(snap, app_module=app_module))
@app.route("/api/hub/options/pnl_curve")
def api_hub_options_pnl_curve():
"""中控汇总用:累计已实现盈亏曲线(划转/提现不影响)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict) or not callable(cfg.get("get_db")):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
range_key = (request.args.get("range") or request.args.get("window") or "7d").strip()
conn = cfg["get_db"]()
try:
from lib.instance.instance_cash_sample_lib import (
query_realized_pnl_series_with_db,
)
out = query_realized_pnl_series_with_db(conn, range_key=range_key)
out["version"] = 1
return jsonify(out)
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "曲线失败", "version": 1}), 500
finally:
conn.close()
@app.route("/api/hub/options/stats")
def api_hub_options_stats():
"""中控汇总用:窗口化已实现盈亏统计(不含浮盈进净盈亏主指标以外的展示字段仍附带)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_positions_lib import sum_options_net_pnl_usdc
from lib.options.options_stats_lib import (
compute_options_stats_for_window,
history_pnl_to_usdt,
load_live_exchange_stats_history,
load_local_options_stats_history,
normalize_stats_window,
)
window = normalize_stats_window(request.args.get("window") or "7d")
ex = cfg.get("exchange_options")
if ex is None:
return jsonify({"ok": False, "msg": "交易所未就绪", "version": 1}), 503
try:
raw_live = cfg["fetch_option_positions"](ex)
if raw_live is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败", "version": 1})
use_sim = False
try:
from lib.sim.mode_lib import is_sim_mode
use_sim = is_sim_mode(cfg["get_db"])
except Exception:
use_sim = False
if use_sim:
conn = cfg["get_db"]()
try:
history = load_local_options_stats_history(conn, ex)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
else:
# 实盘:先网后短事务,禁止握着 DB 锁拉行情(否则中控整机超时)
history = load_live_exchange_stats_history(cfg, ex)
stats = compute_options_stats_for_window(history_pnl_to_usdt(history, ex), window)
open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live)
net_realized = stats.get("net_realized_pnl")
try:
net_f = float(net_realized) if net_realized is not None else 0.0
except (TypeError, ValueError):
net_f = 0.0
total_pnl = None
if open_float is not None:
total_pnl = round(net_f + float(open_float), 4)
elif stats.get("total_closed"):
total_pnl = round(net_f, 4)
pnl_unit = "U" if is_coin_margin_mode() else "USDC"
return jsonify(
{
"ok": True,
"version": 1,
"pnl_unit": pnl_unit,
"open_float_pnl": open_float,
"total_pnl": total_pnl,
**stats,
}
)
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "统计失败", "version": 1}), 500
@app.route("/api/hub/options/close", methods=["POST"])
def api_hub_options_close():
"""中控下发全平指令:客户端本地买一限价执行;无效买一拒绝."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
data = request.get_json(silent=True) or {}
if data.get("market"):
return jsonify({"ok": False, "msg": "已禁用市价平仓,仅支持买一限价", "version": 1})
# 总览约定只全平,忽略/拒绝自定义张数
if data.get("sheets") is not None and str(data.get("sheets")).strip() != "":
return jsonify({"ok": False, "msg": "中控平仓仅支持全平,勿传 sheets", "version": 1})
inst_id = (data.get("inst_id") or "").strip()
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id", "version": 1})
hub_ctx = app.config.get("HUB_CTX") or {}
fn = hub_ctx.get("options_close_fn")
if not callable(fn):
return jsonify({"ok": False, "msg": "平仓接口未注册", "version": 1}), 503
try:
result = fn(inst_id) or {}
except Exception as e:
return jsonify({"ok": False, "msg": str(e) or "平仓失败", "version": 1}), 500
if not isinstance(result, dict):
return jsonify({"ok": False, "msg": "平仓返回异常", "version": 1}), 500
out = dict(result)
out["version"] = 1
return jsonify(out)
@app.route("/api/hub/options/transfer", methods=["POST"])
def api_hub_options_transfer():
"""中控指令:实例内资金户↔交易户划转(默认资金→交易,USDT)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
data = request.get_json(silent=True) or {}
from lib.options.options_transfer_exec_lib import execute_options_internal_transfer
out = execute_options_internal_transfer(
cfg,
amount=data.get("amount"),
from_acct=(data.get("from") or "funding"),
to_acct=(data.get("to") or "trading"),
ccy=(data.get("ccy") or "USDT"),
)
if not isinstance(out, dict):
return jsonify({"ok": False, "msg": "划转返回异常", "version": 1}), 500
body = dict(out)
body["version"] = 1
return jsonify(body)
@app.route("/api/hub/options/cond_orders", methods=["GET"])
def api_hub_options_cond_orders_list():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import list_cond_orders
from lib.options.options_db import init_options_tables
conn = cfg["get_db"]()
try:
init_options_tables(conn)
orders = list_cond_orders(conn, active_only=False)
return jsonify({"ok": True, "version": 1, "orders": orders})
finally:
conn.close()
@app.route("/api/hub/options/cond_orders", methods=["POST"])
def api_hub_options_cond_orders_create():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import (
create_cond_order,
machine_has_option_position,
)
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import option_position_limit_block_msg
ex = cfg.get("exchange_options")
if machine_has_option_position(cfg, ex):
return jsonify({"ok": False, "msg": "本机已有期权仓,拒绝条件单", "version": 1})
limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id="",
fetch_positions=cfg.get("fetch_option_positions"),
)
if limit_msg:
return jsonify({"ok": False, "msg": limit_msg, "version": 1})
data = request.get_json(silent=True) or {}
conn = cfg["get_db"]()
try:
init_options_tables(conn)
out = create_cond_order(
conn,
data,
send_wechat=cfg.get("send_wechat"),
account_label=str(cfg.get("account_label") or ""),
)
out["version"] = 1
return jsonify(out)
finally:
conn.close()
@app.route("/api/hub/options/cond_orders/<hub_order_id>/cancel", methods=["POST"])
def api_hub_options_cond_orders_cancel(hub_order_id: str):
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import cancel_cond_order
from lib.options.options_db import init_options_tables
conn = cfg["get_db"]()
try:
init_options_tables(conn)
out = cancel_cond_order(
conn,
hub_order_id,
send_wechat=cfg.get("send_wechat"),
account_label=str(cfg.get("account_label") or ""),
)
out["version"] = 1
return jsonify(out)
finally:
conn.close()
@app.route("/api/hub/options/cond_orders/<hub_order_id>/exit", methods=["POST"])
def api_hub_options_cond_orders_exit(hub_order_id: str):
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权", "version": 1}), 401
hub_ctx = app.config.get("HUB_CTX") or {}
cfg = hub_ctx.get("options_cfg")
if not isinstance(cfg, dict):
return jsonify({"ok": False, "msg": "期权未就绪", "version": 1}), 503
from lib.options.options_cond_order_lib import update_cond_order_exit
from lib.options.options_db import init_options_tables
data = request.get_json(silent=True) or {}
conn = cfg["get_db"]()
try:
init_options_tables(conn)
out = update_cond_order_exit(
conn,
hub_order_id,
target_index=data.get("target_index"),
profit_exit_enabled=data.get("profit_exit_enabled"),
profit_exit_mult=data.get("profit_exit_mult"),
)
out["version"] = 1
return jsonify(out)
finally:
conn.close()
@app.route("/api/hub/version", methods=["GET"])
def hub_version():
"""中控探测客户端当前 git 版本/在线."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
from lib.instance.instance_update_lib import read_git_version
ver = read_git_version()
ver["online"] = True
mode = "live"
try:
from lib.sim.mode_lib import exchange_mode_label, is_sim_mode
get_db = getattr(app_module, "get_db", None)
if callable(get_db) and is_sim_mode(get_db):
mode = "sim"
ver["mode"] = mode
if callable(get_db):
ver["mode_label"] = exchange_mode_label(get_db=get_db, exchange_key="okx") or ""
except Exception:
ver["mode"] = mode
return jsonify(ver)
@app.route("/api/hub/update", methods=["POST"])
def hub_update():
"""中控远程快速更新:git pull + pm2 restart(异步)."""
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
from lib.instance.instance_update_lib import schedule_pull_and_restart
out = schedule_pull_and_restart(delay_seconds=1.5)
return jsonify(out), (200 if out.get("ok") else 400)
@app.route("/api/hub/backup/list", methods=["GET"])
def hub_backup_list():
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
return jsonify(
{
"ok": True,
"items": list_backups(),
"backup_dir": backup_dir(),
"keep_days": backup_keep_days(),
"auto_hour_bj": 3,
"includes": [".env", "database", "options_journal"],
}
)
@app.route("/api/hub/backup/download/<name>", methods=["GET"])
def hub_backup_download(name: str):
if not hub_bridge_token_ok(request.headers):
return jsonify({"ok": False, "msg": "未授权"}), 401
import os
from flask import send_file
from lib.instance.backup_lib import backup_path
path = backup_path(name)
if not path or not os.path.isfile(path):
return jsonify({"ok": False, "msg": "备份不存在"}), 404
return send_file(
path,
mimetype="application/gzip",
as_attachment=True,
download_name=os.path.basename(path),
)
@app.route("/hub_sso")
def hub_sso_login():
token = (request.args.get("token") or "").strip()
next_path = _safe_next_path(request.args.get("next") or "/embed")
if not verify_sso_token(token):
return (
"<!doctype html><meta charset=utf-8><title>SSO 失败</title>"
"<p>中控登录链无效或已过期,请从中控重新打开.</p>",
403,
)
session["logged_in"] = True
session.permanent = True
session.modified = True
# iframe 跨站时常丢 Cookie:附带可放入 sessionStorage 的 embed_sso
embed_tok = make_embed_session_token(hub_bridge_token(), ttl_sec=604800)
joiner = "&" if "?" in next_path else "?"
return redirect(f"{next_path}{joiner}embed_sso={quote(embed_tok, safe='')}")
@app.after_request
def _hub_frame_ancestors(resp):
ancestors = frame_ancestors_header_value()
if ancestors:
resp.headers["Content-Security-Policy"] = f"frame-ancestors {ancestors}"
resp.headers.pop("X-Frame-Options", None)
return resp
+278
View File
@@ -0,0 +1,278 @@
"""实例备份/恢复: .env + 数据库 + 期权复盘图片;默认落盘 /root/crypto_okx_backups."""
from __future__ import annotations
import json
import os
import re
import shutil
import tarfile
import tempfile
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
_BJ = timezone(timedelta(hours=8))
_SAFE_NAME_RE = re.compile(r"^crypto_okx_backup_\d{8}_\d{6}\.tar\.gz$")
_LOCK = threading.Lock()
_LAST_AUTO_DAY = ""
def beijing_now() -> datetime:
return datetime.now(_BJ)
def backup_keep_days() -> int:
try:
return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10))
except (TypeError, ValueError):
return 10
def backup_dir() -> str:
"""备份目录:默认 /root/crypto_okx_backups,可用 BACKUP_DIR 覆盖."""
root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root"
path = os.path.join(root, "crypto_okx_backups")
os.makedirs(path, exist_ok=True)
return path
def _safe_backup_name(name: str) -> str | None:
n = os.path.basename((name or "").strip())
if not _SAFE_NAME_RE.match(n):
return None
return n
def backup_path(name: str) -> str | None:
safe = _safe_backup_name(name)
if not safe:
return None
path = os.path.abspath(os.path.join(backup_dir(), safe))
root = os.path.abspath(backup_dir())
if not path.startswith(root + os.sep) and path != root:
return None
return path
def list_backups() -> list[dict[str, Any]]:
root = backup_dir()
items: list[dict[str, Any]] = []
try:
names = os.listdir(root)
except OSError:
return []
for name in names:
if not _SAFE_NAME_RE.match(name):
continue
path = os.path.join(root, name)
try:
st = os.stat(path)
except OSError:
continue
items.append(
{
"name": name,
"size": int(st.st_size),
"mtime": int(st.st_mtime),
"mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
)
items.sort(key=lambda x: x["mtime"], reverse=True)
return items
def prune_backups(*, keep_days: int | None = None) -> int:
days = backup_keep_days() if keep_days is None else max(1, int(keep_days))
cutoff = time.time() - days * 86400
removed = 0
for item in list_backups():
if float(item["mtime"]) >= cutoff:
continue
path = backup_path(item["name"])
if not path:
continue
try:
os.remove(path)
removed += 1
except OSError:
pass
return removed
def _add_path(tar: tarfile.TarFile, abs_path: str, arcname: str) -> None:
if not abs_path or not os.path.exists(abs_path):
return
tar.add(abs_path, arcname=arcname, recursive=True)
def create_backup(
*,
base_dir: str,
db_path: str,
upload_folder: str,
label: str = "manual",
) -> dict[str, Any]:
"""打包 .env / 数据库 / options_journal 到 tar.gz."""
with _LOCK:
now = beijing_now()
name = f"crypto_okx_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
out_path = os.path.join(backup_dir(), name)
manifest = {
"kind": "crypto_okx",
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"label": (label or "manual").strip() or "manual",
"includes": [".env", "database", "options_journal"],
}
env_path = os.path.join(base_dir, ".env")
try:
from lib.options.options_review_images_lib import options_review_upload_dir
journal_dir = options_review_upload_dir(upload_folder)
except Exception:
journal_dir = os.path.join(upload_folder or "", "options_journal")
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="okx_bak_")
os.close(tmp_fd)
try:
with tarfile.open(tmp_path, "w:gz") as tar:
manifest_path = tmp_path + ".manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
try:
tar.add(manifest_path, arcname="manifest.json")
finally:
try:
os.remove(manifest_path)
except OSError:
pass
_add_path(tar, env_path, ".env")
if db_path and os.path.isfile(db_path):
_add_path(tar, db_path, "database/" + os.path.basename(db_path))
if journal_dir and os.path.isdir(journal_dir):
_add_path(tar, journal_dir, "options_journal")
shutil.move(tmp_path, out_path)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
prune_backups()
st = os.stat(out_path)
return {
"ok": True,
"name": name,
"path": out_path,
"size": int(st.st_size),
"mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"keep_days": backup_keep_days(),
}
def restore_backup(
name: str,
*,
base_dir: str,
db_path: str,
upload_folder: str,
) -> dict[str, Any]:
path = backup_path(name)
if not path or not os.path.isfile(path):
return {"ok": False, "msg": "备份不存在"}
with _LOCK:
try:
from lib.options.options_review_images_lib import options_review_upload_dir
journal_dir = options_review_upload_dir(upload_folder)
except Exception:
journal_dir = os.path.join(upload_folder or "", "options_journal")
os.makedirs(journal_dir, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="okx_restore_") as tmp:
with tarfile.open(path, "r:gz") as tar:
tar.extractall(tmp)
env_src = os.path.join(tmp, ".env")
if os.path.isfile(env_src):
shutil.copy2(env_src, os.path.join(base_dir, ".env"))
db_src_dir = os.path.join(tmp, "database")
if os.path.isdir(db_src_dir) and db_path:
for fn in os.listdir(db_src_dir):
src = os.path.join(db_src_dir, fn)
if os.path.isfile(src):
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
shutil.copy2(src, db_path)
break
journal_src = os.path.join(tmp, "options_journal")
if os.path.isdir(journal_src):
if os.path.isdir(journal_dir):
# 覆盖同名,保留其它本地文件
for root, _dirs, files in os.walk(journal_src):
rel = os.path.relpath(root, journal_src)
dest_root = journal_dir if rel == "." else os.path.join(journal_dir, rel)
os.makedirs(dest_root, exist_ok=True)
for fn in files:
shutil.copy2(os.path.join(root, fn), os.path.join(dest_root, fn))
else:
shutil.copytree(journal_src, journal_dir)
return {
"ok": True,
"msg": "已恢复 .env / 数据库 / 复盘图片,建议重启进程使配置完全生效",
"name": os.path.basename(path),
}
def maybe_run_auto_backup(
*,
base_dir: str,
db_path: str,
upload_folder: str,
hour: int = 3,
) -> dict[str, Any] | None:
"""北京时间指定整点触发一次自动备份(同日不重复)."""
global _LAST_AUTO_DAY
now = beijing_now()
day = now.strftime("%Y-%m-%d")
if now.hour != int(hour):
return None
if _LAST_AUTO_DAY == day:
return None
# 已有今日自动备份则跳过创建 prefix = f"crypto_okx_backup_{now.strftime('%Y%m%d')}_"
for item in list_backups():
if str(item["name"]).startswith(prefix):
_LAST_AUTO_DAY = day
return None
result = create_backup(
base_dir=base_dir,
db_path=db_path,
upload_folder=upload_folder,
label="auto",
)
_LAST_AUTO_DAY = day
return result
def start_backup_scheduler(
*,
base_dir: str,
db_path_fn: Callable[[], str],
upload_folder_fn: Callable[[], str],
hour: int = 3,
) -> None:
def loop() -> None:
while True:
try:
maybe_run_auto_backup(
base_dir=base_dir,
db_path=db_path_fn(),
upload_folder=upload_folder_fn(),
hour=hour,
)
except Exception:
pass
time.sleep(30)
t = threading.Thread(target=loop, name="okx-backup-scheduler", daemon=True)
t.start()
+6 -4
View File
@@ -8,10 +8,12 @@ from lib.market.ohlcv_lib import (
price_tick_from_market,
round_ohlcv_bars_to_tick,
)
from lib.trade.order_monitor_display_lib import (
apply_order_live_price_display,
apply_order_price_display_fields,
)
def apply_order_price_display_fields(payload, **kwargs):
return payload
def apply_order_live_price_display(payload, *args, **kwargs):
return payload
def resolve_kline_price_tick(
+330
View File
@@ -0,0 +1,330 @@
"""实例资金曲线采样:权益(现金+现货币+权利金+浮盈),供数据统计页使用."""
from __future__ import annotations
import datetime as dt
import os
import threading
import time
from typing import Any, Callable, Optional
_lock = threading.Lock()
_last_sample_at = 0.0
_thread: threading.Thread | None = None
_stop = threading.Event()
def cash_sample_interval_sec() -> float:
try:
return max(30.0, float(os.getenv("INSTANCE_CASH_SAMPLE_SEC") or 60))
except (TypeError, ValueError):
return 60.0
def cash_sample_keep_days() -> int:
try:
return max(7, int(float(os.getenv("INSTANCE_CASH_SAMPLE_KEEP_DAYS") or 90)))
except (TypeError, ValueError):
return 90
def init_cash_sample_table(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS instance_cash_samples (
ts_ms INTEGER PRIMARY KEY,
cash_usdt REAL NOT NULL
)
"""
)
try:
conn.execute("ALTER TABLE instance_cash_samples ADD COLUMN equity_usdt REAL")
except Exception:
pass
def _safe_float(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def cash_usdt_from_balances(bal: dict[str, Any] | None) -> Optional[float]:
"""稳定币现金 = 交易账户 + 资金账户(优先 USDT,否则 USDC)."""
if not isinstance(bal, dict):
return None
parts = [_safe_float(bal.get("trading_usdt")), _safe_float(bal.get("funding_usdt"))]
if all(x is None for x in parts):
parts = [_safe_float(bal.get("trading_usdc")), _safe_float(bal.get("funding_usdc"))]
known = [x for x in parts if x is not None]
if not known:
return None
return round(sum(known), 4)
def spot_coin_usdt_from_balances(
bal: dict[str, Any] | None,
*,
btc_px: float | None = None,
eth_px: float | None = None,
) -> float:
"""交易+资金账户 BTC/ETH 现货按指数折 U(缓冲买币占用)."""
if not isinstance(bal, dict):
return 0.0
total = 0.0
btc = (_safe_float(bal.get("trading_btc")) or 0.0) + (
_safe_float(bal.get("funding_btc")) or 0.0
)
eth = (_safe_float(bal.get("trading_eth")) or 0.0) + (
_safe_float(bal.get("funding_eth")) or 0.0
)
if btc > 0 and btc_px is not None and btc_px > 0:
total += btc * float(btc_px)
if eth > 0 and eth_px is not None and eth_px > 0:
total += eth * float(eth_px)
return round(total, 4)
def option_premium_and_upl_usdt(positions: list[dict[str, Any]] | None) -> tuple[float, float]:
"""持仓权利金U + 浮盈U(权利金+浮盈≈期权市值)."""
prem = 0.0
upl = 0.0
if not positions:
return 0.0, 0.0
from lib.options.options_positions_lib import (
display_pnl_usdt_from_option_row,
premium_paid_usdt_from_option_row,
)
for p in positions:
if not isinstance(p, dict):
continue
try:
pu = premium_paid_usdt_from_option_row(p)
except Exception:
pu = None
if pu is not None:
prem += float(pu)
try:
du = display_pnl_usdt_from_option_row(p)
except Exception:
du = None
if du is not None:
upl += float(du)
return round(prem, 4), round(upl, 4)
def equity_usdt_from_snapshot(
bal: dict[str, Any] | None,
positions: list[dict[str, Any]] | None = None,
*,
btc_px: float | None = None,
eth_px: float | None = None,
) -> Optional[float]:
"""权益 = 稳定币现金 + 现货币折U + 权利金U + 浮盈U."""
cash = cash_usdt_from_balances(bal)
spot = spot_coin_usdt_from_balances(bal, btc_px=btc_px, eth_px=eth_px)
prem, upl = option_premium_and_upl_usdt(positions)
if cash is None and spot <= 0 and prem == 0 and upl == 0:
return None
return round(float(cash or 0.0) + float(spot) + float(prem) + float(upl), 4)
def record_cash_sample(
conn,
cash_usdt: float,
*,
equity_usdt: float | None = None,
ts_ms: int | None = None,
) -> None:
init_cash_sample_table(conn)
t = int(ts_ms if ts_ms is not None else time.time() * 1000)
eq = float(equity_usdt) if equity_usdt is not None else float(cash_usdt)
conn.execute(
"""
INSERT INTO instance_cash_samples(ts_ms, cash_usdt, equity_usdt)
VALUES (?, ?, ?)
ON CONFLICT(ts_ms) DO UPDATE SET
cash_usdt=excluded.cash_usdt,
equity_usdt=excluded.equity_usdt
""",
(t, float(cash_usdt), eq),
)
keep_ms = cash_sample_keep_days() * 86400 * 1000
cutoff = t - keep_ms
conn.execute("DELETE FROM instance_cash_samples WHERE ts_ms < ?", (cutoff,))
def _range_start_ms(range_key: str) -> tuple[str, int]:
rk = (range_key or "7d").strip().lower()
now_ms = int(time.time() * 1000)
if rk in ("today", "1d"):
start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return "today", int(start.timestamp() * 1000)
if rk in ("30d", "30"):
return "30d", now_ms - 30 * 86400 * 1000
if rk in ("all", "0"):
return "all", 0
return "7d", now_ms - 7 * 86400 * 1000
def _closed_at_to_ms(raw: Any) -> int | None:
if raw is None or raw == "":
return None
try:
from lib.options.options_stats_lib import _parse_ts
d = _parse_ts(raw)
if d is None:
return None
return int(d.timestamp() * 1000)
except Exception:
return None
def _trade_realized_usdt(row: Any) -> float | None:
u = _safe_float(row["realized_pnl_usdt"] if hasattr(row, "keys") else None)
if u is not None:
return round(float(u), 4)
# 兼容未冻结 U:币盈亏×平仓价
try:
coin = _safe_float(row["realized_pnl"])
px = _safe_float(row["close_index_px"])
except Exception:
return None
if coin is None or px is None or px <= 0:
return None
return round(float(coin) * float(px), 4)
def query_realized_pnl_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
"""累计已实现盈亏曲线(只随平仓变化,划转/提现不影响)."""
rk, start_ms = _range_start_ms(range_key)
now_ms = int(time.time() * 1000)
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
except Exception:
pass
rows = conn.execute(
"""
SELECT id, closed_at, created_at, realized_pnl_usdt, realized_pnl, close_index_px
FROM options_trades
WHERE status = 'closed'
ORDER BY COALESCE(closed_at, created_at) ASC, id ASC
"""
).fetchall()
events: list[tuple[int, float]] = []
for r in rows:
pnl_u = _trade_realized_usdt(r)
if pnl_u is None:
continue
ts = _closed_at_to_ms(r["closed_at"]) or _closed_at_to_ms(r["created_at"])
if ts is None:
continue
events.append((ts, float(pnl_u)))
cum = 0.0
all_points: list[dict[str, Any]] = []
for ts, pnl in events:
cum = round(cum + pnl, 4)
all_points.append({"t": int(ts), "v": cum})
baseline = 0.0
series: list[dict[str, Any]] = []
for p in all_points:
if int(p["t"]) < start_ms:
baseline = float(p["v"])
continue
if not series:
series.append({"t": int(start_ms), "v": baseline})
series.append({"t": int(p["t"]), "v": float(p["v"])})
if not series:
# 窗口内无新平仓:画水平线(历史累计或 0)
series = [
{"t": int(start_ms), "v": baseline},
{"t": int(now_ms), "v": baseline},
]
else:
last_v = float(series[-1]["v"])
if int(series[-1]["t"]) < now_ms:
series.append({"t": int(now_ms), "v": last_v})
return {
"ok": True,
"range": rk,
"metric": "realized_pnl",
"points": series,
"start_ms": start_ms,
"end_ms": now_ms,
"latest": series[-1]["v"] if series else None,
"trade_count": len(events),
}
def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
"""兼容旧名:数据统计曲线改为累计已实现盈亏."""
return query_realized_pnl_series_with_db(conn, range_key=range_key)
def sample_once(get_db: Callable, fetch_snapshot: Callable) -> bool:
"""拉取快照并写入采样.
fetch_snapshot 可返回:
- float: 视为权益(兼容旧现金回调)
- dict: {cash_usdt, equity_usdt}
"""
global _last_sample_at
try:
snap = fetch_snapshot()
except Exception:
return False
cash: float | None
equity: float | None
if isinstance(snap, dict):
cash = _safe_float(snap.get("cash_usdt"))
equity = _safe_float(snap.get("equity_usdt"))
else:
cash = _safe_float(snap)
equity = cash
if equity is None and cash is None:
return False
if cash is None:
cash = float(equity or 0.0)
if equity is None:
equity = float(cash)
with _lock:
conn = get_db()
try:
record_cash_sample(conn, float(cash), equity_usdt=float(equity))
conn.commit()
finally:
conn.close()
_last_sample_at = time.time()
return True
def start_cash_sample_loop(get_db: Callable, fetch_snapshot: Callable) -> None:
global _thread
if _thread and _thread.is_alive():
return
_stop.clear()
def _loop() -> None:
try:
sample_once(get_db, fetch_snapshot)
except Exception:
pass
while not _stop.is_set():
if _stop.wait(cash_sample_interval_sec()):
break
try:
sample_once(get_db, fetch_snapshot)
except Exception:
pass
_thread = threading.Thread(target=_loop, daemon=True, name="instance-cash-sample")
_thread.start()
+4 -17
View File
@@ -237,12 +237,8 @@ def _table_exists(conn, name: str) -> bool:
def collect_orders(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "order_monitors"):
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]
del conn
return []
def collect_keys(conn) -> list[dict[str, Any]]:
@@ -265,17 +261,8 @@ def collect_trends(conn) -> list[dict[str, Any]]:
def collect_rolls(conn) -> list[dict[str, Any]]:
if not _table_exists(conn, "roll_groups") or not _table_exists(conn, "order_monitors"):
return []
try:
rows = conn.execute(
"""SELECT g.* FROM roll_groups g
INNER JOIN order_monitors m ON m.id = g.order_monitor_id AND m.status='active'
WHERE g.status='active' ORDER BY g.id DESC"""
).fetchall()
except Exception:
return []
return [_format_roll_item(_row_dict(r)) for r in rows]
del conn
return []
def collect_options_items(
@@ -0,0 +1,25 @@
"""实例数据统计:复盘 KPI + 已实现盈亏曲线 API."""
from __future__ import annotations
from typing import Any, Callable
from flask import Flask, jsonify, request
def install_instance_data_stats(app: Flask, app_module: Any) -> None:
get_db: Callable = app_module.get_db
login_required = app_module.login_required
@app.route("/api/instance/cash_curve")
@login_required
def api_instance_cash_curve():
from lib.instance.instance_cash_sample_lib import query_realized_pnl_series_with_db
range_key = (request.args.get("range") or "7d").strip()
conn = get_db()
try:
return jsonify(query_realized_pnl_series_with_db(conn, range_key=range_key))
except Exception as e:
return jsonify({"ok": False, "msg": f"加载盈亏曲线失败: {e}"}), 500
finally:
conn.close()
+13 -14
View File
@@ -10,22 +10,22 @@ DISPLAY_RUNTIME_PREFIX = "display."
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_dashboard": False,
"show_nav_account_ledger": False,
"show_nav_key_monitor": True,
"show_nav_trade": False, # 实盘下单界面已移除
"show_nav_key_monitor": True, # 关键位监控保留
"show_nav_trade": False, # 永续实盘下单已下线
"show_nav_strategy": False,
"show_nav_strategy_records": False,
"show_nav_records": False, # 永续交易记录与复盘已移除
"show_nav_stats": False,
"show_nav_stats": True, # 数据统计(复盘 KPI + 资金曲线);可在系统设置关闭
"show_nav_risk_policy": True,
"show_nav_live_guide": True,
"show_nav_system_guide": False,
"show_nav_env_config": True,
"show_nav_options": True,
"show_nav_options_review": True,
"show_nav_hedge_plan": False,
"show_settings_transfer": False, # 永续资金划转默认隐藏
"show_settings_transfer": False, # 永续资金划转已下线
"show_settings_export": True,
"show_settings_password": True,
"show_settings_options_swap": True,
"show_settings_options_swap": False, # USDC 币种兑换已下线
"show_settings_options_transfer": True,
}
@@ -37,13 +37,13 @@ DISPLAY_LABELS: dict[str, str] = {
"show_nav_strategy": "策略交易",
"show_nav_strategy_records": "策略交易记录",
"show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析",
"show_nav_stats": "数据统计",
"show_nav_risk_policy": "风控说明",
"show_nav_live_guide": "实盘说明",
"show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置",
"show_nav_options": "期权",
"show_nav_options_review": "期权复盘",
"show_nav_hedge_plan": "对冲计划",
"show_settings_transfer": "永续资金划转",
"show_settings_export": "数据导出",
"show_settings_password": "账户密码修改",
@@ -61,11 +61,11 @@ NAV_TAB_ALLOWED: dict[str, str] = {
"records": "show_nav_records",
"stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy",
"live_guide": "show_nav_live_guide",
"system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config",
"options": "show_nav_options",
"options_review": "show_nav_options_review",
"hedge_plan": "show_nav_hedge_plan",
}
@@ -111,8 +111,8 @@ def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
prefs = normalize_display_prefs(display or {})
t = (tab or "").strip()
if t in ("trade", "records", "stats"):
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除
if t in ("trade", "records", "hedge_plan"):
return False # 永续下单/记录已下线;关键位监控保留
key = NAV_TAB_ALLOWED.get(t)
if not key:
return True
@@ -124,18 +124,17 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
"show_nav_dashboard",
"show_nav_account_ledger",
"show_nav_key_monitor",
"show_nav_stats",
"show_nav_risk_policy",
"show_nav_live_guide",
"show_nav_system_guide",
"show_nav_env_config",
"show_nav_options",
"show_nav_options_review",
"show_nav_hedge_plan",
]
settings_keys = [
"show_settings_transfer",
"show_settings_export",
"show_settings_password",
"show_settings_options_swap",
"show_settings_options_transfer",
]
return [
+11 -3
View File
@@ -62,7 +62,7 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
key_history=page == "key_monitor",
key_list=page in ("key_monitor", "trade") or is_strategy,
orders=page == "trade" or is_strategy,
stats_bundle=page == "stats",
stats_bundle=False, # 旧永续统计已弃用;数据统计页走复盘 API + 资金曲线
strategy=is_strategy,
orphan_live=page == "trade" and is_shell,
)
@@ -161,10 +161,18 @@ def trading_account_label(
parts.append(f"{float(usdt):.2f} USDT")
except (TypeError, ValueError):
pass
eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
# 低于现货最小可卖的粉尘不展示(与卖回逻辑一致)
try:
from lib.options.options_spot_bridge_lib import spot_min_sell_sz
eth_min = spot_min_sell_sz(None, "ETH")
btc_min = spot_min_sell_sz(None, "BTC")
except Exception:
eth_min, btc_min = 0.0001, 0.00001
eth_txt = _fmt_coin_amount(eth, min_amt=eth_min)
if eth_txt is not None:
parts.append(f"{eth_txt} ETH")
btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
btc_txt = _fmt_coin_amount(btc, min_amt=btc_min)
if btc_txt is not None:
parts.append(f"{btc_txt} BTC")
return "\n".join(parts) if parts else "—"
+14 -9
View File
@@ -16,8 +16,9 @@ EMBED_TABS: tuple[str, ...] = (
"key_monitor",
"options",
"options_review",
"hedge_plan",
"stats",
"risk_policy",
"live_guide",
"system_guide",
"env_config",
"settings",
@@ -27,14 +28,15 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/": "options",
"/trade": "options", # 实盘下单界面已移除
"/records": "options_review", # 永续交易记录与复盘已移除
"/stats": "options",
"/stats": "stats",
"/dashboard": "dashboard",
"/account_ledger": "account_ledger",
"/key_monitor": "key_monitor",
"/options": "options",
"/options/review": "options_review",
"/hedge-plan": "hedge_plan",
"/hedge-plan": "options", # 对冲计划已移除,旧链接落到期权
"/risk_policy": "risk_policy",
"/live_guide": "live_guide",
"/system_guide": "system_guide",
"/env_config": "env_config",
"/settings": "settings",
@@ -53,8 +55,9 @@ def order_rule_tips_template(exchange_key: str) -> str:
def include_transfer_block(exchange_key: str) -> bool:
"""三所 standalone / embed 壳均在顶栏展示划转区块."""
return (exchange_key or "").strip().lower() in ORDER_RULE_TIPS_BY_EXCHANGE
"""永续划转区块已下线."""
del exchange_key
return False
def ui_open_guard_enabled(exchange_key: str) -> bool:
@@ -79,16 +82,18 @@ def embed_shell_enabled() -> bool:
_SETTINGS_SUB_TABS = frozenset(
{"nav", "password", "transfer", "export", "options_swap", "options_transfer"}
{"nav", "password", "transfer", "export", "backup", "options_swap", "options_transfer"}
)
def redirect_to_embed_shell_if_enabled(page: str):
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换)."""
"""直连 /trade 等整页路由时,重定向到 embed 壳(顶栏常驻,tab 软切换).
注意:中控 iframe 会给导航链加 embed=1;若因此跳过重定向,会落到 index 整页,
顶栏链接变成 3 列 grid 子项而变形.embed=1 时仍应进 /embed 壳.
"""
if not embed_shell_enabled():
return None
if (request.args.get("embed") or "").strip() == "1":
return None
if (request.path or "").rstrip("/") == "/embed":
return None
q = {k: v for k, v in request.args.items()}
+68
View File
@@ -0,0 +1,68 @@
"""实例「实盘说明」:加载 Markdown,生成 h2 目录与带锚点正文."""
from __future__ import annotations
import re
from functools import lru_cache
from html import escape
from pathlib import Path
from typing import Any
from lib.common.markdown_html_lib import render_markdown_html
from lib.paths import REPO_ROOT
def live_guide_md_path() -> Path:
return REPO_ROOT / "docs" / "实盘说明.md"
def _slugify(text: str) -> str:
raw = re.sub(r"<[^>]+>", "", text or "")
raw = re.sub(r"\s+", "-", raw.strip())
raw = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", raw)
return raw[:80] or "section"
def _inject_h2_ids(html: str) -> tuple[str, list[dict[str, str]]]:
toc: list[dict[str, str]] = []
used: dict[str, int] = {}
def repl(m: re.Match[str]) -> str:
inner = m.group(1)
base = _slugify(inner)
n = used.get(base, 0) + 1
used[base] = n
hid = base if n == 1 else f"{base}-{n}"
toc.append({"id": hid, "title": re.sub(r"<[^>]+>", "", inner).strip()})
return f'<h2 id="{escape(hid)}">{inner}</h2>'
out = re.sub(r"<h2>(.*?)</h2>", repl, html, flags=re.I | re.S)
return out, toc
@lru_cache(maxsize=4)
def _load_payload_cached(mtime_ns: int, path_str: str) -> dict[str, Any]:
path = Path(path_str)
try:
md_text = path.read_text(encoding="utf-8")
except OSError:
md_text = "# 实盘说明缺失\n\n未找到 `docs/实盘说明.md`。"
body = render_markdown_html(md_text)
body, toc = _inject_h2_ids(body)
return {"html": body, "toc": toc, "mtime_ns": mtime_ns}
def load_live_guide_payload() -> dict[str, Any]:
path = live_guide_md_path()
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
mtime_ns = 0
return dict(_load_payload_cached(mtime_ns, str(path)))
def live_guide_template_context() -> dict[str, Any]:
payload = load_live_guide_payload()
return {
"live_guide_html": payload.get("html") or "",
"live_guide_toc": payload.get("toc") or [],
}
+6 -12
View File
@@ -127,20 +127,10 @@ def build_settings_tabs(display: dict[str, Any] | None, instance_settings: dict[
tabs.append({"key": "sim_funds", "title": "模拟资金"})
if disp.get("show_settings_password", True):
tabs.append({"key": "password", "title": "账户密码"})
if inst.get("show_transfer") and disp.get("show_settings_transfer", False):
tabs.append({"key": "transfer", "title": "永续划转"})
# 永续划转 / USDC 币种兑换已下线
if disp.get("show_settings_export", True):
tabs.append({"key": "export", "title": "数据导出"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_swap", True):
_coin = False
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
_coin = bool(is_coin_margin_mode())
except Exception:
_coin = False
if not _coin:
tabs.append({"key": "options_swap", "title": "币种兑换"})
tabs.append({"key": "backup", "title": "备份恢复"})
if inst.get("options_settings_enabled") and disp.get("show_settings_options_transfer", True):
tabs.append({"key": "options_transfer", "title": "期权划转"})
return tabs
@@ -152,6 +142,10 @@ def settings_page_context(page: str, *, instance_base_dir: str | None = None, **
from lib.instance.instance_system_guide_lib import system_guide_template_context
return system_guide_template_context()
if p == "live_guide":
from lib.instance.instance_live_guide_lib import live_guide_template_context
return live_guide_template_context()
if p not in ("settings", "risk_policy", "env_config"):
return {}
display = kwargs.pop("display", None)
+85 -20
View File
@@ -1,13 +1,13 @@
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启."""
"""实例系统设置 API:导航开关,env 读写,改密,PM2 重启,备份恢复."""
from __future__ import annotations
import os
from functools import wraps
from typing import Any, Callable
from flask import jsonify, request, session
from flask import jsonify, request, send_file, session
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
from lib.env.env_file_lib import apply_env_updates
from lib.env.env_ui_manifest import (
build_env_ui_payload,
filter_updates_for_ui,
@@ -44,6 +44,17 @@ def _api_login_required(hub_token_write_allowed: bool = False):
# crypto_okx 可无 lib.hub;鉴权与中控 request_allowed 对齐
allowed = bool(auth_disabled or logged_in)
if not allowed:
try:
from lib.hub.hub_bridge_auth import hub_embed_sso_ok
if hub_embed_sso_ok(request):
session["logged_in"] = True
session.permanent = True
session.modified = True
allowed = True
except Exception:
pass
if not allowed and hub_hdr and bridge and hub_hdr == bridge:
allowed = True
if not allowed:
@@ -72,10 +83,14 @@ def register_instance_settings_routes(
exchange_key: str,
username: str,
password: str,
db_path: str | None = None,
upload_folder: str | None = None,
) -> None:
env_path = os.path.join(base_dir, ".env")
example_path = os.path.join(base_dir, ".env.example")
api_auth = _api_login_required()
resolved_db = (db_path or "").strip() or os.path.join(base_dir, "crypto.db")
resolved_upload = (upload_folder or "").strip() or os.path.join(base_dir, "static", "images")
@app.route("/api/settings/display", methods=["GET", "POST"])
@api_auth
@@ -118,23 +133,8 @@ def register_instance_settings_routes(
if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
if "OKX_OPTIONS_MARGIN_MODE" in clean:
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
lines = read_env_lines(env_path)
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
if old_mode != new_mode:
conn_m = get_db()
try:
block = mode_switch_block_msg(conn_m, None)
if block:
return jsonify({"ok": False, "msg": block}), 400
finally:
conn_m.close()
except Exception as e:
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
# 币本位专用机:忽略任何写入,强制 coin
clean["OKX_OPTIONS_MARGIN_MODE"] = "coin"
changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups)
@@ -179,6 +179,71 @@ def register_instance_settings_routes(
def api_admin_health():
return jsonify({"ok": True, "status": "up"})
@app.route("/api/settings/backup/list", methods=["GET"])
@api_auth
def api_settings_backup_list():
from lib.instance.backup_lib import backup_dir, backup_keep_days, list_backups
return jsonify(
{
"ok": True,
"items": list_backups(),
"backup_dir": backup_dir(),
"keep_days": backup_keep_days(),
"auto_hour_bj": 3,
"includes": [".env", "database", "options_journal"],
}
)
@app.route("/api/settings/backup/create", methods=["POST"])
@api_auth
def api_settings_backup_create():
from lib.instance.backup_lib import create_backup
try:
result = create_backup(
base_dir=base_dir,
db_path=resolved_db,
upload_folder=resolved_upload,
label="manual",
)
return jsonify(result)
except Exception as exc:
return jsonify({"ok": False, "msg": str(exc) or "备份失败"}), 500
@app.route("/api/settings/backup/download/<name>", methods=["GET"])
@api_auth
def api_settings_backup_download(name: str):
from lib.instance.backup_lib import backup_path
path = backup_path(name)
if not path or not os.path.isfile(path):
return jsonify({"ok": False, "msg": "备份不存在"}), 404
return send_file(
path,
mimetype="application/gzip",
as_attachment=True,
download_name=os.path.basename(path),
)
@app.route("/api/settings/backup/restore", methods=["POST"])
@api_auth
def api_settings_backup_restore():
from lib.instance.backup_lib import restore_backup
body = request.get_json(silent=True) or {}
name = str(body.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "msg": "缺少备份文件名"}), 400
result = restore_backup(
name,
base_dir=base_dir,
db_path=resolved_db,
upload_folder=resolved_upload,
)
code = 200 if result.get("ok") else 400
return jsonify(result), code
def tab_allowed_fn(tab: str) -> bool:
prefs = get_display_prefs(get_db)
return tab_allowed(tab, prefs)
+3
View File
@@ -28,10 +28,12 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"journal_form_save.js": "application/javascript; charset=utf-8",
"instance_page.css": "text/css; charset=utf-8",
"instance_embed.js": "application/javascript; charset=utf-8",
"hub_embed_auth.js": "application/javascript; charset=utf-8",
"instance_mobile_nav.js": "application/javascript; charset=utf-8",
"instance_stats.js": "application/javascript; charset=utf-8",
"instance_live.js": "application/javascript; charset=utf-8",
"instance_settings_prefs.js": "application/javascript; charset=utf-8",
"instance_backup.js": "application/javascript; charset=utf-8",
"instance_dashboard.js": "application/javascript; charset=utf-8",
"account_ledger.js": "application/javascript; charset=utf-8",
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
@@ -40,6 +42,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"options_settings.js": "application/javascript; charset=utf-8",
"options_position_cards.js": "application/javascript; charset=utf-8",
"options_review.js": "application/javascript; charset=utf-8",
"options_data_stats.js": "application/javascript; charset=utf-8",
"order_entry_model.js": "application/javascript; charset=utf-8",
"focus_chart_page.js": "application/javascript; charset=utf-8",
"focus_chart_page.css": "text/css; charset=utf-8",
+124
View File
@@ -0,0 +1,124 @@
"""客户端远程快速更新:git pull + pm2 restart(供中控 Bridge 触发)."""
from __future__ import annotations
import os
import shlex
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any
from lib.paths import REPO_ROOT
_update_lock = threading.Lock()
_update_pending_until = 0.0
def _repo_root() -> Path:
env_root = (os.getenv("REPO") or "").strip()
if env_root:
return Path(env_root)
return Path(REPO_ROOT)
def _run_git(args: list[str], *, cwd: Path) -> tuple[bool, str]:
try:
proc = subprocess.run(
["git", *args],
cwd=str(cwd),
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
return False, "未找到 git"
except subprocess.TimeoutExpired:
return False, "git 超时"
except Exception as e:
return False, str(e) or "git 失败"
out = (proc.stdout or "").strip() or (proc.stderr or "").strip()
return proc.returncode == 0, out
def read_git_version() -> dict[str, Any]:
"""读取当前部署目录 git 版本信息."""
root = _repo_root()
out: dict[str, Any] = {
"ok": True,
"repo": str(root),
"sha": "",
"short_sha": "",
"branch": "",
"dirty": False,
"msg": "",
}
if not (root / ".git").exists() and not (root / ".git").is_file():
# worktree 时 .git 可能是文件
out["ok"] = False
out["msg"] = "非 git 仓库"
return out
ok, sha = _run_git(["rev-parse", "HEAD"], cwd=root)
if not ok or not sha:
out["ok"] = False
out["msg"] = sha or "无法读取 HEAD"
return out
out["sha"] = sha
out["short_sha"] = sha[:7]
ok_b, branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
if ok_b and branch:
out["branch"] = branch
ok_d, dirty = _run_git(["status", "--porcelain"], cwd=root)
if ok_d:
out["dirty"] = bool(dirty.strip())
return out
def schedule_pull_and_restart(*, delay_seconds: float = 1.5) -> dict[str, Any]:
"""延迟执行 deploy/pull_and_restart.sh,便于 Bridge 响应先返回."""
if not sys.platform.startswith("linux"):
return {"ok": False, "msg": "仅 Linux 服务器支持远程更新", "deferred": False}
root = _repo_root()
script = root / "deploy" / "pull_and_restart.sh"
if not script.is_file():
return {"ok": False, "msg": f"缺少更新脚本: {script}", "deferred": False}
global _update_pending_until
now = time.time()
with _update_lock:
if now < _update_pending_until:
return {"ok": False, "msg": "已有更新任务进行中,请稍后再试", "deferred": False}
_update_pending_until = now + 120.0
version = read_git_version()
try:
delay = max(0.5, float(delay_seconds))
cmd = (
f"sleep {delay} && "
f"cd {shlex.quote(str(root))} && "
f"exec bash {shlex.quote(str(script))}"
)
subprocess.Popen(
["bash", "-c", cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
cwd=str(root),
)
return {
"ok": True,
"msg": "更新已受理,即将 git pull 并重启",
"deferred": True,
"before_sha": version.get("sha") or "",
"before_short_sha": version.get("short_sha") or "",
"branch": version.get("branch") or "",
}
except FileNotFoundError:
with _update_lock:
_update_pending_until = 0.0
return {"ok": False, "msg": "未找到 bash", "deferred": False}
except Exception as e:
with _update_lock:
_update_pending_until = 0.0
return {"ok": False, "msg": str(e) or "触发更新失败", "deferred": False}
+7
View File
@@ -71,6 +71,13 @@ def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], g
set_trading_mode(get_db, raw)
except Exception:
pass
# 清余额缓存,避免模拟盘资金残留到实盘顶栏
try:
from app import invalidate_account_balance_cache
invalidate_account_balance_cache()
except Exception:
pass
from lib.env.env_schema import updates_need_restart
return {"restart_required": updates_need_restart(groups, changed_keys)}
@@ -0,0 +1,37 @@
{# 备份恢复:本地 /root/crypto_okx_backups,凌晨3点自动,保留10天 #}
<div class="backup-settings" data-backup-root="1">
<h2>备份恢复</h2>
<p class="settings-subcard-desc muted">
含 <code>.env</code>、数据库、期权复盘图片。每天北京时间 <strong>03:00</strong> 自动备份,保留最近
<strong>10</strong> 天。目录:
<code id="backup-dir-label">/root/crypto_okx_backups</code>
</p>
<div class="settings-export-links-inline settings-export-links-block" style="gap:8px;margin-bottom:12px">
<button type="button" class="btn" id="backup-create-btn">立即备份</button>
<button type="button" class="btn-secondary" id="backup-refresh-btn">刷新列表</button>
<span class="muted" id="backup-status" style="font-size:.78rem"></span>
</div>
<div class="table-wrap">
<table class="or-trades-table" style="width:100%;font-size:.8rem">
<thead>
<tr>
<th>文件</th>
<th>时间(北京)</th>
<th>大小</th>
<th>操作</th>
</tr>
</thead>
<tbody id="backup-list-body">
<tr><td colspan="4" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
<script src="/static/instance_backup.js?v=1"></script>
<script>
(function () {
if (window.InstanceBackup && typeof window.InstanceBackup.boot === "function") {
window.InstanceBackup.boot();
}
})();
</script>
+44 -12
View File
@@ -1158,7 +1158,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
const pushCoin = (v, ccy) => {
if (v === null || v === undefined || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
// 低于 OKX 现货常见 minSz 的粉尘不展示
const minSz = ccy === "BTC" ? 0.00001 : 0.0001;
if (Number.isNaN(n) || !(n >= minSz)) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
@@ -1200,7 +1202,23 @@ function accountSnapshotFundingMissing(data){
const hasTrading = data.current_capital != null && data.current_capital !== "";
return !hasFunding && !hasTotal && !hasTrading;
}
let accountSnapshotRetryCount = 0;
let accountSnapshotMissRetryTimer = null;
let accountSnapshotMissRetryDone = false;
function clearAccountSnapshotMissRetry(){
accountSnapshotMissRetryDone = false;
if(accountSnapshotMissRetryTimer){
clearTimeout(accountSnapshotMissRetryTimer);
accountSnapshotMissRetryTimer = null;
}
}
function scheduleAccountSnapshotMissRetry(){
if(accountSnapshotMissRetryDone || accountSnapshotMissRetryTimer) return;
accountSnapshotMissRetryTimer = setTimeout(function(){
accountSnapshotMissRetryTimer = null;
accountSnapshotMissRetryDone = true;
refreshAccountSnapshot({ silent: true, force: true, missRetry: true });
}, 10000);
}
function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
@@ -1212,9 +1230,13 @@ function applyAccountSnapshot(data){
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} else if("funding_usdt" in data){
setFundsFieldText("total-capital", "—");
}
if(data.total_funds != null && data.total_funds !== ""){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} else if("total_funds" in data){
setFundsFieldText("total-funds", "—");
}
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText(
@@ -1226,6 +1248,16 @@ function applyAccountSnapshot(data){
data.options_margin_mode
)
);
} else if("current_capital" in data){
setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
null,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
}
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(
@@ -1315,20 +1347,20 @@ function applyAccountSnapshot(data){
}
function refreshAccountSnapshot(opts){
const options = opts || {};
const qs = options.force ? "?force=1" : "";
let useForce = !!options.force;
if(useForce && !options.missRetry && (accountSnapshotMissRetryTimer || accountSnapshotMissRetryDone)){
useForce = false;
}
const qs = useForce ? "?force=1" : "";
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount);
}else if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount = 0;
if(!accountSnapshotFundingMissing(data)){
clearAccountSnapshotMissRetry();
return;
}
scheduleAccountSnapshotMissRetry();
}).catch(()=>{
if(!options.silent && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
}
scheduleAccountSnapshotMissRetry();
});
}
@@ -127,8 +127,8 @@
{% include 'options_panel.html' %}
{% elif page == 'options_review' %}
{% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %}
{% include 'hedge_plan_panel.html' %}
{% elif page == 'stats' %}
{% include 'options_data_stats_panel.html' %}
{% endif %}
@@ -145,6 +145,10 @@
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'live_guide' %}
{% include 'live_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
+16 -16
View File
@@ -3,12 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=51"></script>
<script src="/static/hub_embed_auth.js?v=1"></script>
<script src="/static/instance_theme.js?v=52"></script>
<script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=21">
<link rel="stylesheet" href="/static/instance_theme.css?v=124">
<link rel="stylesheet" href="/static/instance_page.css?v=26">
<link rel="stylesheet" href="/static/instance_theme.css?v=129">
<script src="/static/account_risk_badge.js?v=4"></script>
<script src="/static/open_submit_gate.js?v=1"></script>
<meta name="theme-color" content="#0b0d14">
@@ -35,7 +36,7 @@
>
<div class="container">
<div class="header">
<h1>加密货币|OKX 期权与对冲</h1>
<h1>加密货币|OKX 期权</h1>
</div>
<nav class="top-nav embed-top-nav" aria-label="实例导航">
<div class="top-nav-links">
@@ -48,12 +49,13 @@
{% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" data-embed-tab="options_review" class="{% if initial_tab == 'options_review' %}active{% endif %}">期权复盘</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" data-embed-tab="hedge_plan" class="{% if initial_tab == 'hedge_plan' %}active{% endif %}">对冲计划</a>
{% endif %}
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
{% if display.get('show_nav_live_guide', true) %}
<a href="/live_guide" data-embed-tab="live_guide" class="{% if initial_tab == 'live_guide' %}active{% endif %}">实盘说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide" class="{% if initial_tab == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
@@ -65,7 +67,7 @@
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
{% include 'instance_header_panel.html' %}
{% if initial_tab not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options_review') and include_transfer_block %}
{% if initial_tab not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options_review', 'stats') and include_transfer_block %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -97,9 +99,6 @@
{% if options_nav_visible and display.show_nav_options %}
<a href="/options" class="inst-m-tab{% if initial_tab == 'options' %} active{% endif %}" data-embed-tab="options">期权</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" class="inst-m-tab{% if initial_tab == 'hedge_plan' %} active{% endif %}" data-embed-tab="hedge_plan">对冲</a>
{% endif %}
<a href="/key_monitor" class="inst-m-tab{% if initial_tab == 'key_monitor' %} active{% endif %}" data-embed-tab="key_monitor"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位</a>
<button type="button" class="inst-m-tab" data-embed-tab="more" id="inst-m-tab-more" aria-haspopup="dialog" aria-expanded="false">更多</button>
</nav>
@@ -115,12 +114,13 @@
{% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" data-embed-tab="options_review">期权复盘</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" data-embed-tab="hedge_plan">对冲计划</a>
{% endif %}
<a href="/stats" data-embed-tab="stats"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" data-embed-tab="risk_policy">风控说明</a>
{% endif %}
{% if display.get('show_nav_live_guide', true) %}
<a href="/live_guide" data-embed-tab="live_guide">实盘说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" data-embed-tab="env_config">env配置</a>
@@ -144,7 +144,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
</script>
<script src="/static/order_entry_model.js?v=5"></script>
<script src="/static/manual_order_rr_preview.js?v=5"></script>
<script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/symbol_live_price.js?v=3"></script>
<script src="/static/key_monitor_form.js?v=2"></script>
<script src="/static/instance_stats.js?v=5"></script>
{% include 'embed_boot_scripts.html' %}
@@ -157,7 +157,7 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=24"></script>
<script src="/static/instance_live.js?v=7"></script>
<script src="/static/instance_embed.js?v=32"></script>
<script src="/static/instance_embed.js?v=33"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script>
</body>
</html>
+11 -5
View File
@@ -16,11 +16,7 @@
</div>
{% if env_config_groups %}
{% set ns = namespace(mode_idx=0) %}
{% for group in env_config_groups %}
{% if '期权/对冲模式' in (group.title or '') %}{% set ns.mode_idx = loop.index0 %}{% endif %}
{% endfor %}
<div class="env-config-body card" data-env-ssr="1" id="env-config-body" data-env-mode-section-idx="{{ ns.mode_idx }}">
<div class="env-config-body card" data-env-ssr="1" id="env-config-body">
{% for group in env_config_groups %}
<input type="radio" name="env-section" id="env-sec-{{ loop.index0 }}" class="env-tab-radio"{% if loop.first %} checked{% endif %}>
{% endfor %}
@@ -77,6 +73,7 @@
onfocus="this.removeAttribute('readonly')"
>
{% else %}
<div class="env-field-input-wrap{% if field.key == 'HUB_BRIDGE_TOKEN' %} env-field-input-wrap--copy{% endif %}">
<input
class="env-field-input"
id="env-f-{{ field.key }}"
@@ -92,6 +89,15 @@
data-bwignore="true"
data-form-type="other"
>
{% if field.key == 'HUB_BRIDGE_TOKEN' %}
<button
type="button"
class="btn-secondary btn-sm env-copy-btn"
data-env-copy="HUB_BRIDGE_TOKEN"
title="复制 Token"
>复制</button>
{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
@@ -1,8 +0,0 @@
{% if force_close.enabled %}
<span class="force-close-badge" id="force-close-header-badge" role="status"
title="北京时间 {{ force_close.hour_label }} 整点未平仓将市价强制清仓(result=强制清仓)"
data-force-close-at-ms="{{ force_close.next_at_ms or '' }}"
data-force-close-active="{{ '1' if force_close.active else '0' }}">
{{ force_close.label }} 已开启 · <span class="force-close-header-cd">{{ force_close.countdown or '--:--:--' }}</span>
</span>
{% endif %}
@@ -1,8 +0,0 @@
{% if force_close.enabled %}
<span class="pos-symbol-force-close pos-force-close-meta" id="order-force-close-wrap-{{ o.id }}"
data-force-close-at-ms="{{ o.force_close_at_ms or force_close.next_at_ms or '' }}"
data-force-close-active="{{ '1' if (o.force_close_active or force_close.active) else '0' }}">
<span class="pos-force-close-label">{{ o.force_close_label or force_close.label }}</span>
· <span class="pos-force-close-cd" id="order-force-close-cd-{{ o.id }}">{{ o.force_close_countdown or force_close.countdown or '--:--:--' }}</span>
</span>
{% endif %}
@@ -1,26 +0,0 @@
<details class="tip-collapse gate-top-tips-collapse">
<summary class="tip-collapse-summary">
实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)
<span class="tip-collapse-hint">· 划转规则</span>
</summary>
<div class="tip-collapse-body rule-tip gate-transfer-tip">
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};<strong>持仓中不划转</strong>并微信通知)
</div>
</details>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
</select>
<button type="submit">手动划转</button>
</form>
+78 -87
View File
@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=50"></script>
<script src="/static/instance_theme.js?v=52"></script>
<script src="/static/autofill_guard.js?v=1"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
@@ -18,8 +18,8 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=21">
<link rel="stylesheet" href="/static/instance_theme.css?v=118">
<link rel="stylesheet" href="/static/instance_page.css?v=26">
<link rel="stylesheet" href="/static/instance_theme.css?v=129">
</head>
<body
@@ -153,45 +153,46 @@
<div class="header">
<h1>加密货币|交易监控 + AI复盘一体化</h1>
</div>
<div class="top-nav">
<nav class="top-nav" aria-label="实例导航">
<div class="top-nav-links">
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
<a href="/trade" data-embed-tab="trade" class="{% if page == 'trade' %}active{% endif %}"{% if not display.show_nav_trade %} style="display:none"{% endif %}>实盘下单</a>
{% if not intraday_discipline and display.show_nav_strategy %}
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
<a href="/strategy" data-embed-tab="strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
{% endif %}
{% if not intraday_discipline and display.show_nav_strategy_records %}
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
{% endif %}
{% if display.show_nav_records %}
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
{% endif %}
{% if display.show_nav_stats %}
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
<a href="/records" data-embed-tab="records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
{% endif %}
{% if options_nav_visible and display.show_nav_options %}
<a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a>
<a href="/options" data-embed-tab="options" class="{% if page == 'options' %}active{% endif %}">期权</a>
{% endif %}
{% if options_nav_visible and display.show_nav_options_review %}
<a href="/options/review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a>
{% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
<a href="/hedge-plan" class="{% if page == 'hedge_plan' %}active{% endif %}">对冲计划</a>
<a href="/options/review" data-embed-tab="options_review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a>
{% endif %}
<a href="/stats" data-embed-tab="stats" class="{% if page == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
{% if display.show_nav_risk_policy %}
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
{% endif %}
<a href="/system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.get('show_nav_live_guide', true) %}
<a href="/live_guide" data-embed-tab="live_guide" class="{% if page == 'live_guide' %}active{% endif %}">实盘说明</a>
{% endif %}
<a href="/system_guide" data-embed-tab="system_guide" class="{% if page == 'system_guide' %}active{% endif %}"{% if not display.show_nav_system_guide %} style="display:none"{% endif %}>系统说明</a>
{% if display.show_nav_env_config %}
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
<a href="/env_config" data-embed-tab="env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
{% endif %}
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
</div>
<a href="/settings" data-embed-tab="settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
</div>
{% include 'nav_spot_tickers.html' %}
</nav>
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %}
{% if page not in ('settings', 'risk_policy', 'system_guide', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% if page not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options', 'options_review', 'stats') %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -205,16 +206,8 @@
{% elif page == 'trade' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">实盘下单监控</h2>
{% if focus_order_id %}
<a href="/order_focus?order_id={{ focus_order_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
{% else %}
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
{% endif %}
</div>
{% include order_rule_tips_tpl %}
{% include 'order_monitor_open_form.html' %}
<h2 style="margin-bottom:8px">实盘下单监控</h2>
<p class="muted">永续下单已下线,请使用「期权」页。</p>
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
@@ -252,7 +245,6 @@
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
</span>
{% endif %}
{% include 'force_close_order_badge.html' %}
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div>
<div class="pos-head-actions">
@@ -365,8 +357,8 @@
{% include 'options_panel.html' %}
{% elif page == 'options_review' %}
{% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %}
{% include 'hedge_plan_panel.html' %}
{% elif page == 'stats' %}
{% include 'options_data_stats_panel.html' %}
{% endif %}
@@ -387,48 +379,14 @@
{% include 'system_guide_panel.html' %}
{% endif %}
{% if page == 'live_guide' %}
{% include 'live_guide_panel.html' %}
{% endif %}
{% if page == 'settings' %}
{% include 'settings_panel.html' %}
{% endif %}
{% if page == 'stats' %}
<div class="card stats-card full" id="stats-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
<h2 style="margin-bottom:0">数据统计</h2>
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
</div>
<div class="stats-content" id="stats-content">
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计):
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong> 次
</div>
<div class="form-row" style="margin-bottom:14px;align-items:center">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
统计品类
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
{% for seg in stats_bundle.segments %}
<option value="{{ seg.key }}">{{ seg.title }}</option>
{% endfor %}
</select>
</label>
</div>
{% for seg in stats_bundle.segments %}
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
<div class="stats-period-tabs" role="tablist" aria-label="统计周期">
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
</div>
{{ period_stats_pane("day", seg.day) }}
{{ period_stats_pane("week", seg.week) }}
{{ period_stats_pane("month", seg.month) }}
{{ period_stats_pane("all", seg.all) }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="modal" id="imgModal" onclick="closeModal()">
@@ -463,7 +421,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
</script>
<script src="/static/order_entry_model.js?v=5"></script>
<script src="/static/manual_order_rr_preview.js?v=5"></script>
<script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/symbol_live_price.js?v=3"></script>
<script src="/static/strategy_roll.js?v=6"></script>
<script src="/static/instance_stats.js?v=5"></script>
<script>
@@ -1639,7 +1597,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
const pushCoin = (v, ccy) => {
if (v == null || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
// 低于 OKX 现货常见 minSz 的粉尘不展示
const minSz = ccy === "BTC" ? 0.00001 : 0.0001;
if (Number.isNaN(n) || !(n >= minSz)) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
@@ -1681,7 +1641,23 @@ function accountSnapshotFundingMissing(data){
const hasTrading = data.current_capital != null && data.current_capital !== "";
return !hasFunding && !hasTotal && !hasTrading;
}
let accountSnapshotRetryCount = 0;
let accountSnapshotMissRetryTimer = null;
let accountSnapshotMissRetryDone = false;
function clearAccountSnapshotMissRetry(){
accountSnapshotMissRetryDone = false;
if(accountSnapshotMissRetryTimer){
clearTimeout(accountSnapshotMissRetryTimer);
accountSnapshotMissRetryTimer = null;
}
}
function scheduleAccountSnapshotMissRetry(){
if(accountSnapshotMissRetryDone || accountSnapshotMissRetryTimer) return;
accountSnapshotMissRetryTimer = setTimeout(function(){
accountSnapshotMissRetryTimer = null;
accountSnapshotMissRetryDone = true;
refreshAccountSnapshot({ silent: true, force: true, missRetry: true });
}, 10000);
}
function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "coin").toLowerCase() === "coin";
@@ -1693,9 +1669,13 @@ function applyAccountSnapshot(data){
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} else if("funding_usdt" in data){
setFundsFieldText("total-capital", "—");
}
if(data.total_funds != null && data.total_funds !== ""){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} else if("total_funds" in data){
setFundsFieldText("total-funds", "—");
}
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText(
@@ -1707,6 +1687,16 @@ function applyAccountSnapshot(data){
data.options_margin_mode
)
);
} else if("current_capital" in data){
setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
null,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
}
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(
@@ -1805,20 +1795,20 @@ function applyAccountSnapshot(data){
}
function refreshAccountSnapshot(opts){
const options = opts || {};
const qs = options.force ? "?force=1" : "";
let useForce = !!options.force;
if(useForce && !options.missRetry && (accountSnapshotMissRetryTimer || accountSnapshotMissRetryDone)){
useForce = false;
}
const qs = useForce ? "?force=1" : "";
fetch("/api/account_snapshot" + qs).then(r=>r.json()).then(data=>{
applyAccountSnapshot(data);
if(accountSnapshotFundingMissing(data) && !options.force && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true, force: accountSnapshotRetryCount >= 2 }), 1200 * accountSnapshotRetryCount);
}else if(!accountSnapshotFundingMissing(data)){
accountSnapshotRetryCount = 0;
if(!accountSnapshotFundingMissing(data)){
clearAccountSnapshotMissRetry();
return;
}
scheduleAccountSnapshotMissRetry();
}).catch(()=>{
if(!options.silent && accountSnapshotRetryCount < 3){
accountSnapshotRetryCount += 1;
setTimeout(() => refreshAccountSnapshot({ silent: true }), 1200 * accountSnapshotRetryCount);
}
scheduleAccountSnapshotMissRetry();
});
}
@@ -2082,6 +2072,7 @@ setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }})
</script>
<script src="/static/records_review_page.js?v=4"></script>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/nav_spot_tickers.js?v=3"></script>
<script src="/static/instance_dashboard.js?v=5"></script>
<script src="/static/account_ledger.js?v=1"></script>
<script>
@@ -28,10 +28,12 @@
{% if trade_policy.badge_text %}
<span class="trade-policy-badge" title="账户交易限制(.env)">{{ trade_policy.badge_text }}</span>
{% endif %}
{% include 'force_close_header_badge.html' %}
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
</div>
{% include 'instance_theme_toggle.html' %}
{% if show_logout|default(true) %}
<a href="/logout" class="instance-logout-btn" title="退出登录">退出</a>
{% endif %}
</div>
</div>
<div class="instance-header-stats-wrap instance-desktop-only">
@@ -1,28 +0,0 @@
{# 系统设置 · 资金划转(三所共用) #}
<div class="settings-transfer-panel">
<p class="rule-tip settings-transfer-auto">
自动划转 <strong>{{ '开启' if auto_transfer_enabled else '关闭' }}</strong>:
每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong> 起该整点小时内尝试;
账簿按 <strong>UTC 自然日</strong> 去重;
将 <code>{{ auto_transfer_to }}</code> 调整至 <strong>{{ transfer_amount_fmt|default(funds_fmt(auto_transfer_amount)) }}U</strong>:
不足从 <code>{{ auto_transfer_from }}</code> 划入,超出划回 <code>{{ auto_transfer_from }}</code>;
<strong>持仓中不划转</strong>并微信通知.
</p>
<form action="/manual_transfer" method="post" class="form-row gate-transfer-form settings-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input name="amount" id="manual-xfer-amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额 U" required
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<select name="from_account" aria-label="划出账户" autocomplete="off">
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
</select>
<select name="to_account" aria-label="划入账户" autocomplete="off">
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
</select>
<button type="submit">执行手动划转</button>
</form>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 关键位放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=50">
<script src="/static/symbol_live_price.js?v=2"></script>
<script src="/static/symbol_live_price.js?v=3"></script>
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page" data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}">
@@ -140,8 +140,10 @@
{% endif %}
</div>
<form id="key-form" action="/add_key" method="post" class="form-row">
{% from 'trade_policy_fields.html' import trade_policy_symbol with context %}
{{ trade_policy_symbol('symbol', 'key-symbol') }}
<select name="symbol" id="key-symbol" required class="trade-policy-symbol-select" title="仅 BTC/ETH,与期权标的一致">
<option value="BTC/USDT" selected>BTC/USDT</option>
<option value="ETH/USDT">ETH/USDT</option>
</select>
<select name="type" id="key-type-select" required>
<option value="关键支撑阻力" selected>关键支撑阻力</option>
</select>
@@ -0,0 +1,87 @@
{# 实盘说明: docs/实盘说明.md + h2 目录 #}
<div class="system-guide-page full">
<div class="card system-guide-card">
<div class="system-guide-head">
<h2 style="margin:0">实盘说明</h2>
<p class="muted" style="margin:6px 0 0;font-size:.85rem">OKX API、账户模式与实例 env。可在系统设置 → 导航显示中关闭顶栏入口。</p>
</div>
<div class="system-guide-layout">
{% if live_guide_toc %}
<aside class="system-guide-toc" aria-label="章节目录">
<div class="system-guide-toc-title">目录</div>
<nav>
{% for item in live_guide_toc %}
<a href="#{{ item.id }}">{{ item.title }}</a>
{% endfor %}
</nav>
</aside>
{% endif %}
<article class="system-guide-body prose">
{{ live_guide_html|safe }}
</article>
</div>
</div>
</div>
<style>
.system-guide-page { grid-column: 1 / -1; }
.system-guide-card { padding: 16px 18px 28px; }
.system-guide-layout {
display: grid;
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
gap: 18px;
margin-top: 14px;
align-items: start;
}
.system-guide-toc {
position: sticky;
top: 8px;
padding: 10px 12px;
border: 1px solid rgba(127,127,127,.25);
border-radius: 8px;
background: rgba(127,127,127,.06);
max-height: calc(100vh - 120px);
overflow: auto;
}
.system-guide-toc-title {
font-size: .78rem;
font-weight: 600;
opacity: .75;
margin-bottom: 8px;
}
.system-guide-toc a {
display: block;
font-size: .84rem;
line-height: 1.35;
padding: 5px 0;
text-decoration: none;
color: inherit;
opacity: .9;
}
.system-guide-toc a:hover { opacity: 1; text-decoration: underline; }
.system-guide-body { min-width: 0; line-height: 1.65; font-size: .92rem; }
.system-guide-body h1 { font-size: 1.35rem; margin: 0 0 12px; }
.system-guide-body h2 { font-size: 1.12rem; margin: 22px 0 10px; padding-top: 4px; scroll-margin-top: 12px; }
.system-guide-body h3 { font-size: 1rem; margin: 16px 0 8px; }
.system-guide-body p, .system-guide-body li { margin: 0 0 8px; }
.system-guide-body ul, .system-guide-body ol { padding-left: 1.35em; margin: 0 0 10px; }
.system-guide-body table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: .86rem; }
.system-guide-body th, .system-guide-body td {
border: 1px solid rgba(127,127,127,.35);
padding: 7px 9px;
text-align: left;
vertical-align: top;
}
.system-guide-body code {
font-family: ui-monospace, Consolas, monospace;
font-size: .86em;
padding: 1px 4px;
border-radius: 4px;
background: rgba(127,127,127,.12);
}
.system-guide-body hr { border: 0; border-top: 1px solid rgba(127,127,127,.28); margin: 18px 0; }
@media (max-width: 820px) {
.system-guide-layout { grid-template-columns: 1fr; }
.system-guide-toc { position: static; max-height: none; }
.system-guide-toc nav { display: flex; flex-wrap: wrap; gap: 4px 12px; }
}
</style>
@@ -0,0 +1,85 @@
{# 实例数据统计:上方期权复盘 KPI/分组,下方资金曲线 #}
<div class="card ods-page full" id="options-data-stats" data-source-type="option_spot">
<style>
.ods-page{grid-column:1/-1}
.ods-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
.ods-head h2{margin:0;font-size:1.05rem}
.ods-section{margin-bottom:22px}
.ods-section-title{font-weight:650;font-size:.9rem;margin-bottom:4px}
.ods-section-desc{font-size:.72rem;opacity:.7;margin:0 0 12px}
.ods-kpi-row{
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
gap:8px;margin-bottom:12px;
}
.ods-kpi-tile{
border:1px solid rgba(127,127,127,.22);border-radius:10px;
padding:10px 12px;background:rgba(0,0,0,.2);min-width:0;
}
.ods-kpi-label{font-size:.7rem;opacity:.7;margin-bottom:4px}
.ods-kpi-value{font-size:.95rem;font-weight:650;letter-spacing:.01em;word-break:break-all}
.ods-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
.ods-stat-card{
border:1px solid rgba(127,127,127,.22);border-radius:10px;
padding:10px 12px;background:rgba(0,0,0,.16);font-size:.76rem;
}
.ods-stat-card-title{
font-weight:650;margin-bottom:8px;font-size:.78rem;
padding-bottom:6px;border-bottom:1px solid rgba(127,127,127,.18);
}
.ods-stat-row{
display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid rgba(127,127,127,.1);
}
.ods-stat-row:last-child{border-bottom:none;padding-bottom:0}
.ods-stat-key{opacity:.9;min-width:0;overflow:hidden;text-overflow:ellipsis}
.ods-stat-val{flex-shrink:0;font-variant-numeric:tabular-nums;opacity:.85}
.ods-curve-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.ods-range-btn{
font-size:.76rem;padding:4px 10px;border-radius:6px;cursor:pointer;
border:1px solid rgba(127,127,127,.3);background:transparent;color:inherit;
}
.ods-range-btn.active{border-color:rgba(90,160,255,.7);background:rgba(90,160,255,.12)}
.ods-curve-meta{font-size:.72rem;opacity:.75;margin-left:auto}
.ods-curve-wrap{
border:1px solid rgba(127,127,127,.22);border-radius:10px;
padding:12px 10px 8px;background:rgba(0,0,0,.14);
}
.ods-curve-svg{width:100%;height:240px;display:block}
.ods-pnl-pos{color:#3dd68c}
.ods-pnl-neg{color:#f07178}
@media (max-width:900px){.ods-kpi-row{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (max-width:640px){
.ods-kpi-row{grid-template-columns:repeat(2,minmax(0,1fr))}
.ods-curve-svg{height:200px}
}
</style>
<div class="ods-head">
<h2>数据统计</h2>
<span class="muted" id="ods-status" style="font-size:.72rem"></span>
<button type="button" class="btn-secondary" id="ods-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div>
<section class="ods-section" aria-labelledby="ods-review-heading">
<div class="ods-section-title" id="ods-review-heading">期权复盘统计</div>
<p class="ods-section-desc">与「期权复盘」页统计同源;复盘页内统计仍保留。</p>
<div id="ods-kpi" class="ods-kpi-row"></div>
<div id="ods-stats-groups" class="ods-stats-grid"></div>
</section>
<section class="ods-section" aria-labelledby="ods-curve-heading">
<div class="ods-section-title" id="ods-curve-heading">已实现盈亏曲线</div>
<p class="ods-section-desc">累计已实现盈亏(期权平仓真盈亏U累加);划转/提现不影响。</p>
<div class="ods-curve-toolbar">
<button type="button" class="ods-range-btn active" data-range="today">今日</button>
<button type="button" class="ods-range-btn" data-range="7d">7日</button>
<button type="button" class="ods-range-btn" data-range="30d">30日</button>
<button type="button" class="ods-range-btn" data-range="all">全部</button>
<span class="ods-curve-meta" id="ods-curve-meta">—</span>
</div>
<div class="ods-curve-wrap">
<svg id="ods-curve-svg" class="ods-curve-svg" viewBox="0 0 640 240" preserveAspectRatio="none" role="img" aria-label="已实现盈亏曲线"></svg>
</div>
</section>
</div>
<script src="/static/options_data_stats.js?v=4"></script>
@@ -1,72 +0,0 @@
{# 趋势户:两级开仓类型 → 自动 trade_style;日内户:假破 / 结构突破 #}
{% macro order_entry_type_fields() -%}
{% if order_entry_profile == 'trend_div' %}
<div class="order-entry-model-row">
<select id="order-entry-category" class="order-entry-category" required title="反转 / 顺势 / 波段" aria-label="开仓性质">
<option value="">性质</option>
{% for cat in entry_model_categories %}
<option value="{{ cat.key }}">{{ cat.label }}</option>
{% endfor %}
</select>
<select name="entry_model" id="order-entry-model" class="order-entry-model-sub" required disabled title="启动A/B,大分歧A/B,小分歧" aria-label="开仓类型">
<option value="">类型</option>
{% for cat in entry_model_categories %}
{% for opt in cat.options %}
<option value="{{ opt.code }}" data-entry-category="{{ cat.key }}" data-trade-style="{{ opt.trade_style }}"{% if opt.help %} title="{{ opt.help }}"{% endif %} hidden disabled>{{ opt.label }}</option>
{% endfor %}
{% endfor %}
</select>
<input type="hidden" name="trade_style" id="order-trade-style-hidden" value="trend">
<span id="order-trade-style-hint" class="order-trade-style-hint" title="由开仓类型自动设定">趋势单</span>
</div>
{% elif order_entry_profile == 'intraday' %}
<select name="entry_model" id="order-entry-model" class="order-entry-intraday" required title="日内开仓类型" aria-label="开仓类型">
<option value="">开仓类型</option>
{% for opt in intraday_entry_model_options %}
<option value="{{ opt.code }}"{% if opt.help %} title="{{ opt.help }}"{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
<input type="hidden" name="trade_style" value="trend">
{% else %}
<select name="trade_style" required>
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
{% endif %}
{%- endmacro %}
-151
View File
@@ -1,151 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<script src="/static/instance_theme.js?v=5"></script>
<title>{{ exchange_display }} | 实盘下单放大</title>
<link rel="stylesheet" href="/static/instance_theme.css?v=5">
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page">
<div class="container">
<div class="card">
<div class="row" style="justify-content:space-between">
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
</svg>
</button>
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
</button>
</div>
<div class="row">
<a class="btn" href="/">返回首页</a>
<strong class="focus-title">实盘下单放大(100根K线)</strong><span class="exchange-tag">{{ exchange_display }}</span>
</div>
<div class="status">最近刷新:<span id="updated-at">--</span></div>
</div>
{% if orders %}
<div class="row" style="margin-top:10px">
<label>订单</label>
<select id="order-id">
{% for o in orders %}
<option value="{{ o.id }}" {% if selected_order and o.id == selected_order.id %}selected{% endif %}>
#{{ o.id }} {{ o.symbol }} {{ '做多' if o.direction == 'long' else '做空' }}
</option>
{% endfor %}
</select>
<label>周期</label>
<select id="timeframe">
{% for tf in ['1m','3m','5m','15m','30m','1h','4h','1d'] %}
<option value="{{ tf }}" {% if tf == default_timeframe %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<button id="manual-refresh" type="button">刷新</button>
<span id="load-status" class="status"></span>
</div>
{% else %}
<div class="empty">当前没有激活订单,无法展示放大K线.</div>
{% endif %}
</div>
{% if orders %}
<div class="card">
<div class="meta">
<div class="meta-item meta-item--emph"><div class="k">交易对</div><div class="v" id="m-symbol">-</div></div>
<div class="meta-item meta-item--emph"><div class="k">方向</div><div class="v" id="m-direction">-</div></div>
<div class="meta-item"><div class="k">成交价</div><div class="v" id="m-entry">-</div></div>
<div class="meta-item"><div class="k">止损</div><div class="v" id="m-sl">-</div></div>
<div class="meta-item"><div class="k">止盈</div><div class="v" id="m-tp">-</div></div>
<div class="meta-item"><div class="k">盈亏比</div><div class="v" id="m-rr">-</div></div>
<div class="meta-item"><div class="k">移动保本</div><div class="v" id="m-breakeven">-</div></div>
<div class="meta-item"><div class="k">现价</div><div class="v" id="m-price">-</div></div>
<div class="meta-item meta-item--emph meta-item--pnl"><div class="k">浮盈亏</div><div class="v" id="m-pnl">-</div></div>
</div>
</div>
<div class="card">
<div id="chart-wrap"><div id="chart"></div></div>
</div>
{% endif %}
</div>
{% if orders %}
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/static/focus_chart_page.js?v=2"></script>
<script>
const refreshMs = Math.max({{ price_refresh_seconds * 1000 }}, 5000);
const orderSelect = document.getElementById("order-id");
const tfSelect = document.getElementById("timeframe");
const statusEl = document.getElementById("load-status");
const updatedAtEl = document.getElementById("updated-at");
const chartHost = document.getElementById("chart");
const FCP = window.FocusChartPage;
let fc = null;
function ensureChart(){
if(fc && fc.ensureSeries()) return true;
if(!window.LightweightCharts){
statusEl.className = "status err";
statusEl.innerText = "图表库加载失败";
return false;
}
fc = FCP.createFocusChart(chartHost);
if(!fc || !fc.ensureSeries()){
statusEl.className = "status err";
statusEl.innerText = "K线序列初始化失败";
return false;
}
return true;
}
async function loadOrderKline(){
if(!ensureChart()) return;
const orderId = orderSelect.value;
const timeframe = tfSelect.value;
if(!orderId) return;
statusEl.className = "status";
statusEl.innerText = "加载中...";
try{
const resp = await fetch(`/api/order_kline?order_id=${encodeURIComponent(orderId)}&timeframe=${encodeURIComponent(timeframe)}`);
const data = await resp.json();
if(!resp.ok || !data.ok) throw new Error(data.msg || "请求失败");
if(fc && typeof fc.setPriceTick === "function") fc.setPriceTick(data.price_tick);
else FCP.setActivePriceTick(data.price_tick);
const candles = Array.isArray(data.candles) ? data.candles : [];
if(!candles.length){
statusEl.className = "status err";
statusEl.innerText = "暂无K线数据";
return;
}
fc.candleSeries.setData(candles);
fc.resetPriceLines();
const o = data.order || {};
fc.addLine(o.trigger_price, FCP.lineTitle("成交价", o.trigger_price_display), "#42a5f5");
fc.addLine(o.stop_loss, FCP.lineTitle("止损", o.stop_loss_display), "#ff6666");
fc.addLine(o.take_profit, FCP.lineTitle("止盈", o.take_profit_display), "#4cd97f");
const markPx = o.current_price;
if(markPx) fc.addLine(markPx, FCP.lineTitle("现价", o.current_price_display), "#ffb74d");
fc.chart.timeScale().fitContent();
FCP.paintOrderMeta(o);
updatedAtEl.innerText = data.updated_at || "--";
statusEl.className = "status";
statusEl.innerText = `已加载 ${candles.length} 根K线`;
}catch(err){
statusEl.className = "status err";
statusEl.innerText = err && err.message ? err.message : "加载失败";
}
}
document.getElementById("manual-refresh").addEventListener("click", loadOrderKline);
orderSelect.addEventListener("change", loadOrderKline);
tfSelect.addEventListener("change", loadOrderKline);
loadOrderKline();
setInterval(loadOrderKline, refreshMs);
</script>
{% endif %}
</body>
</html>
@@ -1,7 +0,0 @@
{# 以损定仓:杠杆按币种默认(BTC/ETH 10x,其它 5x),不可选手输 #}
{% macro order_leverage_fields() -%}
{% if position_sizing_mode != 'full_margin' %}
<input type="hidden" id="order-leverage" name="leverage" value="">
<span id="order-leverage-hint" class="order-leverage-hint" title="BTC/ETH 默认10x,其它默认5x">杠杆 —</span>
{% endif %}
{%- endmacro %}
@@ -1,77 +0,0 @@
{# 实盘下单监控 · 开仓表单(实例页与中控嵌入共用) #}
<form id="add-order-form" action="/add_order" method="post" class="order-monitor-form" data-risk-percent="{{ risk_percent }}">
<div class="om-row om-row-policy">
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction with context %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode" title="止盈止损模式">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
{% from 'order_entry_model_fields.html' import order_entry_type_fields with context %}
{{ order_entry_type_fields() }}
{% from 'order_leverage_fields.html' import order_leverage_fields with context %}
{{ order_leverage_fields() }}
</div>
<div class="om-row om-row-levels">
<label class="om-field" id="om-field-sl">
<span class="om-field-lab">止损价格</span>
<input id="order-sl" name="sl" step="any" placeholder="必填" required>
</label>
<label class="om-field" id="om-field-rr">
<span class="om-field-lab">盈亏比</span>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="默认1.5" value="1.5" title="止盈距离=止损距离×盈亏比">
</label>
<label class="om-field" id="om-field-tp" style="display:none">
<span class="om-field-lab">止盈价格</span>
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格">
</label>
<label class="om-field" id="om-field-sl-pct" style="display:none">
<span class="om-field-lab">止损%</span>
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%">
</label>
<label class="om-field" id="om-field-tp-pct" style="display:none">
<span class="om-field-lab">止盈%</span>
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%">
</label>
<div class="om-live-meta">
{% from 'symbol_live_price_snippet.html' import symbol_live_price_hint %}
{{ symbol_live_price_hint('order-symbol-live-price', 'order-symbol', 'order-direction') }}
<span class="symbol-live-price-note">成交价以交易所回报为准</span>
</div>
</div>
<div class="om-row om-row-opts">
{% if not intraday_discipline %}
<label class="om-check">
<input type="checkbox" name="breakeven_enabled" value="1" checked>
<span>移动保本</span>
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap om-time-close">
<label class="om-check" style="margin:0">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb">
<span>时间平仓</span>
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
{% else %}
<input type="hidden" name="breakeven_enabled" value="0">
{% endif %}
<label class="om-check" title="开仓后生成多周期K线图(各周期100根,含开平仓标记)">
<input type="checkbox" name="order_chart" value="true">
<span>开仓后生成多周期K线</span>
</label>
</div>
<div class="om-row om-row-action">
<button type="submit" class="om-submit{% if not can_trade %} is-blocked{% endif %}" id="om-submit-btn"{% if not can_trade %} disabled aria-disabled="true"{% endif %}>{{ open_position_button_label }}</button>
<span id="om-open-block-note" class="om-open-block-note"{% if can_trade or not (open_block_note|default('')) %} hidden{% endif %}>{% if not can_trade and (open_block_note|default('')) %}{{ open_block_note }}{% endif %}</span>
</div>
</form>
{% include 'order_plan_preview_bar.html' %}
@@ -1,38 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-exit-collapse">
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
<div class="tip-collapse-body rule-tip">
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
<li><strong>强制清仓</strong>:
{% if force_close is defined and force_close.enabled %}
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
{% else %}
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
{% endif %}
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。
</li>
</ul>
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,38 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓已满,单日开仓达上限,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-exit-collapse">
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
<div class="tip-collapse-body rule-tip">
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
<li><strong>强制清仓</strong>:
{% if force_close is defined and force_close.enabled %}
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
{% else %}
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
{% endif %}
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓;交易所裸仓且无本地监控时不会被此逻辑平掉。
</li>
</ul>
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,21 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最大同时持仓 {{ max_active_positions }}(当前 active {{ active_count }});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓(持仓达上限,单日开仓达上限,有趋势回调计划,或未到北京时间 {{ reset_hour }}:00){% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,38 +0,0 @@
<details class="tip-collapse order-rule-collapse">
<summary class="tip-collapse-summary">开仓规则说明</summary>
<div class="tip-collapse-body rule-tip" id="order-rule-tip">
规则:最多 {{ max_active_positions }} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;
本交易日开仓 {{ opens_today }}{% if daily_open_hard_limit > 0 %} / 硬上限 {{ daily_open_hard_limit }}{% endif %}(AI 提醒 {{ daily_open_alert_threshold }});
{% if can_trade %}可开仓{% else %}不可开仓{% if active_count >= max_active_positions %}(持仓 {{ active_count }}/{{ max_active_positions }}){% endif %}{% if daily_open_hard_limit > 0 and opens_today >= daily_open_hard_limit %}(单日开仓达上限){% endif %}{% if open_guard_blocks_now %}(未到北京时间 {{ reset_hour }}:00){% endif %}{% endif %};
人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
</div>
</details>
<details class="tip-collapse order-exit-collapse">
<summary class="tip-collapse-summary">平仓 / 委托 / 强制清仓</summary>
<div class="tip-collapse-body rule-tip">
<ul style="margin:0;padding-left:1.1em;line-height:1.55">
<li><strong>止盈止损委托</strong>:开仓后挂交易所条件止盈/止损;持仓卡可查看状态,「委托」可重挂,「撤单」只撤对应条件单、不平仓。</li>
<li><strong>手动平仓</strong>:持仓卡「平仓」会市价平仓并撤销该合约条件单,交易记录记为「手动平仓」。</li>
<li><strong>强制清仓</strong>:
{% if force_close is defined and force_close.enabled %}
已开启 · 北京时间 <strong>{{ force_close.hour_label }}</strong> 整点起 <strong>{{ force_close.grace_minutes|default(5) }}</strong> 分钟内,本地状态为 active 的下单监控会被市价清仓,结果记「强制清仓」;该窗口内开仓按钮灰显不可点。
{% else %}
当前<strong>已关闭</strong>(<code>FORCE_CLOSE_ENABLED=false</code>);开启后按 <code>FORCE_CLOSE_BJ_HOUR</code> 在北京时间该整点起 <code>FORCE_CLOSE_GRACE_MINUTES</code>(默认 5)分钟内清掉 active 监控仓。
{% endif %}
冷静期/日冻结期间开仓按钮同样灰显不可点。仅影响本系统监控中的仓。
</li>
</ul>
</div>
</details>
<details class="tip-collapse order-sizing-collapse">
<summary class="tip-collapse-summary">计仓与保本说明</summary>
<div class="tip-collapse-body rule-tip">
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
{% if position_sizing_mode == 'full_margin' %}
|全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x,其它 {{ alt_leverage }}x,单仓;张数按交易所精度
{% else %}
|以损定仓:风险 {{ risk_percent }}%
{% endif %}
|移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
</div>
</details>
@@ -1,5 +0,0 @@
<div id="order-plan-preview" class="order-plan-preview">
<span id="order-risk-preview" class="order-preview-risk">预估风险:<strong>—</strong></span>
<span id="order-profit-preview" class="order-preview-profit">预估盈利:<strong>—</strong></span>
<span id="order-rr-preview" class="order-preview-rr">预估盈亏比:<strong>—</strong></span>
</div>
+4 -3
View File
@@ -37,9 +37,8 @@
{% elif tab.key == 'password' %}
{% include 'password_settings_panel.html' %}
{% elif tab.key == 'transfer' %}
<h2>永续资金划转</h2>
<p class="settings-subcard-desc">账户内:资金账户与交易账户之间划转 USDT.</p>
{% include 'instance_transfer_panel.html' %}
<h2>资金划转</h2>
<p class="settings-subcard-desc muted">永续账户划转已下线;期权/模拟盘请使用对应页内划转.</p>
{% elif tab.key == 'export' %}
<h2>数据导出</h2>
<p class="settings-subcard-desc muted">CSV · v{{ instance_settings.data_export_version }}</p>
@@ -47,6 +46,8 @@
<a href="/export/key_monitors">关键位(当前)</a>
<a href="/export/key_monitor_history">关键位历史</a>
</div>
{% elif tab.key == 'backup' %}
{% include 'backup_settings_panel.html' %}
{% elif tab.key == 'options_swap' %}
<h2>币种兑换</h2>
{% include 'options_settings_swap.html' %}
-98
View File
@@ -1,98 +0,0 @@
"""Sync order_monitors after an external/market flat."""
from __future__ import annotations
import time
from typing import Any, Callable
def reconcile_hub_external_close_impl(
conn,
symbol: str,
direction: str,
*,
exchange_configured: Callable[[], bool],
not_configured_msg: str,
symbols_match: Callable[[str, str], bool],
get_opened_at_value: Callable[[Any], str],
resolve_monitor_exchange_symbol: Callable[[Any], str],
get_live_position_contracts: Callable[[str, str], float | None],
cancel_conditional_orders: Callable[[str], None],
resolve_synced_flat_close: Callable[..., tuple],
finalize_stopped_monitor: Callable[..., None],
sync_trade_records: Callable[..., None] | None = None,
reconcile_flat_streak: dict | None = None,
to_ms_with_fallback: Callable[..., int | None] | None = None,
prefer_manual_resolve: bool = False,
order_row_monitor_type: Callable[[Any], str] | None = None,
) -> dict[str, Any]:
if not exchange_configured():
return {"ok": False, "msg": not_configured_msg, "synced": 0}
sym_req = (symbol or "").strip()
dir_l = (direction or "").strip().lower()
if dir_l not in ("long", "short"):
return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0}
synced = 0
streak = reconcile_flat_streak if reconcile_flat_streak is not None else {}
rows = conn.execute(
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
).fetchall()
for r in rows:
if not symbols_match(str(r["symbol"] or ""), sym_req):
continue
if (r["direction"] or "").strip().lower() != dir_l:
continue
oid = int(r["id"])
if r["status"] == "error":
opened_at_chk = get_opened_at_value(r)
mtype = order_row_monitor_type(r) if order_row_monitor_type else r["monitor_type"]
existing = conn.execute(
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1",
(r["symbol"], opened_at_chk, mtype),
).fetchone()
if existing:
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
synced += 1
continue
exchange_symbol = resolve_monitor_exchange_symbol(r)
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
if live_contracts is None:
continue
if live_contracts > 0:
time.sleep(0.6)
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
if live_contracts is None or live_contracts > 0:
continue
streak.pop(oid, None)
cancel_conditional_orders(exchange_symbol)
opened_at = get_opened_at_value(r)
opened_at_ms = None
if to_ms_with_fallback is not None:
keys = r.keys() if hasattr(r, "keys") else ()
opened_at_ms = to_ms_with_fallback(
r["opened_at_ms"] if "opened_at_ms" in keys else None,
opened_at,
)
resolve_kw = {"opened_at_ms": opened_at_ms}
if prefer_manual_resolve:
resolve_kw["prefer_manual"] = True
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(
r, opened_at, **resolve_kw
)
finalize_stopped_monitor(
conn,
r,
result=result,
pnl_amount=pnl_amount,
closed_at=closed_at,
miss_reason=miss_reason,
)
synced += 1
if sync_trade_records is not None:
try:
sync_trade_records(conn, force=True)
except Exception:
pass
return {"ok": True, "synced": synced}
reconcile_external_close_impl = reconcile_hub_external_close_impl
+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
+150 -11
View File
@@ -11,9 +11,11 @@ from lib.options.options_close_gate_lib import (
update_close_gate,
)
from lib.options.options_pricing_lib import (
PREMIUM_DECIMALS_COIN,
estimate_close_by_bids,
fetch_option_mark_px,
is_stub_bid_px,
round_premium_amount,
total_premium,
)
@@ -307,7 +309,9 @@ def close_option_by_bid1(
px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "")
# 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计
prem_recv_submit = round(total_premium(px, level_sheets * ct_mult), 4)
prem_recv_submit = round_premium_amount(
total_premium(px, level_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN
)
time.sleep(0.6)
invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex)
@@ -331,7 +335,9 @@ def close_option_by_bid1(
if fully_closed and filled_sheets < 1:
filled_sheets = level_sheets
batch_recv = (
round(total_premium(px, filled_sheets * ct_mult), 4) if filled_sheets > 0 else 0.0
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
@@ -360,7 +366,9 @@ def close_option_by_bid1(
ord_id=oid or None,
)
accum = get_open_close_accum(conn, inst_id)
total_recv = round(float(accum.get("recv") or 0), 4)
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:
@@ -369,35 +377,163 @@ def close_option_by_bid1(
open_rows = conn.execute(
"""
SELECT id, premium_paid FROM options_trades
SELECT id, premium_paid, open_fee, premium_ccy, underlying,
exchange_ord_id, budget_usdt, open_index_px, fee_ccy,
eth_amount, open_quote, trading_usdt_before
FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
""",
(inst_id,),
).fetchall()
from lib.options.options_pnl_settle_lib import (
extract_order_fee,
fee_to_premium_ccy,
resolve_close_index_px,
settle_closed_pnl,
sum_option_order_fees_in_premium,
)
from lib.options.options_time_lib import options_app_now_str
row_ccy = premium_ccy
if open_rows:
row_ccy = str(open_rows[0]["premium_ccy"] or premium_ccy or "").strip() or premium_ccy
underly = str((open_rows[0]["underlying"] if open_rows else "") or "").strip()
close_idx = resolve_close_index_px(
ex, inst_id=inst_id, underlying=underly, hint=index_px
)
# 平仓手续费:优先累计订单号查单;查不到再用本笔下单 info
close_fee_total = 0.0
try:
close_fee_total = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=merged_ord,
premium_ccy=row_ccy,
index_px=close_idx,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
close_fee_total = 0.0
if close_fee_total <= 0:
fee_hint, fee_hint_ccy = extract_order_fee(
order if isinstance(order, dict) else None
)
if fee_hint > 0:
close_fee_total = fee_to_premium_ccy(
fee_hint,
fee_hint_ccy or row_ccy,
premium_ccy=row_ccy,
index_px=close_idx,
)
total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows)
total_open_fee = sum(float(r["open_fee"] or 0) for r in open_rows)
# 开仓费未落库时按开仓单号补查
if total_open_fee <= 0:
open_oids = [
str(r["exchange_ord_id"] or "").strip()
for r in open_rows
if str(r["exchange_ord_id"] or "").strip()
]
try:
total_open_fee = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=open_oids,
premium_ccy=row_ccy,
index_px=close_idx,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
total_open_fee = 0.0
allocated = 0.0
allocated_cfee = 0.0
allocated_ofee = 0.0
for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1:
recv = round(total_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:
recv = round(total_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
row_cfee = round(close_fee_total * share, 8)
allocated_cfee += row_cfee
stored_ofee = float(row["open_fee"] or 0)
if stored_ofee > 0:
row_ofee = stored_ofee
else:
row_ofee = round(total_open_fee * share, 8)
allocated_ofee += row_ofee
else:
recv = round(total_recv / len(open_rows), 4)
recv = round_premium_amount(
total_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv
pnl = round(recv - paid, 4)
note_sql = ""
from lib.options.options_time_lib import options_app_now_str
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
# 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id
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 = ""
# 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/close_idx/open_idx/pnl_u/[note]/id
params: list[Any] = [
close_px,
recv,
pnl,
merged_ord,
options_app_now_str(),
row_ofee,
row_cfee,
str(row["premium_ccy"] or row_ccy or premium_ccy or ""),
settled.get("close_index_px"),
settled.get("open_index_px") or row_open_idx,
pnl_usdt,
]
if signal_note and i == len(open_rows) - 1:
note_sql = """,
@@ -412,6 +548,9 @@ def close_option_by_bid1(
UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?,
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}
+2 -2
View File
@@ -27,7 +27,7 @@ def _close_recycle_mult(premium_ccy: str) -> float:
def _close_hold_seconds() -> float:
return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 20.0)
def _close_net_pnl_min_usdt() -> float:
@@ -36,7 +36,7 @@ def _close_net_pnl_min_usdt() -> float:
# 兼容旧引用
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0)
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 20.0)
_lock = threading.Lock()
# inst_id -> gate state
+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]:
from lib.exchange.okx_options_lib import fetch_options_balances
@@ -38,7 +87,7 @@ def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
trading_f = float(trading or 0)
except (TypeError, ValueError):
trading_f = 0.0
buf = float(cfg.get("budget_buffer") or 0.95)
buf = float(cfg.get("budget_buffer") or 1.0)
return compute_coin_budget_usdt(trading_f, buffer=buf)
@@ -69,13 +118,33 @@ def open_coin_option_buy_full(
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
# 先清粉尘桥 + 空仓扫尾卖回(短连接),再开业务连接
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], ex)
except Exception:
pass
try:
sweep_spot_after_options_flat(cfg, ex)
except Exception:
pass
conn: Any = None
conn = cfg["get_db"]()
try:
init_options_tables(conn)
try:
conn.commit()
except Exception:
pass
block = _spot_bridge.bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
# 网络检查前再提交,避免握锁拉持仓/行情
try:
conn.commit()
except Exception:
pass
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
@@ -142,6 +211,21 @@ def open_coin_option_buy_full(
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 买币/读余额(模拟盘写钱包)前必须提交释放锁
try:
conn.commit()
except Exception:
pass
# 开仓前有可卖标的币 → 拒开(规格:先卖回)
from lib.options.options_account_pnl_lib import (
fetch_trading_usdt,
sellable_coin_blocks_open_msg,
)
coin_block = sellable_coin_blocks_open_msg(ex, underlying)
if coin_block:
return {"ok": False, "msg": coin_block, "can_open": False}
trading_usdt_before = fetch_trading_usdt(cfg, ex)
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0
buy = _spot_bridge.spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
@@ -161,6 +245,10 @@ def open_coin_option_buy_full(
inst_id=inst_id,
message="已买币,待开期权",
)
try:
conn.commit()
except Exception:
pass
# 等余额落账
time.sleep(1.5)
try:
@@ -183,14 +271,59 @@ def open_coin_option_buy_full(
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
if coin_bought <= 0:
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}
# 落账延迟:短重试取增量;绝不把整户余额当成「本次买入」
for _ in range(3):
time.sleep(0.6)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
again = _spot_bridge.fetch_trading_coin_available(ex, underlying)
if again is None:
continue
coin_bought = max(0.0, float(again) - float(coin_before or 0))
if coin_bought > 0:
coin_after = again
break
if coin_bought <= 0:
# 订单回执有数量则用之,否则回滚
try:
hinted = float(buy.get("coin_bought") or 0)
except (TypeError, ValueError):
hinted = 0.0
if hinted > 0:
coin_bought = hinted
else:
rb = _spot_bridge.rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {
"ok": False,
"msg": "买币后可用增量无效",
"rollback": rb,
"budget": budget_info,
}
_spot_bridge.update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
# 买侧 VWAP 累加(多笔买同理);USDT 优先成交回报,否则用计划买额
try:
usdt_spent = float(buy.get("usdt_spent") or buy_usdt)
except (TypeError, ValueError):
usdt_spent = float(buy_usdt)
try:
_spot_bridge.add_bridge_buy_fill(
conn,
bridge_id,
usdt_spent=usdt_spent,
coin_bought=float(coin_bought),
)
except Exception:
pass
try:
conn.commit()
except Exception:
pass
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
@@ -288,6 +421,17 @@ def open_coin_option_buy_full(
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
# 等待成交可能十余秒:先提交并释放业务连接,避免锁死中控/卖币
try:
conn.commit()
except Exception:
pass
try:
conn.close()
except Exception:
pass
conn = None
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
@@ -296,6 +440,9 @@ def open_coin_option_buy_full(
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
conn = cfg["get_db"]()
init_options_tables(conn)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
@@ -313,11 +460,73 @@ def open_coin_option_buy_full(
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
# 开仓手续费(折权利金单位);优先成交回包,否则查单
open_fee = 0.0
idx_open = None
try:
from lib.options.options_pnl_settle_lib import (
extract_order_fee,
fee_to_premium_ccy,
resolve_close_index_px,
sum_option_order_fees_in_premium,
)
idx_open = resolve_close_index_px(
ex,
inst_id=inst_id,
underlying=underlying,
hint=(q.get("index_px") if isinstance(q, dict) else None) or idx,
)
fee_hint, fee_ccy_hint = extract_order_fee(order if isinstance(order, dict) else None)
if fee_hint <= 0 and isinstance(fill, dict):
fee_hint, fee_ccy_hint = extract_order_fee(fill.get("order") if isinstance(fill.get("order"), dict) else fill)
if fee_hint > 0:
open_fee = fee_to_premium_ccy(
fee_hint, fee_ccy_hint or premium_ccy, premium_ccy=premium_ccy, index_px=idx_open
)
if open_fee <= 0:
open_fee = sum_option_order_fees_in_premium(
ex,
inst_id=inst_id,
ord_ids=[ord_id],
premium_ccy=premium_ccy,
index_px=idx_open,
fetch_order_fn=cfg.get("fetch_option_order"),
)
except Exception:
open_fee = 0.0
# 部分成交/减半张数后卖掉多余买币(按 bridge coin_bought 封顶)
need_coin = float(premium_paid)
try:
_sell_excess_bridge_coin(
cfg,
ex,
conn,
bridge_id=bridge_id,
underlying=underlying,
keep_coin=need_coin,
coin_bought=float(coin_bought),
)
except Exception:
pass
# 开仓价必须在缩减 coin_bought 之前按买币 VWAP 冻住
# (buy_usdt_spent/buy_coin_accum;禁止事后 budget/缩水权利金量反推)
open_px_freeze = idx_open
try:
bv = _spot_bridge.bridge_buy_vwap(conn, bridge_id)
if bv is not None and float(bv) > 0:
open_px_freeze = float(bv)
except Exception:
pass
_spot_bridge.update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
coin_bought=float(need_coin),
message="期权持仓中",
)
@@ -337,6 +546,9 @@ def open_coin_option_buy_full(
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
open_fee=open_fee,
open_index_px=open_px_freeze,
trading_usdt_before=trading_usdt_before,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
@@ -393,10 +605,11 @@ def open_coin_option_buy_full(
"fill": fill,
}
finally:
try:
conn.close()
except Exception:
pass
if conn is not None:
try:
conn.close()
except Exception:
pass
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_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle"
open_fee = float(kwargs.get("open_fee") or 0.0)
if open_fee < 0:
open_fee = abs(open_fee)
fee_ccy = str(kwargs.get("premium_ccy") or "ETH")
open_index_px = kwargs.get("open_index_px")
try:
open_index_px = float(open_index_px) if open_index_px is not None else None
if open_index_px is not None and open_index_px <= 0:
open_index_px = None
except (TypeError, ValueError):
open_index_px = None
tusdt_before = kwargs.get("trading_usdt_before")
try:
tusdt_before = float(tusdt_before) if tusdt_before is not None else None
except (TypeError, ValueError):
tusdt_before = None
paid = kwargs.get("premium_paid")
try:
paid_f = float(paid) if paid is not None else None
except (TypeError, ValueError):
paid_f = None
initial_premium_usdt = None
if open_index_px is not None and paid_f is not None and paid_f > 0:
initial_premium_usdt = round(float(open_index_px) * float(paid_f), 4)
cur = conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt,
open_fee, fee_ccy, open_index_px, trading_usdt_before, pnl_settled,
initial_premium_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
@@ -429,6 +668,11 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
open_fee,
fee_ccy,
open_index_px,
tusdt_before,
initial_premium_usdt,
pe,
pe_mult,
pe_state,
@@ -446,32 +690,136 @@ def maybe_sell_spot_after_close(
inst_id: str,
close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币.
持仓查询失败/空响应时不卖,等下次监控再查(与 sweep_spot_after_options_flat 一致).
"""
del close_result # 预留字段;卖回只认交易所持仓实况
if margin_mode_from_inst_id(inst_id) != "coin":
return None
# 仍有仓则不卖
try:
rows = cfg["fetch_option_positions"](ex) or []
for p in rows:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
except Exception:
pass
rows = cfg["fetch_option_positions"](ex)
except Exception as e:
return {"ok": False, "msg": f"持仓查询失败,暂不卖币: {e}", "skipped": True}
if rows is None:
return {"ok": False, "msg": "持仓查询失败: 空响应,暂不卖币", "skipped": True}
for p in rows or []:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
result = _spot_bridge.sell_residual_after_option_flat(
cfg["get_db"],
ex,
underlying=underlying,
inst_id=inst_id,
)
# 卖回完成(sell_complete):交易账户 after − 开仓 before → 冻结本单盈亏
try:
from lib.options.options_db import init_options_tables
from lib.options.options_account_pnl_lib import try_settle_after_sellback
init_options_tables(conn)
return _spot_bridge.sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
finally:
res = result or {}
if res.get("ok") and res.get("sell_complete", True) and not res.get("incomplete"):
bid = res.get("bridge_id")
try_settle_after_sellback(
cfg["get_db"],
ex,
bridge_id=int(bid) if bid is not None else None,
cfg=cfg,
underlying=underlying,
sell_complete=True,
)
except Exception:
pass
return result
def sweep_spot_after_options_flat(
cfg: dict[str, Any],
ex: Any | None = None,
) -> dict[str, Any]:
"""币本位:某标的已无期权持仓时,把交易户该标的残留币卖回 USDT.
覆盖到期结算、限价平仓后同步关仓等未走买一平仓卖回路径的情况。
"""
if not is_coin_margin_mode():
return {"ok": True, "skipped": True, "msg": "非币本位"}
exchange = ex if ex is not None else cfg.get("exchange_options")
if exchange is None:
return {"ok": False, "msg": "exchange 未就绪"}
live_uly: set[str] = set()
try:
rows = cfg["fetch_option_positions"](exchange)
except Exception as e:
# 持仓查询失败:只清 pending 粉尘桥,绝不卖币
try:
conn.close()
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
return {"ok": False, "msg": f"持仓查询失败: {e}"}
if rows is None:
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
return {"ok": False, "msg": "持仓查询失败: 空响应"}
for p in rows or []:
try:
if abs(float(p.get("pos") or 0)) <= 1e-12:
continue
except (TypeError, ValueError):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
live_uly.add(inst.split("-")[0].upper())
# 先关掉只剩粉尘的 pending 桥,避免拦开仓又看不到重试按钮
try:
_spot_bridge.clear_dust_pending_sell_bridges(cfg["get_db"], exchange)
except Exception:
pass
sold: list[dict[str, Any]] = []
for uly in ("BTC", "ETH"):
if uly in live_uly:
continue
try:
avail = _spot_bridge.fetch_trading_coin_available(exchange, uly)
# 低于现货 minSz 的粉尘不扫、不重试
if avail is None or not _spot_bridge.is_sellable_spot_coin(exchange, uly, avail):
continue
except Exception:
continue
try:
out = _spot_bridge.sell_residual_after_option_flat(
cfg["get_db"],
exchange,
underlying=uly,
inst_id=None,
)
out = out or {}
if out.get("ok") and out.get("sell_complete", True) and not out.get("incomplete"):
try:
from lib.options.options_account_pnl_lib import try_settle_after_sellback
bid = out.get("bridge_id")
try_settle_after_sellback(
cfg["get_db"],
exchange,
bridge_id=int(bid) if bid is not None else None,
cfg=cfg,
underlying=uly,
sell_complete=True,
)
except Exception:
pass
sold.append({"underlying": uly, **out})
except Exception as e:
sold.append({"underlying": uly, "ok": False, "msg": str(e)})
return {"ok": True, "results": sold, "skipped": not sold}
+999
View File
@@ -0,0 +1,999 @@
"""中控条件开仓单:客户端本地监控/开仓/出场(中控只下发不盯盘)."""
from __future__ import annotations
import json
import sqlite3
import time
from datetime import datetime, timedelta, timezone
from typing import Any
# armed:待命穿越; triggering:已穿越等偏离区内开仓; open:持仓中;
# done:终态(leave_up/leave_down/cancel/timeout/closed)
ACTIVE_STATUSES = ("armed", "triggering", "open")
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def ensure_cond_order_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_cond_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hub_order_id TEXT NOT NULL UNIQUE,
underlying TEXT NOT NULL,
opt_type TEXT NOT NULL,
trigger_side TEXT NOT NULL,
trigger_px REAL NOT NULL,
deviation_pct REAL NOT NULL,
min_expiry_hours REAL NOT NULL,
target_index REAL,
profit_exit_enabled INTEGER DEFAULT 0,
profit_exit_mult REAL DEFAULT 1.0,
pending_target_index REAL,
pending_profit_exit_enabled INTEGER,
pending_profit_exit_mult REAL,
exit_update_pending INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'armed',
end_reason TEXT,
last_index REAL,
crossed_at TEXT,
opened_inst_id TEXT,
opened_trade_id INTEGER,
opened_at TEXT,
message TEXT,
created_at TEXT,
updated_at TEXT,
ended_at TEXT
)
"""
)
try:
conn.execute("ALTER TABLE options_cond_orders ADD COLUMN opened_at TEXT")
except Exception:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_cond_orders_status
ON options_cond_orders(status)
"""
)
try:
conn.execute(
"""
UPDATE options_cond_orders
SET opened_at = updated_at
WHERE opened_at IS NULL
AND status = 'open'
AND updated_at IS NOT NULL
AND TRIM(updated_at) != ''
"""
)
except Exception:
pass
def _now() -> str:
try:
from lib.options.options_time_lib import options_app_now_str
return options_app_now_str()
except Exception:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _notify_cond(
send_wechat: Any,
*,
title: str,
order: dict[str, Any],
account_label: str = "",
extra: list[str] | None = None,
) -> None:
if not callable(send_wechat) or not order:
return
try:
lines = [
title,
f"账户:{account_label or 'OKX期权'}",
f"单号:{order.get('hub_order_id') or '—'}",
f"标的:{order.get('underlying') or '—'} {order.get('opt_type') or '—'}",
f"触发价:{order.get('trigger_px') if order.get('trigger_px') is not None else '—'}",
]
for line in extra or []:
if line:
lines.append(line)
send_wechat("\n".join(lines))
except Exception:
pass
def next_thursday_2300(created_at: Any) -> datetime | None:
"""下发后第一个周四 23:00(业务时区).周四 23:00 之后下发的顺延到下周四."""
from lib.options.options_time_lib import options_app_tz, options_wall_clock_to_ms
ms = options_wall_clock_to_ms(created_at)
if ms is None:
return None
tz = options_app_tz()
dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(tz)
days = (3 - dt.weekday()) % 7
cand = dt.replace(hour=23, minute=0, second=0, microsecond=0) + timedelta(days=days)
if cand <= dt:
cand += timedelta(days=7)
return cand
def expire_untriggered_cond_orders(
conn: sqlite3.Connection,
*,
send_wechat: Any = None,
account_label: str = "",
now: datetime | None = None,
) -> int:
"""仅取消仍待命(未触发)的条件单;持仓/触发中不动."""
from lib.options.options_time_lib import options_app_tz
now_dt = now or datetime.now(options_app_tz())
n = 0
for order in list_cond_orders(conn, active_only=True):
if str(order.get("status") or "") != "armed":
continue
deadline = next_thursday_2300(order.get("created_at"))
if deadline is None or now_dt < deadline:
continue
_end_order(
conn,
order,
end_reason="timeout",
message="周四23:00未触发自动取消",
send_wechat=send_wechat,
account_label=account_label,
)
n += 1
return n
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
d = dict(row)
d["profit_exit_enabled"] = bool(int(d.get("profit_exit_enabled") or 0))
d["exit_update_pending"] = bool(int(d.get("exit_update_pending") or 0))
if d.get("pending_profit_exit_enabled") is not None:
d["pending_profit_exit_enabled"] = bool(int(d["pending_profit_exit_enabled"]))
return d
def list_cond_orders(conn: sqlite3.Connection, *, active_only: bool = False) -> list[dict[str, Any]]:
ensure_cond_order_table(conn)
if active_only:
rows = conn.execute(
f"""
SELECT * FROM options_cond_orders
WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))})
ORDER BY id DESC
""",
ACTIVE_STATUSES,
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM options_cond_orders ORDER BY id DESC LIMIT 100"
).fetchall()
return [_row_to_dict(r) for r in rows if r]
def get_cond_order_by_hub_id(conn: sqlite3.Connection, hub_order_id: str) -> dict[str, Any] | None:
ensure_cond_order_table(conn)
row = conn.execute(
"SELECT * FROM options_cond_orders WHERE hub_order_id = ?",
((hub_order_id or "").strip(),),
).fetchone()
return _row_to_dict(row)
def has_active_cond_order(conn: sqlite3.Connection) -> bool:
ensure_cond_order_table(conn)
row = conn.execute(
f"""
SELECT 1 FROM options_cond_orders
WHERE status IN ({",".join("?" * len(ACTIVE_STATUSES))})
LIMIT 1
""",
ACTIVE_STATUSES,
).fetchone()
return row is not None
def cond_order_blocks_manual_open_msg(conn: sqlite3.Connection) -> str | None:
if has_active_cond_order(conn):
return "中控条件单占用中,禁止手动开仓"
return None
def machine_has_option_position(cfg: dict[str, Any], ex: Any) -> bool:
try:
raw = cfg["fetch_option_positions"](ex)
except Exception:
return False
if not raw:
return False
from lib.options.options_position_limit_lib import count_live_option_positions
return count_live_option_positions(raw) > 0
def create_cond_order(
conn: sqlite3.Connection,
payload: dict[str, Any],
*,
send_wechat: Any = None,
account_label: str = "",
) -> dict[str, Any]:
"""接收中控下发;同机仅允许一张活跃条件单."""
ensure_cond_order_table(conn)
hub_order_id = (payload.get("hub_order_id") or "").strip()
if not hub_order_id:
return {"ok": False, "msg": "缺少 hub_order_id"}
existing = get_cond_order_by_hub_id(conn, hub_order_id)
if existing:
return {"ok": True, "msg": "条件单已存在", "order": existing, "deduped": True}
if has_active_cond_order(conn):
return {"ok": False, "msg": "本机已有活跃条件单"}
underlying = (payload.get("underlying") or "ETH").strip().upper() or "ETH"
try:
from lib.options.options_underly_lib import resolve_default_underly, underly_not_allowed_msg
if not (payload.get("underlying") or "").strip():
underlying = resolve_default_underly()
uly_block = underly_not_allowed_msg(underlying)
if uly_block:
return {"ok": False, "msg": uly_block}
except Exception:
pass
opt_type = (payload.get("opt_type") or "P").strip().upper()
if opt_type not in ("P", "C"):
return {"ok": False, "msg": "opt_type 须为 P 或 C"}
# 左侧单:看跌等上穿、看涨等下穿;忽略中控传入的 trigger_side
trigger_side = "gte" if opt_type == "P" else "lte"
trigger_px = _safe_float(payload.get("trigger_px"))
if trigger_px is None or trigger_px <= 0:
return {"ok": False, "msg": "trigger_px 无效"}
deviation_pct = _safe_float(payload.get("deviation_pct"))
if deviation_pct is None or deviation_pct <= 0:
return {"ok": False, "msg": "deviation_pct 须 > 0"}
min_expiry_hours = _safe_float(payload.get("min_expiry_hours"))
if min_expiry_hours is None or min_expiry_hours <= 0:
return {"ok": False, "msg": "min_expiry_hours 须 > 0"}
exit_cfg = payload.get("exit") if isinstance(payload.get("exit"), dict) else {}
target_index = _safe_float(exit_cfg.get("target_index") or payload.get("target_index"))
pe_en = bool(exit_cfg.get("profit_exit_enabled") or payload.get("profit_exit_enabled"))
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
pe_mult = normalize_profit_exit_mult(
exit_cfg.get("profit_exit_mult", payload.get("profit_exit_mult")), default=1.0
)
if target_index is not None and target_index <= 0:
return {"ok": False, "msg": "target_index 无效"}
if not pe_en and target_index is None:
return {"ok": False, "msg": "请至少配置目标位或翻倍出场"}
now = _now()
conn.execute(
"""
INSERT INTO options_cond_orders (
hub_order_id, underlying, opt_type, trigger_side, trigger_px, deviation_pct,
min_expiry_hours, target_index, profit_exit_enabled, profit_exit_mult,
status, message, created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?, 'armed', ?, ?, ?)
""",
(
hub_order_id,
underlying,
opt_type,
trigger_side,
float(trigger_px),
float(deviation_pct),
float(min_expiry_hours),
float(target_index) if target_index is not None else None,
1 if pe_en else 0,
float(pe_mult),
"已接收,待命穿越",
now,
now,
),
)
conn.commit()
order = get_cond_order_by_hub_id(conn, hub_order_id)
_notify_cond(
send_wechat,
title="【OKX期权·条件单下发】",
order=order or {},
account_label=account_label,
extra=[
f"方向:{'Put 上穿' if opt_type == 'P' else 'Call 下穿'}",
f"偏离:{deviation_pct:g}%",
f"最短到期:{float(min_expiry_hours):g}h",
"状态:待命",
],
)
return {"ok": True, "msg": "条件单已 armed", "order": order}
def cancel_cond_order(
conn: sqlite3.Connection,
hub_order_id: str,
*,
send_wechat: Any = None,
account_label: str = "",
) -> dict[str, Any]:
order = get_cond_order_by_hub_id(conn, hub_order_id)
if not order:
return {"ok": False, "msg": "条件单不存在"}
if order.get("status") not in ACTIVE_STATUSES:
return {"ok": True, "msg": "条件单已结束", "order": order}
if order.get("status") == "open":
return {"ok": False, "msg": "已开仓,请用平仓/出场结束;取消仅适用于未成交条件单"}
_end_order(
conn,
order,
end_reason="cancel",
message="中控取消",
send_wechat=send_wechat,
account_label=account_label,
)
return {"ok": True, "msg": "已取消", "order": get_cond_order_by_hub_id(conn, hub_order_id)}
def update_cond_order_exit(
conn: sqlite3.Connection,
hub_order_id: str,
*,
target_index: Any = None,
profit_exit_enabled: Any = None,
profit_exit_mult: Any = None,
) -> dict[str, Any]:
"""成交后改出场;若正在平仓挂单则先记 pending,本轮结束后再套用."""
order = get_cond_order_by_hub_id(conn, hub_order_id)
if not order:
return {"ok": False, "msg": "条件单不存在"}
if order.get("status") != "open":
return {"ok": False, "msg": "仅持仓中的条件单可改出场"}
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
new_target = _safe_float(target_index) if target_index is not None else order.get("target_index")
if target_index is not None and str(target_index).strip() == "":
new_target = None
if new_target is not None and new_target <= 0:
return {"ok": False, "msg": "target_index 无效"}
if profit_exit_enabled is None:
pe_en = bool(order.get("profit_exit_enabled"))
else:
pe_en = bool(profit_exit_enabled)
pe_mult = normalize_profit_exit_mult(
profit_exit_mult if profit_exit_mult is not None else order.get("profit_exit_mult"),
default=1.0,
)
if not pe_en and new_target is None:
return {"ok": False, "msg": "请至少配置目标位或翻倍出场"}
closing = _exit_close_in_progress(conn, str(order.get("opened_inst_id") or ""))
now = _now()
if closing:
conn.execute(
"""
UPDATE options_cond_orders SET
pending_target_index = ?,
pending_profit_exit_enabled = ?,
pending_profit_exit_mult = ?,
exit_update_pending = 1,
message = ?,
updated_at = ?
WHERE hub_order_id = ?
""",
(
float(new_target) if new_target is not None else None,
1 if pe_en else 0,
float(pe_mult),
"出场已更新,等待本轮平仓挂单结束后生效",
now,
hub_order_id,
),
)
conn.commit()
return {
"ok": True,
"msg": "已记录,本轮平仓结束后生效",
"deferred": True,
"order": get_cond_order_by_hub_id(conn, hub_order_id),
}
_apply_exit_to_position(
conn,
order,
target_index=new_target,
profit_exit_enabled=pe_en,
profit_exit_mult=pe_mult,
)
conn.execute(
"""
UPDATE options_cond_orders SET
target_index = ?,
profit_exit_enabled = ?,
profit_exit_mult = ?,
exit_update_pending = 0,
pending_target_index = NULL,
pending_profit_exit_enabled = NULL,
pending_profit_exit_mult = NULL,
message = ?,
updated_at = ?
WHERE hub_order_id = ?
""",
(
float(new_target) if new_target is not None else None,
1 if pe_en else 0,
float(pe_mult),
"出场已更新并生效",
now,
hub_order_id,
),
)
conn.commit()
return {"ok": True, "msg": "出场已更新", "order": get_cond_order_by_hub_id(conn, hub_order_id)}
def _exit_close_in_progress(conn: sqlite3.Connection, inst_id: str) -> bool:
if not inst_id:
return False
try:
from lib.options.options_target_lib import list_closing_targets
for t in list_closing_targets(conn):
if str(t.get("inst_id") or "") == inst_id:
return True
except Exception:
pass
try:
row = conn.execute(
"""
SELECT profit_exit_state FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC LIMIT 1
""",
(inst_id,),
).fetchone()
if row and str(row["profit_exit_state"] or "") == "closing":
return True
except Exception:
pass
return False
def _apply_exit_to_position(
conn: sqlite3.Connection,
order: dict[str, Any],
*,
target_index: float | None,
profit_exit_enabled: bool,
profit_exit_mult: float,
) -> None:
inst_id = str(order.get("opened_inst_id") or "").strip()
if not inst_id:
return
from lib.options.options_profit_exit_lib import set_profit_exit
from lib.options.options_target_lib import cancel_target_monitor, upsert_target_monitor
set_profit_exit(conn, inst_id=inst_id, enabled=profit_exit_enabled, mult=profit_exit_mult)
if target_index is not None:
upsert_target_monitor(
conn,
inst_id=inst_id,
target_index=float(target_index),
underlying=str(order.get("underlying") or ""),
opt_type=str(order.get("opt_type") or ""),
)
else:
cancel_target_monitor(conn, inst_id=inst_id)
conn.commit()
def _end_order(
conn: sqlite3.Connection,
order: dict[str, Any],
*,
end_reason: str,
message: str,
send_wechat: Any = None,
account_label: str = "",
index_px: float | None = None,
) -> None:
now = _now()
conn.execute(
"""
UPDATE options_cond_orders SET
status = 'done',
end_reason = ?,
message = ?,
updated_at = ?,
ended_at = ?
WHERE hub_order_id = ?
""",
(end_reason, message, now, now, order["hub_order_id"]),
)
conn.commit()
if callable(send_wechat):
try:
reason_map = {
"leave_up": "上破偏离区结束",
"leave_down": "下破偏离区结束",
"cancel": "中控取消",
"timeout": "周四23:00未触发自动取消",
"closed": "出场完成",
}
_notify_cond(
send_wechat,
title="【OKX期权·条件单结束】",
order=order,
account_label=account_label,
extra=[
f"原因:{reason_map.get(end_reason, end_reason)}",
f"说明:{message}",
]
+ ([f"当前指数:{index_px:g}"] if index_px is not None else []),
)
except Exception:
pass
def band_bounds(trigger_px: float, deviation_pct: float) -> tuple[float, float]:
d = max(0.0, float(deviation_pct)) / 100.0
lo = float(trigger_px) * (1.0 - d)
hi = float(trigger_px) * (1.0 + d)
return lo, hi
def pick_nearest_itm_contract(
cfg: dict[str, Any],
ex: Any,
*,
underlying: str,
opt_type: str,
index_px: float,
min_expiry_hours: float,
) -> dict[str, Any] | None:
"""按当前指数选最近一档实值;到期 ≥ min_expiry_hours."""
from lib.exchange.okx_options_lib import build_option_chain, option_buy_liquidity_ok
from lib.options.options_pricing_lib import option_moneyness
max_dte_days = max(float(min_expiry_hours) / 24.0 + 14.0, 14.0)
# 放宽实值距离:取全部 ITM,再按距离排序
chain = build_option_chain(
ex,
underlying,
max_dte_days=max_dte_days,
itm_only=False,
itm_max_dist_usd=1e12,
index_px=index_px,
)
min_ms = time.time() * 1000 + float(min_expiry_hours) * 3600 * 1000
want = (opt_type or "P").upper()
candidates: list[tuple[float, dict[str, Any]]] = []
expiries = chain.get("expiries") or []
# build_option_chain 返回 list[{exp_time, contracts}]
if isinstance(expiries, dict):
exp_iter = [{"exp_time": k, "contracts": v} for k, v in expiries.items()]
else:
exp_iter = list(expiries)
for bucket in exp_iter:
try:
exp_ms = int(bucket.get("exp_time") or 0)
except (TypeError, ValueError):
exp_ms = 0
if exp_ms and exp_ms < min_ms:
continue
for c in bucket.get("contracts") or []:
if str(c.get("opt_type") or "").upper() != want:
continue
strike = _safe_float(c.get("strike"))
if strike is None:
continue
mny = option_moneyness(opt_type=want, strike=strike, index_px=index_px)
if mny != "itm":
continue
dist = abs(float(strike) - float(index_px))
candidates.append((dist, c))
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], str(x[1].get("inst_id") or "")))
# 优先有卖一深度的最近档
for _dist, c in candidates[:12]:
inst = str(c.get("inst_id") or "")
if not inst:
continue
try:
q = cfg["quote_option_contract"](ex, inst)
except Exception:
continue
ask = q.get("ask")
ask_sz = q.get("ask_sz")
ok, _ = option_buy_liquidity_ok(ask, ask_sz)
if ok:
out = dict(c)
out["quote"] = q
return out
# 深度都不够时仍返回最近档,由上层继续等
best = dict(candidates[0][1])
return best
def _fetch_index(cfg: dict[str, Any], ex: Any, underlying: str) -> float | None:
from lib.exchange.okx_options_lib import fetch_index_price
uly = f"{(underlying or 'ETH').upper()}-USD"
try:
return fetch_index_price(ex, uly)
except Exception:
return None
def _try_open_full(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
order: dict[str, Any],
) -> dict[str, Any]:
from lib.options.options_coin_open_lib import open_coin_option_buy_full
note = f"中控条件单:{order.get('hub_order_id')}"
target_index = _safe_float(order.get("target_index"))
pe_en = bool(order.get("profit_exit_enabled"))
pe_mult = float(order.get("profit_exit_mult") or 1.0)
# USDC 期权开仓已下线,条件单仅走币本位
return open_coin_option_buy_full(
cfg,
ex,
inst_id=inst_id,
signal_note=note,
target_index=target_index,
profit_exit_enabled=pe_en,
profit_exit_mult=pe_mult,
target_sheets=None,
)
def run_cond_orders_tick(
conn_or_get_db: Any,
cfg: dict[str, Any],
*,
send_wechat: Any = None,
account_label: str = "",
) -> None:
"""监控循环单步:周四未触发取消 / 穿越 / 偏离区 / 开仓 / 出场收尾.
推荐传 get_db:开仓前会关闭连接,避免与买币/等待成交嵌套锁库。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
if get_db is None:
conn = conn_or_get_db
ensure_cond_order_table(conn)
expire_untriggered_cond_orders(
conn, send_wechat=send_wechat, account_label=account_label
)
ex = cfg.get("exchange_options")
if ex is None:
return
for order in list_cond_orders(conn, active_only=True):
try:
_tick_one(
conn,
cfg,
ex,
order,
send_wechat=send_wechat,
account_label=account_label,
get_db=None,
)
except Exception as e:
try:
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"监控异常: {e}"[:200], _now(), order["hub_order_id"]),
)
conn.commit()
except Exception:
pass
return
ex: Any = None
orders: list[dict[str, Any]] = []
conn = get_db()
try:
ensure_cond_order_table(conn)
expire_untriggered_cond_orders(
conn, send_wechat=send_wechat, account_label=account_label
)
ex = cfg.get("exchange_options")
if ex is not None:
orders = list_cond_orders(conn, active_only=True)
try:
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
if ex is None or not orders:
return
for order in orders:
conn = get_db()
try:
_tick_one(
conn,
cfg,
ex,
order,
send_wechat=send_wechat,
account_label=account_label,
get_db=get_db,
)
try:
conn.commit()
except Exception:
pass
except Exception as e:
try:
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"监控异常: {e}"[:200], _now(), order["hub_order_id"]),
)
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
def _tick_one(
conn: sqlite3.Connection,
cfg: dict[str, Any],
ex: Any,
order: dict[str, Any],
*,
send_wechat: Any,
account_label: str,
get_db: Any = None,
) -> None:
status = str(order.get("status") or "")
underlying = str(order.get("underlying") or "ETH")
idx = _fetch_index(cfg, ex, underlying)
if idx is None or idx <= 0:
return
last = _safe_float(order.get("last_index"))
conn.execute(
"UPDATE options_cond_orders SET last_index=?, updated_at=? WHERE hub_order_id=?",
(float(idx), _now(), order["hub_order_id"]),
)
conn.commit()
trigger_px = float(order["trigger_px"])
lo, hi = band_bounds(trigger_px, float(order["deviation_pct"]))
side = str(order.get("trigger_side") or "gte")
if status == "armed":
crossed = False
if side == "gte" and last is not None and last < trigger_px and idx >= trigger_px:
crossed = True
elif side == "lte" and last is not None and last > trigger_px and idx <= trigger_px:
crossed = True
# 首次无 last:只记录,等下一跳再判穿越(避免挂单时已在上方立刻开)
if last is None:
return
if crossed:
conn.execute(
"""
UPDATE options_cond_orders SET status='triggering', crossed_at=?, message=?, updated_at=?
WHERE hub_order_id=?
""",
(_now(), f"已穿越 {trigger_px:g},等待偏离区内开仓", _now(), order["hub_order_id"]),
)
conn.commit()
order["status"] = "triggering"
status = "triggering"
else:
return
if status == "triggering":
if idx > hi:
_end_order(
conn,
order,
end_reason="leave_up",
message=f"指数 {idx:g} 上破偏离上沿 {hi:g}",
send_wechat=send_wechat,
account_label=account_label,
index_px=idx,
)
return
if idx < lo:
_end_order(
conn,
order,
end_reason="leave_down",
message=f"指数 {idx:g} 下破偏离下沿 {lo:g}",
send_wechat=send_wechat,
account_label=account_label,
index_px=idx,
)
return
# 区内:有仓则不再开
if machine_has_option_position(cfg, ex):
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
("偏离区内但已有持仓,等待", _now(), order["hub_order_id"]),
)
conn.commit()
return
picked = pick_nearest_itm_contract(
cfg,
ex,
underlying=underlying,
opt_type=str(order.get("opt_type") or "P"),
index_px=idx,
min_expiry_hours=float(order.get("min_expiry_hours") or 24),
)
if not picked or not picked.get("inst_id"):
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
("偏离区内暂无合适实值合约", _now(), order["hub_order_id"]),
)
conn.commit()
return
inst_id = str(picked["inst_id"])
# 深度检查
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
q = picked.get("quote") or cfg["quote_option_contract"](ex, inst_id)
ok_liq, msg_liq = option_buy_liquidity_ok(q.get("ask"), q.get("ask_sz"))
if not ok_liq:
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"深度不足继续等待: {msg_liq or '无卖一'}", _now(), order["hub_order_id"]),
)
conn.commit()
return
# 开仓(买币/等成交)前释放本连接,避免嵌套锁库
try:
conn.commit()
except Exception:
pass
hub_id = order["hub_order_id"]
if get_db is not None:
try:
conn.close()
except Exception:
pass
conn = None
result = _try_open_full(cfg, ex, inst_id=inst_id, order=order)
if get_db is not None:
conn = get_db()
ensure_cond_order_table(conn)
assert conn is not None
if not result.get("ok"):
conn.execute(
"UPDATE options_cond_orders SET message=?, updated_at=? WHERE hub_order_id=?",
(f"开仓未成继续等: {result.get('msg') or '失败'}"[:180], _now(), hub_id),
)
conn.commit()
return
trade_id = result.get("trade_id")
conn.execute(
"""
UPDATE options_cond_orders SET
status='open', opened_inst_id=?, opened_trade_id=?,
opened_at=COALESCE(opened_at, ?),
message=?, updated_at=?
WHERE hub_order_id=?
""",
(
inst_id,
int(trade_id) if trade_id is not None else None,
_now(),
f"已开仓 {inst_id}",
_now(),
hub_id,
),
)
conn.commit()
opened = get_cond_order_by_hub_id(conn, order["hub_order_id"]) or order
_notify_cond(
send_wechat,
title="【OKX期权·条件单开仓】",
order=opened,
account_label=account_label,
extra=[
f"合约:{inst_id}",
f"说明:已开仓 {inst_id}",
],
)
order2 = opened
order2["opened_inst_id"] = inst_id
_apply_exit_to_position(
conn,
order2,
target_index=_safe_float(order2.get("target_index")),
profit_exit_enabled=bool(order2.get("profit_exit_enabled")),
profit_exit_mult=float(order2.get("profit_exit_mult") or 1.0),
)
return
if status == "open":
# 延迟出场更新
if order.get("exit_update_pending") and not _exit_close_in_progress(
conn, str(order.get("opened_inst_id") or "")
):
pe_en = order.get("pending_profit_exit_enabled")
if pe_en is None:
pe_en = order.get("profit_exit_enabled")
_apply_exit_to_position(
conn,
order,
target_index=_safe_float(order.get("pending_target_index")),
profit_exit_enabled=bool(pe_en),
profit_exit_mult=float(
order.get("pending_profit_exit_mult")
if order.get("pending_profit_exit_mult") is not None
else order.get("profit_exit_mult")
or 1.0
),
)
conn.execute(
"""
UPDATE options_cond_orders SET
target_index = pending_target_index,
profit_exit_enabled = COALESCE(pending_profit_exit_enabled, profit_exit_enabled),
profit_exit_mult = COALESCE(pending_profit_exit_mult, profit_exit_mult),
exit_update_pending = 0,
pending_target_index = NULL,
pending_profit_exit_enabled = NULL,
pending_profit_exit_mult = NULL,
message = ?,
updated_at = ?
WHERE hub_order_id = ?
""",
("延迟出场已生效", _now(), order["hub_order_id"]),
)
conn.commit()
inst_id = str(order.get("opened_inst_id") or "")
if not inst_id:
return
if not machine_has_option_position(cfg, ex):
# 确认该合约已平
raw = cfg["fetch_option_positions"](ex) or []
still = any(str(p.get("instId") or "") == inst_id for p in raw)
if still:
return
_end_order(
conn,
order,
end_reason="closed",
message="持仓已平,条件单完成",
send_wechat=send_wechat,
account_label=account_label,
index_px=idx,
)
+53
View File
@@ -95,6 +95,19 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ON options_target_monitors(status)
"""
)
# 实盘统计:按期权历史 history_key 冻结交易所盈亏折 U,避免跟实时指数漂移
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_exchange_pnl_freeze (
history_key TEXT PRIMARY KEY,
realized_pnl REAL,
realized_pnl_usdt REAL,
close_index_px REAL,
premium_ccy TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
@@ -109,6 +122,19 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
"ALTER TABLE options_trades ADD COLUMN close_recv_accum REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_sheets_accum INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_notional_accum REAL DEFAULT 0",
# 手续费(权利金单位)与平仓冻结 U 口径
"ALTER TABLE options_trades ADD COLUMN open_fee REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_fee REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN fee_ccy TEXT",
"ALTER TABLE options_trades ADD COLUMN close_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN open_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt REAL",
# 交易账户快照盈亏(一次一仓):before→after 差分冻结
"ALTER TABLE options_trades ADD COLUMN trading_usdt_before REAL",
"ALTER TABLE options_trades ADD COLUMN trading_usdt_after REAL",
"ALTER TABLE options_trades ADD COLUMN pnl_settled INTEGER DEFAULT 0",
# 初始权利金(U)=开仓指数×权利金币量,不含多买缓冲
"ALTER TABLE options_trades ADD COLUMN initial_premium_usdt REAL",
):
try:
conn.execute(ddl)
@@ -120,6 +146,12 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ensure_bridge_table(conn)
except Exception:
pass
try:
from lib.options.options_cond_order_lib import ensure_cond_order_table
ensure_cond_order_table(conn)
except Exception:
pass
init_options_review_tables(conn)
@@ -142,6 +174,27 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
return round(float(row["total"] or 0), 8)
def sum_open_budget_usdt(conn: sqlite3.Connection, inst_id: str) -> float | None:
"""同合约 open 腿开仓预算/兑换占用 USDT 合计(权利金折 U 展示用,不随币价变)."""
inst = (inst_id or "").strip()
if not inst:
return None
row = conn.execute(
"""
SELECT SUM(budget_usdt) AS total, COUNT(*) AS n
FROM options_trades
WHERE inst_id = ? AND status = 'open' AND budget_usdt IS NOT NULL
""",
(inst,),
).fetchone()
if not row or int(row["n"] or 0) < 1:
return None
try:
return round(float(row["total"] or 0), 4)
except (TypeError, ValueError):
return None
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
"""同合约所有 open 腿张数合计."""
inst = (inst_id or "").strip()
+46 -16
View File
@@ -29,6 +29,25 @@ def enrich_position_row_display(
if premium_override is not None:
row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
try:
from lib.options.options_pricing_lib import equivalent_contract_leverage
def _sf(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
row["contract_leverage"] = equivalent_contract_leverage(
index_px=_sf(row.get("idx_px")),
eth_amount=_sf(row.get("eth_amount")),
total_premium=_sf(row.get("premium_paid")),
margin_mode=row_mode,
)
except Exception:
row.setdefault("contract_leverage", None)
return row
@@ -48,6 +67,8 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
if raw_live is None:
return []
prem_by_inst: dict[str, float | None] = {}
hidden_keys: set[str] = set()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
@@ -57,25 +78,34 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
}
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
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))
if inst:
prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = prem_by_inst.get(inst)
row = enrich_position_row_display(
cfg,
ex,
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)
for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip()
+43 -3
View File
@@ -23,7 +23,10 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = build_display_option_positions(cfg, ex, raw)
# 中控需同步实例买一/流动性;盘口在 DB 已释放后拉取(见 build_display_option_positions)
positions = build_display_option_positions(
cfg, ex, raw, include_close_preview=True
)
target_monitors: list[dict[str, Any]] = []
try:
conn = cfg["get_db"]()
@@ -83,6 +86,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
for p in positions:
# 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_usdt_from_option_row(p)
p["display_pnl_usdt"] = pnl
if pnl is None:
continue
has_upl = True
@@ -129,7 +133,10 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
except Exception:
open_bridges = []
return {
from lib.options.options_underly_lib import underly_cfg_snapshot
_uly_snap = underly_cfg_snapshot()
out = {
"ok": True,
"enabled": True,
"positions": positions,
@@ -148,10 +155,43 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
"max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH",
"options_underly": _uly_snap["default_underly"],
"options_allowed_underlys": _uly_snap["allowed_underlys"],
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
"cond_order_locked": False,
"cond_orders_active": [],
"transfer_blocked": False,
"transfer_block_msg": "",
}
try:
from lib.options.options_cond_order_lib import has_active_cond_order, list_cond_orders
conn_c = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn_c)
out["cond_order_locked"] = has_active_cond_order(conn_c)
out["cond_orders_active"] = list_cond_orders(conn_c, active_only=True)
finally:
conn_c.close()
except Exception:
pass
try:
from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg
conn_tb = cfg["get_db"]()
try:
tmsg = transfer_blocked_by_position_msg(conn_tb, ex)
if tmsg:
out["transfer_blocked"] = True
out["transfer_block_msg"] = tmsg
finally:
conn_tb.close()
except Exception:
pass
return out
except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)}
+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:
"""返回 usdc | coin;未配置时默认币本位."""
if raw is None:
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
v = str(raw or MODE_COIN).strip().lower()
if v in ("usdc", "usdc_margin", "usd_margin", "u本位", "u"):
return MODE_USDC
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
return MODE_COIN
# 空串或未知值:默认币本位
if not v:
return MODE_COIN
"""币本位专用机:始终 coin;历史 usdc 配置读取时亦归一为 coin."""
del raw
return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool:
return normalize_options_margin_mode(raw) == MODE_COIN
del raw
return True
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
"""仅币本位期权族 {U}-USD;USD_UM 已下线."""
del margin_mode
u = (underlying or "ETH").strip().upper() or "ETH"
mode = normalize_options_margin_mode(margin_mode)
if mode == MODE_COIN:
return f"{u}-USD"
return f"{u}-USD_UM"
return f"{u}-USD"
def margin_mode_from_inst_id(inst_id: str) -> str:
@@ -63,9 +54,10 @@ def margin_mode_from_inst_id(inst_id: str) -> str:
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
return (underlying or "ETH").strip().upper() or "ETH"
return "USDC"
"""币本位权利金为标的币;历史 USDC 行(margin_mode=usdc)仍标 USDC."""
if str(margin_mode or "").strip().lower() == MODE_USDC:
return "USDC"
return (underlying or "ETH").strip().upper() or "ETH"
def spot_quote_inst_id(underlying: str) -> str:
@@ -90,9 +82,9 @@ def compute_coin_budget_usdt(
"""
bal = max(0.0, float(trading_usdt or 0))
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 1.0)
if buf <= 0:
buf = 0.95
buf = 1.0
fixed = (
float(fixed_budget_usdt)
if fixed_budget_usdt is not None
@@ -137,18 +129,18 @@ def compute_coin_budget_usdt(
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.05(=多买 5%).
也可写 0.05 表示 +5%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.05)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
v = 1.05
if v <= 0:
return 1.10
return 1.05
if v < 1.0:
return 1.0 + v
return v
+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 ""),
)
try:
# 推送前先提交,避免握锁等 webhook
try:
conn.commit()
except Exception:
pass
send_wechat(msg)
conn.execute(
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
tuple(bucket["ids"]),
)
# 立即提交,避免后续目标/翻倍/同步异常回滚后每轮重推
try:
conn.commit()
except Exception:
@@ -183,7 +187,8 @@ def backfill_closed_options_realized_pnl_from_history(
rows = conn.execute(
"""
SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote,
created_at, closed_at, close_ord_id
created_at, closed_at, close_ord_id, realized_pnl_usdt,
open_fee, close_fee, premium_ccy, underlying
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
@@ -194,8 +199,16 @@ def backfill_closed_options_realized_pnl_from_history(
if not rows:
return 0
from lib.options.options_pnl_settle_lib import (
resolve_close_index_px,
settle_exchange_realized_pnl,
)
groups: dict[str, list[Any]] = {}
for row in rows:
# 已冻结 U 的本地结算不再被交易所历史覆盖
if _safe_float(row["realized_pnl_usdt"]) is not None:
continue
inst = str(row["inst_id"] or "").strip()
if not inst or inst not in by_inst:
continue
@@ -229,6 +242,8 @@ def backfill_closed_options_realized_pnl_from_history(
total_paid = 0.0
for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
underly = str(group[0]["underlying"] or "").strip()
close_idx = resolve_close_index_px(None, inst_id=inst, underlying=underly)
allocated = 0.0
for i, r in enumerate(group):
paid = float(_safe_float(r["premium_paid"]) or 0.0)
@@ -250,17 +265,37 @@ def backfill_closed_options_realized_pnl_from_history(
ord_ok = (not matched_pos) or (local_ord == matched_pos)
if pnl_ok and quote_ok and ord_ok:
continue
prem_recv = round(paid + share, 4)
# OKX realizedPnl 已含手续费,只冻结折 U,禁止再减 open_fee/close_fee
prem_ccy = str(r["premium_ccy"] or "").strip() or "ETH"
settled = settle_exchange_realized_pnl(
realized_pnl=share,
premium_ccy=prem_ccy,
index_px=close_idx,
)
# 展示用收回≈已付+净盈亏+已记手续费(毛额近似,不参与再扣费)
ofee = float(_safe_float(r["open_fee"]) or 0.0)
cfee = float(_safe_float(r["close_fee"]) or 0.0)
prem_recv = round(paid + share + ofee + cfee, 4)
conn.execute(
"""
UPDATE options_trades
SET realized_pnl = ?,
premium_received = ?,
close_quote = COALESCE(?, close_quote),
close_ord_id = COALESCE(?, close_ord_id)
WHERE id = ?
close_ord_id = COALESCE(?, close_ord_id),
close_index_px = COALESCE(?, close_index_px),
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt)
WHERE id = ? AND realized_pnl_usdt IS NULL
""",
(share, prem_recv, close_quote, matched_pos, int(r["id"])),
(
settled.get("realized_pnl"),
prem_recv,
close_quote,
matched_pos,
settled.get("close_index_px"),
settled.get("realized_pnl_usdt"),
int(r["id"]),
),
)
updated += 1
return updated
@@ -279,13 +314,21 @@ def sync_open_options_trades(
"""
rows = conn.execute(
"""
SELECT id, inst_id, premium_paid, exp_time, created_at
SELECT id, inst_id, premium_paid, exp_time, created_at,
open_fee, premium_ccy, underlying, exchange_ord_id,
budget_usdt, open_index_px, fee_ccy
FROM options_trades
WHERE status = 'open'
"""
).fetchall()
updated = 0
closed_inst_ids: list[str] = []
now_ms = int(time.time() * 1000)
from lib.options.options_pnl_settle_lib import (
resolve_close_index_px,
settle_closed_pnl,
)
for row in rows:
inst_id = str(row["inst_id"] or "")
if not inst_id or inst_id in live_inst_ids:
@@ -308,8 +351,6 @@ def sync_open_options_trades(
close_quote = close_info.get("close_quote")
realized_pnl = close_info.get("realized_pnl")
close_ord_id = close_info.get("pos_id")
if realized_pnl is not None:
prem_recv = round(paid + float(realized_pnl), 4)
close_ms = close_info.get("close_ms")
if close_ms:
closed_at = ms_to_options_app_str(close_ms)
@@ -326,6 +367,44 @@ def sync_open_options_trades(
if not closed_at:
closed_at = options_app_now_str()
open_fee = float(_safe_float(row["open_fee"]) or 0.0)
prem_ccy = str(row["premium_ccy"] or "").strip() or "ETH"
underly = str(row["underlying"] or "").strip()
close_idx = resolve_close_index_px(None, inst_id=inst_id, underlying=underly)
budget = _safe_float(row["budget_usdt"])
open_idx = _safe_float(row["open_index_px"])
fee_ccy = str(row["fee_ccy"] or prem_ccy or "")
if close_reason == "exchange" and realized_pnl is not None:
# OKX realizedPnl 已含费 → 反推毛收回,再按开仓成本折 U(不再二次扣费)
prem_recv = round(paid + float(realized_pnl) + open_fee, 4)
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=prem_recv,
open_fee=open_fee,
close_fee=0,
premium_ccy=prem_ccy,
index_px=close_idx,
open_index_px=open_idx,
budget_usdt=budget,
fee_ccy=fee_ccy,
)
else:
if prem_recv is None:
prem_recv = 0.0
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=prem_recv,
open_fee=open_fee,
close_fee=0,
premium_ccy=prem_ccy,
index_px=close_idx,
open_index_px=open_idx,
budget_usdt=budget,
fee_ccy=fee_ccy,
)
realized_pnl = settled.get("realized_pnl")
pnl_usdt = settled.get("realized_pnl_usdt")
conn.execute(
"""
UPDATE options_trades
@@ -335,6 +414,10 @@ def sync_open_options_trades(
realized_pnl = ?,
close_ord_id = COALESCE(?, close_ord_id),
closed_at = COALESCE(?, closed_at),
close_index_px = COALESCE(?, close_index_px),
open_index_px = COALESCE(open_index_px, ?),
realized_pnl_usdt = COALESCE(?, realized_pnl_usdt),
fee_ccy = COALESCE(fee_ccy, ?),
signal_note = CASE
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
THEN '到期结算'
@@ -348,11 +431,16 @@ def sync_open_options_trades(
realized_pnl,
close_ord_id,
closed_at,
settled.get("close_index_px"),
settled.get("open_index_px"),
pnl_usdt,
prem_ccy,
close_reason,
int(row["id"]),
),
)
updated += 1
closed_inst_ids.append(inst_id)
if notify_cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
@@ -371,9 +459,80 @@ def sync_open_options_trades(
)
except Exception:
pass
if notify_cfg is not None:
_notify_recent_unsent_expiry_closes(conn, notify_cfg)
# 卖币不在此处做:调用方须先 commit/close 本连接,再调 sell_spot_after_options_sync,
# 否则监控线程握锁时中控快照会超时,表现为全部离线。
return updated
def sell_spot_after_options_sync(
cfg: dict[str, Any] | None,
closed_inst_ids: list[str] | None = None,
) -> None:
"""在释放业务 DB 连接后调用:同步关仓/到期后的币本位卖回."""
if not isinstance(cfg, dict):
return
ex = cfg.get("exchange_options")
if ex is None:
return
try:
from lib.options.options_coin_open_lib import (
maybe_sell_spot_after_close,
sweep_spot_after_options_flat,
)
seen: set[str] = set()
for inst_id in closed_inst_ids or []:
iid = str(inst_id or "").strip()
if not iid or iid in seen:
continue
seen.add(iid)
try:
maybe_sell_spot_after_close(cfg, ex, inst_id=iid)
except Exception:
pass
sweep_spot_after_options_flat(cfg, ex)
except Exception:
pass
def _notify_recent_unsent_expiry_closes(
conn: sqlite3.Connection,
notify_cfg: dict[str, Any],
*,
within_hours: float = 48.0,
) -> None:
"""页面同步曾漏推时,补发近期到期结算."""
try:
from lib.options.options_notify_lib import notify_options_close_trade_ids
from lib.options.options_time_lib import options_wall_clock_to_ms
now_ms = int(time.time() * 1000)
by_inst: dict[str, list[int]] = {}
rows = conn.execute(
"""
SELECT id, inst_id, closed_at
FROM options_trades
WHERE status = 'closed'
AND COALESCE(wechat_close_sent, 0) = 0
AND COALESCE(signal_note, '') = '到期结算'
ORDER BY id DESC
LIMIT 20
"""
).fetchall()
for r in rows:
closed_ms = options_wall_clock_to_ms(r["closed_at"])
if closed_ms is None or now_ms - int(closed_ms) > float(within_hours) * 3600 * 1000:
continue
inst = str(r["inst_id"] or "")
by_inst.setdefault(inst, []).append(int(r["id"]))
for ids in by_inst.values():
notify_options_close_trade_ids(notify_cfg, conn, ids, reason="到期结算")
except Exception:
pass
def reconcile_live_open_trades(
conn: sqlite3.Connection,
*,
@@ -440,6 +599,7 @@ def options_monitor_loop(
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
cond_order_tick_fn: Callable[[sqlite3.Connection], None] | None = None,
stop_event: Any = None,
) -> None:
if not enabled:
@@ -448,9 +608,25 @@ def options_monitor_loop(
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
break
try:
conn = get_db()
# 持仓拉取不握 DB,避免与模拟盘钱包写互相锁
try:
positions = fetch_positions()
except Exception:
positions = []
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
# 条件单自管连接(开仓前会释放)
if cond_order_tick_fn is not None:
try:
cond_order_tick_fn(None)
except Exception:
pass
conn = get_db()
try:
run_options_profit_alerts(
conn,
positions,
@@ -459,25 +635,37 @@ def options_monitor_loop(
account_label=account_label,
ticker_bid_fn=ticker_bid_fn,
)
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
if target_close_fn is not None:
from lib.options.options_target_lib import run_options_target_closes
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
# 目标/翻倍出场:传 get_db,平仓时不握业务连接
if target_close_fn is not None:
from lib.options.options_target_lib import run_options_target_closes
try:
run_options_target_closes(
conn,
get_db,
positions,
close_fn=target_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
)
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
except Exception:
pass
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
try:
run_options_profit_exits(
conn,
get_db,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
@@ -485,12 +673,18 @@ def options_monitor_loop(
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
if sync_trades_fn is not None:
sync_trades_fn(conn)
conn.commit()
finally:
conn.close()
# 平仓限价挂单超时撤单(独立于 DB 事务)
except Exception:
pass
if sync_trades_fn is not None:
try:
sync_trades_fn(None)
except Exception:
pass
try:
sell_spot_after_options_sync(pe_cfg)
except Exception:
pass
if stale_pending_fn is not None:
try:
stale_pending_fn()
+44
View File
@@ -58,6 +58,38 @@ def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
pass
_BOUND_NOTIFY_CFG: dict[str, Any] | None = None
def bind_options_notify_cfg(cfg: dict[str, Any] | None) -> None:
global _BOUND_NOTIFY_CFG
_BOUND_NOTIFY_CFG = cfg
def active_options_notify_cfg(cfg: dict[str, Any] | None = None) -> dict[str, Any]:
if cfg is not None and callable(cfg.get("send_wechat")):
return cfg
if _BOUND_NOTIFY_CFG is not None and callable(_BOUND_NOTIFY_CFG.get("send_wechat")):
return _BOUND_NOTIFY_CFG
import os
from lib.common.wechat_notify_lib import send_wechat_webhook
webhook = (os.getenv("WECHAT_WEBHOOK") or "").strip()
try:
timeout = int(float(os.getenv("WECHAT_TIMEOUT_SECONDS") or "10"))
except (TypeError, ValueError):
timeout = 10
label = (
os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or os.getenv("OKX_ACCOUNT_LABEL") or "OKX期权"
).strip()
def send(content: str) -> None:
send_wechat_webhook(webhook, content, timeout=timeout)
return {"send_wechat": send, "account_label": label}
def notify_options_send(cfg: dict[str, Any], content: str) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
@@ -200,6 +232,12 @@ def notify_options_open(
trade_id=trade_id,
premium_ccy=ccy,
)
# 推送前先提交,避免握锁等 webhook 导致模拟盘 database is locked
if conn is not None:
try:
conn.commit()
except Exception:
pass
ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None:
conn.execute(
@@ -304,6 +342,12 @@ def notify_options_close(
trade_id=head.get("id") if len(rows) == 1 else None,
premium_ccy=ccy,
)
# 推送企业微信前先提交,避免握锁等待 webhook(可达数秒)导致卖币/快照 database is locked
if conn is not None:
try:
conn.commit()
except Exception:
pass
ok = notify_options_send(cfg, msg)
if ok and conn is not None:
for r in pending or rows:
+477
View File
@@ -0,0 +1,477 @@
"""已实现盈亏结算:扣手续费,并在平仓时按指数冻结 U 口径."""
from __future__ import annotations
from typing import Any
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def fee_amount_abs(fee: Any) -> float:
"""OKX 扣费多为负;统一为正数「已付手续费」."""
f = _safe_float(fee)
if f is None:
return 0.0
return abs(float(f))
def fee_to_premium_ccy(
fee_amt: Any,
fee_ccy: Any,
*,
premium_ccy: str,
index_px: float | None,
) -> float:
"""把手续费折成权利金币种数量(与 premium_paid/received 同单位)."""
amt = fee_amount_abs(fee_amt)
if amt <= 0:
return 0.0
fc = str(fee_ccy or "").strip().upper()
pc = str(premium_ccy or "").strip().upper()
if not fc or not pc or fc == pc:
return round(amt, 8)
# 稳定币互兑近似 1:1
stables = {"USDT", "USDC", "USD"}
if fc in stables and pc in stables:
return round(amt, 8)
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return 0.0
if fc in stables and pc in ("ETH", "BTC"):
return round(amt / float(idx), 8)
if fc in ("ETH", "BTC") and pc in stables:
return round(amt * float(idx), 8)
if fc in ("ETH", "BTC") and pc in ("ETH", "BTC") and fc != pc:
# 跨币种无可靠汇率时放弃折算,避免脏数据
return 0.0
return round(amt, 8)
def extract_order_fee(order: dict[str, Any] | None) -> tuple[float, str]:
"""从下单/查单结果取手续费(原始币种,正数)."""
if not isinstance(order, dict):
return 0.0, ""
raw = order.get("raw") if isinstance(order.get("raw"), dict) else {}
info = order.get("info") if isinstance(order.get("info"), dict) else {}
fee = order.get("fee")
if fee is None:
fee = raw.get("fee")
if fee is None:
fee = info.get("fee")
ccy = (
order.get("fee_ccy")
or raw.get("feeCcy")
or raw.get("fee_ccy")
or info.get("fee_ccy")
or info.get("feeCcy")
or ""
)
return fee_amount_abs(fee), str(ccy or "").strip().upper()
def sum_option_order_fees_in_premium(
ex: Any,
*,
inst_id: str,
ord_ids: list[str] | str | None,
premium_ccy: str,
index_px: float | None,
fetch_order_fn: Any = None,
) -> float:
"""按订单号汇总手续费并折成权利金单位."""
if isinstance(ord_ids, str):
ids = [p.strip() for p in ord_ids.split(",") if p.strip()]
else:
ids = [str(x).strip() for x in (ord_ids or []) if str(x).strip()]
if not ids:
return 0.0
if fetch_order_fn is None:
from lib.exchange.okx_options_lib import fetch_option_order
fetch_order_fn = fetch_option_order
total = 0.0
seen: set[str] = set()
for oid in ids:
if oid in seen:
continue
seen.add(oid)
try:
info = fetch_order_fn(ex, inst_id=inst_id, ord_id=oid)
except Exception:
continue
if not isinstance(info, dict) or not info.get("ok"):
continue
fee_amt, fee_ccy = extract_order_fee(info)
total += fee_to_premium_ccy(
fee_amt, fee_ccy, premium_ccy=premium_ccy, index_px=index_px
)
return round(total, 8)
def freeze_pnl_to_usdt(
net_pnl: float,
*,
premium_ccy: str,
index_px: float | None,
total_fee: float = 0.0,
) -> dict[str, float | None]:
"""把已是「净额」的权利金盈亏冻结为 U(不再扣费)."""
net = round(float(net_pnl), 8)
pc = str(premium_ccy or "").strip().upper()
idx = _safe_float(index_px)
fee_total = abs(float(total_fee or 0.0))
if pc in ("ETH", "BTC"):
if idx is None or idx <= 0:
return {
"realized_pnl": net,
"close_index_px": None,
"realized_pnl_usdt": None,
"total_fee": round(fee_total, 8),
}
return {
"realized_pnl": net,
"close_index_px": float(idx),
"realized_pnl_usdt": round(net * float(idx), 4),
"total_fee": round(fee_total, 8),
}
return {
"realized_pnl": net,
"close_index_px": float(idx) if idx is not None and idx > 0 else None,
"realized_pnl_usdt": round(net, 4),
"total_fee": round(fee_total, 8),
}
def fee_to_usdt(
fee_amt: Any,
fee_ccy: Any,
*,
index_px: float | None,
) -> float:
"""手续费折 USDT."""
amt = fee_amount_abs(fee_amt)
if amt <= 0:
return 0.0
fc = str(fee_ccy or "").strip().upper()
if fc in ("USDT", "USDC", "USD", ""):
# 空币种时按稳定币近似,避免把小数额 ETH 费当成整 U
if fc == "" and amt < 0.05:
idx = _safe_float(index_px)
if idx is not None and idx > 100:
return round(amt * float(idx), 4)
return round(amt, 4)
if fc in ("ETH", "BTC"):
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return 0.0
return round(amt * float(idx), 4)
return round(amt, 4)
def true_pnl_usdt_premium_move(
*,
coin_pnl: Any,
premium_paid: Any = None,
sell_back_px: Any = None,
initial_premium_usdt: Any = None,
open_index_px: Any = None,
) -> float | None:
"""复盘真盈亏(U)=期权历史币盈亏×平仓价.
平仓价优先卖回 VWAP,否则用冻结/传入的 close 价.
premium_paid / initial_premium_usdt / open_index_px 仅兼容旧调用,不参与计算.
"""
del premium_paid, initial_premium_usdt, open_index_px
pnl = _safe_float(coin_pnl)
close_px = _safe_float(sell_back_px)
if pnl is None or close_px is None or close_px <= 0:
return None
return round(float(pnl) * float(close_px), 4)
def resolve_spot_vwap_prices(
conn: Any = None,
*,
bridge_id: Any = None,
open_index_px: Any = None,
close_index_px: Any = None,
) -> tuple[float | None, float | None, str]:
"""开仓价/平仓价解析(期权盈亏折U用).
平仓价:桥上卖回 VWAP > 冻结 close_index_px > 开仓价.
桥未关不再阻断折U(现货卖回与期权盈亏脱钩).
返回 (open_px, close_px, gate):
gate='' 可算; 'missing' 缺价
"""
open_px = _safe_float(open_index_px)
close_hint = _safe_float(close_index_px)
close_px = close_hint
bid = None
try:
if bridge_id is not None and str(bridge_id).strip() != "":
bid = int(bridge_id)
except (TypeError, ValueError):
bid = None
if conn is not None and bid is not None:
try:
from lib.options.options_spot_bridge_lib import (
bridge_buy_vwap,
bridge_sell_vwap,
ensure_bridge_table,
)
ensure_bridge_table(conn)
if open_px is None or open_px <= 0:
bv = bridge_buy_vwap(conn, bid)
if bv is not None and bv > 0:
open_px = bv
sv = bridge_sell_vwap(conn, bid)
if sv is not None and sv > 0:
close_px = sv
except Exception:
pass
if close_px is None or close_px <= 0:
close_px = open_px
if close_px is None or close_px <= 0:
return open_px, close_px, "missing"
return open_px, close_px, ""
def resolve_true_pnl_usdt_from_trade(
*,
premium_paid: Any = None,
premium_received: Any = None,
realized_pnl: Any = None,
open_index_px: Any = None,
close_index_px: Any = None,
initial_premium_usdt: Any = None,
open_fee: Any = 0,
close_fee: Any = 0,
premium_ccy: str | None = None,
fee_ccy: str | None = None,
conn: Any = None,
bridge_id: Any = None,
) -> tuple[float | None, str]:
"""由成交字段算复盘真盈亏: 期权币盈亏×平仓价.
返回 (pnl_u, source): premium_move | stable | none
"""
del fee_ccy, initial_premium_usdt
pc = str(premium_ccy or "").strip().upper()
paid = _safe_float(premium_paid)
recv = _safe_float(premium_received)
ofee = abs(float(_safe_float(open_fee) or 0.0))
cfee = abs(float(_safe_float(close_fee) or 0.0))
# 期权历史币盈亏已含开/平手续费;真盈亏不再另扣费
coin = _safe_float(realized_pnl)
if coin is None and paid is not None and recv is not None:
coin = round(float(recv) - float(paid) - ofee - cfee, 8)
if pc not in ("ETH", "BTC"):
if coin is not None:
return round(float(coin), 4), "stable"
return None, "none"
_open_px, close_px, gate = resolve_spot_vwap_prices(
conn,
bridge_id=bridge_id,
open_index_px=open_index_px,
close_index_px=close_index_px,
)
if gate == "missing":
return None, "none"
usdt = true_pnl_usdt_premium_move(
coin_pnl=coin,
sell_back_px=close_px,
)
if usdt is not None:
return usdt, "premium_move"
return None, "none"
def settle_sim_coin_pnl_usdt(
*,
premium_paid: Any,
premium_received: Any,
open_fee: Any = 0,
close_fee: Any = 0,
open_index_px: float | None,
sell_back_px: float | None,
fee_ccy: str | None = None,
premium_ccy: str = "ETH",
) -> dict[str, float | None]:
"""由毛权利金生成净币盈亏,再按期权盈亏折 U(费只扣进币盈亏一次).
币盈亏 = 收回 − 已付 − 开/平手续费
真盈亏U = 币盈亏 × 平仓价
"""
del fee_ccy
paid = float(_safe_float(premium_paid) or 0.0)
recv = float(_safe_float(premium_received) or 0.0)
ofee = abs(float(_safe_float(open_fee) or 0.0))
cfee = abs(float(_safe_float(close_fee) or 0.0))
open_idx = _safe_float(open_index_px)
sell_px = _safe_float(sell_back_px)
net_coin = round(recv - paid - ofee - cfee, 8)
fee_u = None
if open_idx is not None and open_idx > 0 and sell_px is not None and sell_px > 0:
fee_u = round(ofee * float(open_idx) + cfee * float(sell_px), 4)
elif sell_px is not None and sell_px > 0 and (ofee > 0 or cfee > 0):
fee_u = round((ofee + cfee) * float(sell_px), 4)
if sell_px is None or sell_px <= 0:
return {
"realized_pnl": net_coin,
"close_index_px": None,
"open_index_px": float(open_idx) if open_idx is not None and open_idx > 0 else None,
"realized_pnl_usdt": None,
"total_fee": round(ofee + cfee, 8),
"initial_premium_usdt": (
round(float(paid) * float(open_idx), 4)
if open_idx is not None and open_idx > 0 and paid > 0
else None
),
"proceeds_usdt": None,
"fee_usdt": fee_u,
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
}
initial_u = (
float(paid) * float(open_idx)
if open_idx is not None and open_idx > 0
else None
)
proceeds_u = float(recv) * float(sell_px)
pnl_u = true_pnl_usdt_premium_move(
coin_pnl=net_coin,
sell_back_px=sell_px,
)
return {
"realized_pnl": net_coin,
"close_index_px": float(sell_px),
"open_index_px": float(open_idx) if open_idx is not None and open_idx > 0 else None,
"realized_pnl_usdt": pnl_u,
"total_fee": round(ofee + cfee, 8),
"initial_premium_usdt": round(initial_u, 4) if initial_u is not None else None,
"proceeds_usdt": round(proceeds_u, 4),
"fee_usdt": fee_u,
"premium_ccy": str(premium_ccy or "").strip().upper() or "ETH",
}
def settle_closed_pnl(
*,
premium_paid: Any,
premium_received: Any,
open_fee: Any = 0,
close_fee: Any = 0,
premium_ccy: str,
index_px: float | None,
open_index_px: float | None = None,
budget_usdt: float | None = None,
fee_ccy: str | None = None,
) -> dict[str, float | None]:
"""
由「毛权利金」结算.
币数量净盈亏 = 收回 − 已付 − 开/平手续费(只扣一次).
币本位 U: 净币盈亏×平仓价(币量不含现货多余仓)
USDC: 净额原样为 U.
注意:不要用 budget_usdt 当开仓成本——它含现货买入缓冲.
不要对 OKX realizedPnl 再调此函数扣费.
"""
del budget_usdt # 买币预算不是权利金成本
paid = float(_safe_float(premium_paid) or 0.0)
recv = float(_safe_float(premium_received) or 0.0)
ofee = float(_safe_float(open_fee) or 0.0)
cfee = float(_safe_float(close_fee) or 0.0)
if ofee < 0:
ofee = abs(ofee)
if cfee < 0:
cfee = abs(cfee)
net = round(recv - paid - ofee - cfee, 8)
pc = str(premium_ccy or "").strip().upper()
if pc in ("ETH", "BTC"):
open_idx = _safe_float(open_index_px)
sell_px = _safe_float(index_px)
if sell_px is not None and sell_px > 0:
return settle_sim_coin_pnl_usdt(
premium_paid=paid,
premium_received=recv,
open_fee=ofee,
close_fee=cfee,
open_index_px=open_idx,
sell_back_px=sell_px,
fee_ccy=fee_ccy,
premium_ccy=pc,
)
return freeze_pnl_to_usdt(
net,
premium_ccy=pc,
index_px=index_px,
total_fee=ofee + cfee,
)
out = freeze_pnl_to_usdt(
net,
premium_ccy=premium_ccy,
index_px=index_px,
total_fee=ofee + cfee,
)
return out
def settle_exchange_realized_pnl(
*,
realized_pnl: Any,
premium_ccy: str,
index_px: float | None,
fee_hint: Any = None,
) -> dict[str, float | None]:
"""
冻结交易所 positions-history 的 realizedPnl.
该字段已含手续费,此处只折 U,不再减 fee.
"""
net = _safe_float(realized_pnl)
if net is None:
return {
"realized_pnl": None,
"close_index_px": None,
"realized_pnl_usdt": None,
"total_fee": fee_amount_abs(fee_hint),
}
return freeze_pnl_to_usdt(
float(net),
premium_ccy=premium_ccy,
index_px=index_px,
total_fee=fee_amount_abs(fee_hint),
)
def resolve_close_index_px(
ex: Any,
*,
inst_id: str = "",
underlying: str = "",
hint: Any = None,
) -> float | None:
"""平仓冻结用指数:优先调用方传入,否则拉标的近似价."""
h = _safe_float(hint)
if h is not None and h > 0:
return float(h)
underly = (underlying or "").strip().upper()
if not underly and inst_id:
underly = str(inst_id).split("-")[0].strip().upper() or "ETH"
if not underly:
underly = "ETH"
try:
from lib.options.options_stats_lib import _underlying_index_usdt
return _underlying_index_usdt(ex, underly)
except Exception:
return None
+3 -3
View File
@@ -6,12 +6,12 @@ from typing import Any, Optional, Sequence
def options_max_active_positions() -> int:
"""同时持有的期权合约笔数上限;0=不限制.热更读 env."""
raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "0")
"""同时持有的期权合约笔数上限;0=不限制.默认 1(一次一仓).热更读 env."""
raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "1")
try:
v = int(float(str(raw).strip()))
except (TypeError, ValueError):
return 0
return 1
return max(0, v)
+82 -17
View File
@@ -95,6 +95,8 @@ def forget_close_gate_for_inst(inst_id: str) -> None:
def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
"""与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值."""
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"):
return None
@@ -112,7 +114,9 @@ def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
recv = _safe_float(preview.get("total_received"))
paid = _safe_float(row.get("premium_paid"))
if covered > 0 and recv is not None and paid is not None:
return round(recv - paid, 4)
ccy = str(row.get("premium_ccy") or "").strip().upper()
decimals = PREMIUM_DECIMALS_COIN if ccy in ("ETH", "BTC") else None
return round_premium_amount(recv - paid, ccy=ccy or None, decimals=decimals)
return None
@@ -132,9 +136,43 @@ def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
pnl = display_pnl_from_option_row(row)
if pnl is None:
return None
return _coin_amount_to_usdt(pnl, row)
def premium_paid_usdt_from_option_row(row: dict[str, Any]) -> float | None:
"""开仓权利金折 USDT.
用权利金币数量 × 开仓/标记指数;绝不用 budget_usdt(那是买币缓冲预算,不是权利金).
"""
entry = _safe_float(row.get("premium_entry_usdt"))
if entry is not None:
return round(float(entry), 4)
paid = _safe_float(row.get("premium_paid"))
if paid is None:
return None
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
if ccy in ("ETH", "BTC"):
idx = _safe_float(
row.get("open_index_px")
or row.get("idx_px")
or row.get("idxPx")
or row.get("index_px")
)
if idx is None or idx <= 0:
return None
return round(float(paid) * float(idx), 4)
return _coin_amount_to_usdt(paid, row)
def _coin_amount_to_usdt(amount: float, row: dict[str, Any]) -> float | None:
"""币本位金额按指数折 U;USDC 原样."""
try:
n = float(amount)
except (TypeError, ValueError):
return None
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
if not ccy or ccy == "USDC":
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 数量当 U
inst = str(row.get("inst_id") or row.get("instId") or "")
mode = str(row.get("margin_mode") or "").strip().lower()
if mode == "coin" or (
@@ -146,10 +184,9 @@ def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
if ccy in ("ETH", "BTC"):
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if idx is None or idx <= 0:
# 无指数时无法折算,跳过该仓以免把「币数量」当成 U
return None
return float(pnl) * float(idx)
return float(pnl)
return float(n) * float(idx)
return float(n)
def sum_options_net_pnl_usdc(
@@ -185,25 +222,53 @@ def build_display_option_positions(
cfg: dict[str, Any],
ex: Any,
raw_positions: list[dict[str, Any]],
*,
include_close_preview: bool = True,
) -> list[dict[str, Any]]:
"""与实例 /api/options/positions 相同 enrichment + close_preview."""
"""与实例 /api/options/positions 相同 enrichment;可选 close_preview.
DB 读写与交易所拉盘口分离,避免握库期间网络导致 database is locked / 中控超时。
"""
from lib.options.options_db import init_options_tables, sum_open_budget_usdt, sum_open_premium_paid
meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = []
prem_by_inst: dict[str, float | None] = {}
budget_by_inst: dict[str, float | None] = {}
conn = cfg["get_db"]()
try:
init_options_tables(conn)
for p in raw_positions:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
row = enrich_position_row_display(
cfg,
ex,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
rows.append(row)
if not inst:
continue
prem_by_inst[inst] = sum_open_premium_paid(conn, inst)
budget_by_inst[inst] = sum_open_budget_usdt(conn, inst)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
rows: list[dict[str, Any]] = []
for p in raw_positions:
inst = str(p.get("instId") or "").strip()
premium_override = prem_by_inst.get(inst)
budget_usdt = budget_by_inst.get(inst)
row = enrich_position_row_display(
cfg,
ex,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
if budget_usdt is not None:
row["budget_usdt"] = round(float(budget_usdt), 4)
# 权利金入口 U:币数量×指数,不用买币预算
prem_u = premium_paid_usdt_from_option_row(row)
if prem_u is not None:
row["premium_entry_usdt"] = round(float(prem_u), 4)
if include_close_preview:
attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
rows.append(row)
return rows
+25 -6
View File
@@ -4,6 +4,24 @@ from __future__ import annotations
import math
from typing import Any
# 币本位权利金常为 1e-4~1e-5 量级(尤其 BTC);round(...,4) 会把净盈亏抹成 0
PREMIUM_DECIMALS_COIN = 8
PREMIUM_DECIMALS_FIAT = 4
def round_premium_amount(v: float | None, *, ccy: str | None = None, decimals: int | None = None) -> float | None:
"""权利金/回收/币盈亏入账精度:ETH/BTC 用 8 位,USDC 等用法币 4 位."""
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
if decimals is None:
unit = (ccy or "").strip().upper()
decimals = PREMIUM_DECIMALS_COIN if unit in ("ETH", "BTC") else PREMIUM_DECIMALS_FIAT
return round(n, int(decimals))
def ct_mult_from_meta(meta: dict[str, Any] | None) -> float:
if not meta:
@@ -298,7 +316,7 @@ def estimate_close_by_bids(
"available_sheets": sz,
"sheets": take,
"eth_amount": eth_amount,
"received": round(received, 4),
"received": round_premium_amount(received, decimals=PREMIUM_DECIMALS_COIN),
}
)
total_received += received
@@ -310,15 +328,16 @@ def estimate_close_by_bids(
estimated_pnl_ratio_pct = None
if premium_paid is not None and covered > 0:
paid = float(premium_paid)
estimated_pnl = round(total_received - paid, 4)
if paid > 0:
# 必须用 8 位:BTC 权利金常约 1e-4,round(...,4) 会把盈亏抹成 0
estimated_pnl = round_premium_amount(total_received - paid, decimals=PREMIUM_DECIMALS_COIN)
if paid > 0 and estimated_pnl is not None:
estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2)
return {
"levels": levels,
"covered_sheets": covered,
"uncovered_sheets": remaining,
"total_received": round(total_received, 4),
"avg_px": round(avg_px, 4) if avg_px is not None else None,
"total_received": round_premium_amount(total_received, decimals=PREMIUM_DECIMALS_COIN),
"avg_px": round(avg_px, 8) if avg_px is not None else None,
"estimated_pnl": estimated_pnl,
"estimated_pnl_ratio_pct": estimated_pnl_ratio_pct,
"bid_invalid": False,
@@ -368,7 +387,7 @@ def calc_order_size(
ct_mult: float,
min_sz: int,
budget_usdc: float | None = None,
budget_buffer: float = 0.95,
budget_buffer: float = 1.0,
eth_amount: float | None = None,
sheets: int | None = None,
budget_cap: float | None = None,
+68 -37
View File
@@ -59,11 +59,14 @@ def profit_exit_hit(
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
prem = float(premium_paid or 0)
m = float(mult or 0)
if prem <= 0 or m <= 0:
return None
return round(prem * (1.0 + m), 4)
# 币本位权利金很小,必须用 8 位,否则翻倍门槛会被 round4 抹歪
return round_premium_amount(prem * (1.0 + m), decimals=PREMIUM_DECIMALS_COIN)
def set_profit_exit(
@@ -297,7 +300,7 @@ def _notify_profit_exit_close(
def run_options_profit_exits(
conn: sqlite3.Connection,
conn_or_get_db: Any,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
@@ -307,54 +310,77 @@ def run_options_profit_exits(
cfg: dict[str, Any] | None = None,
ex: Any = None,
) -> int:
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数.
可传 get_db:拉盘口/平仓时不握业务连接。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
conn = conn_or_get_db() if get_db else conn_or_get_db
ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
rules = profit_exit_by_inst(conn)
triggered = 0
closing_ids: list[str] = []
scan_jobs: list[tuple[str, dict[str, Any], float, float]] = []
for inst_id, info in list(rules.items()):
if not inst_id:
continue
pos = pos_by_inst.get(inst_id)
if not pos:
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
continue
state = str(info.get("profit_exit_state") or "active")
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
prem = sum_open_premium_paid(conn, inst_id)
if prem is None or prem <= 0:
continue
# done/idle 不再扫描(查询层已过滤;此处双保险)
if state in ("done", "idle"):
continue
if state == "closing":
result = close_fn(inst_id)
if result.get("already_flat") or _result_fully_done(result):
_mark_state(conn, inst_id, "done")
try:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
except Exception:
pass
_commit(conn)
else:
_mark_state(conn, inst_id, "closing")
_commit(conn)
closing_ids.append(inst_id)
continue
if not info.get("profit_exit_enabled"):
continue
scan_jobs.append((inst_id, pos, float(prem), float(mult)))
_commit(conn)
if get_db is not None:
try:
conn.close()
except Exception:
pass
conn = None
def _ensure_conn() -> sqlite3.Connection:
nonlocal conn
if conn is not None:
return conn
assert get_db is not None
conn = get_db()
ensure_profit_exit_columns(conn)
return conn
for inst_id in closing_ids:
result = close_fn(inst_id)
c = _ensure_conn()
if result.get("already_flat") or _result_fully_done(result):
_mark_state(c, inst_id, "done")
try:
c.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
except Exception:
pass
_commit(c)
else:
_mark_state(c, inst_id, "closing")
_commit(c)
for inst_id, pos, prem, mult in scan_jobs:
if recycle_fn is not None:
recycle = recycle_fn(pos, prem)
elif cfg is not None and ex is not None:
@@ -367,10 +393,11 @@ def run_options_profit_exits(
continue
result = close_fn(inst_id)
c = _ensure_conn()
if result.get("already_flat"):
_mark_state(conn, inst_id, "done")
_mark_state(c, inst_id, "done")
try:
conn.execute(
c.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
@@ -380,18 +407,18 @@ def run_options_profit_exits(
)
except Exception:
pass
_commit(conn)
_commit(c)
continue
if not result.get("ok"):
_mark_state(conn, inst_id, "active")
_commit(conn)
_mark_state(c, inst_id, "active")
_commit(c)
continue
done = _result_fully_done(result)
_mark_state(conn, inst_id, "done" if done else "closing")
_mark_state(c, inst_id, "done" if done else "closing")
if done:
try:
conn.execute(
c.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0
@@ -401,8 +428,7 @@ def run_options_profit_exits(
)
except Exception:
pass
# 先落库再推送:同一笔只推一次;closing 重试不再进本分支
_commit(conn)
_commit(c)
triggered += 1
_notify_profit_exit_close(
cfg,
@@ -413,6 +439,11 @@ def run_options_profit_exits(
premium_paid=prem,
recycle=recycle,
result=result,
conn=conn,
conn=c,
)
if get_db is not None and conn is not None:
try:
conn.close()
except Exception:
pass
return triggered
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -87,6 +87,7 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
strategy_tag TEXT,
direction_view TEXT,
entry_logic TEXT,
prev_entry_tf TEXT,
exit_reason TEXT,
followed_plan TEXT,
mistake_tags TEXT,
@@ -130,6 +131,11 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
_ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
_ensure_column(conn, "options_review_trades", "profit_rr", "REAL")
_ensure_column(conn, "options_review_trades", "initial_premium_usdt", "REAL")
_ensure_column(conn, "options_review_trades", "profit_rate", "REAL")
# 仅期权开/平手续费折 U(不含现货买币/卖回)
_ensure_column(conn, "options_review_trades", "option_fee_usdt", "REAL")
_ensure_column(conn, "options_review_entries", "prev_entry_tf", "TEXT")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
@@ -140,5 +146,13 @@ def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str)
names.add(str(r["name"]))
except (TypeError, KeyError, IndexError):
names.add(str(r[1]))
if col not in names:
if col in names:
return
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
except sqlite3.OperationalError as e:
# 并发首启时多个请求同时 ADD,后者报 duplicate column — 可忽略
msg = str(e).lower()
if "duplicate column" in msg or "already exists" in msg:
return
raise
+377 -38
View File
@@ -66,21 +66,28 @@ def _safe_float(v: Any) -> Optional[float]:
def get_sync_state(conn: sqlite3.Connection, key: str) -> Optional[str]:
row = conn.execute(
"SELECT value FROM options_review_sync_state WHERE key=?", (key,)
).fetchone()
try:
row = conn.execute(
"SELECT value FROM options_review_sync_state WHERE key=?", (key,)
).fetchone()
except sqlite3.OperationalError:
return None
return str(row["value"]) if row and row["value"] is not None else None
def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None:
conn.execute(
"""
INSERT INTO options_review_sync_state(key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, value, _now_str()),
)
try:
conn.execute(
"""
INSERT INTO options_review_sync_state(key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, value, _now_str()),
)
except sqlite3.OperationalError:
# 列表/统计并发刷新时可能撞锁;标记失败不阻断读列表
pass
def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bool:
@@ -139,6 +146,9 @@ def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) ->
"close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")),
"premium_paid": _safe_float(row.get("premium_paid")),
"realized_pnl": pnl,
"initial_premium_usdt": _safe_float(row.get("initial_premium_usdt")),
"profit_rate": _safe_float(row.get("profit_rate")),
"option_fee_usdt": _safe_float(row.get("option_fee_usdt")),
}
cols = list(fields.keys())
if existing:
@@ -270,6 +280,148 @@ def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]
return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")}
def _sum_sim_order_fees(conn: sqlite3.Connection, ord_ids: list[str]) -> float | None:
"""从 sim_option_orders 汇总手续费(权利金币种,正数)."""
ids = [str(x).strip() for x in (ord_ids or []) if str(x).strip()]
if not ids:
return None
try:
conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone()
except Exception:
return None
total = 0.0
n = 0
for oid in ids:
try:
row = conn.execute(
"SELECT fee FROM sim_option_orders WHERE ord_id=?",
(oid,),
).fetchone()
except Exception:
continue
if not row:
continue
fee = _safe_float(row["fee"] if hasattr(row, "keys") else row[0])
if fee is None:
continue
total += abs(float(fee))
n += 1
return round(total, 8) if n else None
def _estimate_okx_option_fee_coin(
*,
underlying_coin: float | None,
premium_coin: float | None,
) -> float:
"""按 OKX 币本位公式估算期权手续费(权利金币种)."""
try:
from lib.sim.pricing_lib import option_fee_coin
return float(
option_fee_coin(
underlying_coin=float(underlying_coin or 0.0),
premium_coin=float(premium_coin or 0.0),
)
)
except Exception:
return 0.0
def _fee_looks_like_legacy_premium_rate(
fee_coin: float,
*,
premium_coin: float | None,
underlying_coin: float | None,
) -> bool:
"""旧模拟盘把手续费算成权利金×费率,约为正确值的 premium/underlying 倍."""
if fee_coin <= 0:
return True
prem = abs(float(premium_coin or 0.0))
uly = abs(float(underlying_coin or 0.0))
if prem <= 0 or uly <= 0:
return False
# 正确量级 ~ uly * fee_rate; 错误量级 ~ prem * fee_rate
# 若 fee << uly*1e-4 且接近 prem*5e-4,视为旧算法
try:
from lib.sim.pricing_lib import sim_fee_rate
fr = float(sim_fee_rate())
except Exception:
fr = 0.0005
expect_okx = uly * fr
expect_legacy = prem * fr
if expect_okx <= 0:
return False
# 比 OKX 估算小一个数量级以上,且靠近旧公式
if fee_coin < expect_okx * 0.25 and abs(fee_coin - expect_legacy) <= max(expect_legacy * 0.5, 1e-10):
return True
if fee_coin < expect_okx * 0.05:
return True
return False
def _resolve_option_fees_coin(
conn: sqlite3.Connection,
row: Any,
*,
open_fee: float,
close_fee: float,
premium_ccy: str,
) -> tuple[float, float]:
"""解析期权开/平手续费(权利金币种);修正模拟盘旧算法偏小值."""
ccy = (premium_ccy or "").strip().upper()
paid = _safe_float(row["premium_paid"] if hasattr(row, "keys") else None)
recv = _safe_float(row["premium_received"] if hasattr(row, "keys") else None)
uly = _safe_float(row["eth_amount"] if hasattr(row, "keys") else None)
if uly is None or uly <= 0:
sheets = _safe_float(row["sheets"] if hasattr(row, "keys") else None) or 0.0
# BTC 期权约 0.01,ETH 约 0.1
ct = 0.01 if ccy == "BTC" else 0.1
uly = float(sheets) * ct if sheets > 0 else None
open_oids = [
p.strip()
for p in str(row["exchange_ord_id"] if hasattr(row, "keys") else "").split(",")
if p.strip()
]
close_oids = [
p.strip()
for p in str(row["close_ord_id"] if hasattr(row, "keys") else "").split(",")
if p.strip()
]
ofee = abs(float(open_fee or 0.0))
cfee = abs(float(close_fee or 0.0))
# 优先用订单表(若订单本身仍是旧算法,下面会再纠正)
sim_open = _sum_sim_order_fees(conn, open_oids)
sim_close = _sum_sim_order_fees(conn, close_oids)
if sim_open is not None and sim_open > 0:
ofee = sim_open
if sim_close is not None and sim_close > 0:
cfee = sim_close
if ccy in ("ETH", "BTC"):
if _fee_looks_like_legacy_premium_rate(ofee, premium_coin=paid, underlying_coin=uly):
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=paid)
if est > 0:
ofee = est
if _fee_looks_like_legacy_premium_rate(cfee, premium_coin=recv, underlying_coin=uly):
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=recv)
if est > 0:
cfee = est
elif cfee <= 0 and recv is not None and recv > 0:
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=recv)
if est > 0:
cfee = est
if ofee <= 0 and paid is not None and paid > 0:
est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=paid)
if est > 0:
ofee = est
return round(ofee, 8), round(cfee, 8)
def sync_options_from_local_trades(
conn: sqlite3.Connection,
@@ -277,24 +429,52 @@ def sync_options_from_local_trades(
) -> dict[str, Any]:
"""从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).
币本位权利金/盈亏按指数折算成 USDT(U) 写入,复盘页统一按 U 展示.
真盈亏=历史币盈亏×卖回价 − (权利金×开仓价 − 权利金×卖回价);
计入权利金 USDT 波动,忽略现货多买.
"""
init_options_review_tables(conn)
from lib.options.options_db import init_options_tables
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
init_options_tables(conn)
# 模拟盘先把现金盈亏写进 realized_pnl_usdt,复盘与持仓统计同一口径.
# 不可 is_sim_mode(lambda: conn): with_db 会 close 掉调用方连接.
try:
from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_get
from lib.sim.mode_lib import MODE_SIM, default_trading_mode
ensure_runtime_settings_table(conn)
mode = runtime_get(conn, "trading.mode")
mode_s = str(mode or "").strip().lower() or default_trading_mode()
if mode_s == MODE_SIM:
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
sync_sim_cash_realized_pnl_usdt(conn, ex)
try:
conn.commit()
except Exception:
pass
except Exception:
pass
rows = conn.execute(
"""
SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets,
open_quote, close_quote, premium_paid, premium_received, realized_pnl,
premium_ccy, created_at, closed_at, signal_note, status
eth_amount, open_quote, close_quote, premium_paid, premium_received,
realized_pnl, realized_pnl_usdt, close_index_px, open_index_px,
budget_usdt, premium_ccy, created_at, closed_at, signal_note, status,
trading_usdt_before, trading_usdt_after, bridge_id, pnl_settled,
initial_premium_usdt, open_fee, close_fee, exchange_ord_id, close_ord_id
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
LIMIT 500
"""
).fetchall()
# 读完先提交,后续拉指数不与写事务叠在一起
try:
conn.commit()
except Exception:
pass
def _index_px(underly: str) -> float | None:
u = (underly or "ETH").strip().upper() or "ETH"
@@ -313,9 +493,9 @@ def sync_options_from_local_trades(
for pub in pubs:
try:
if hasattr(pub, "public_get_market_ticker"):
rows = (pub.public_get_market_ticker({"instId": inst}) or {}).get("data") or []
if rows:
last = rows[0].get("last") or rows[0].get("lastPx")
tick_rows = (pub.public_get_market_ticker({"instId": inst}) or {}).get("data") or []
if tick_rows:
last = tick_rows[0].get("last") or tick_rows[0].get("lastPx")
if last is not None and float(last) > 0:
return float(last)
except Exception:
@@ -349,6 +529,24 @@ def sync_options_from_local_trades(
inserted = updated = skipped = 0
idx_cache: dict[str, float | None] = {}
need_idx: set[str] = set()
for r in rows:
# 已有冻结 U 或平仓指数时不再打实时行情
if _safe_float(r["realized_pnl_usdt"]) is not None:
continue
frozen_idx = _safe_float(r["close_index_px"]) or _safe_float(r["open_index_px"])
if frozen_idx is not None and frozen_idx > 0:
continue
inst0 = str(r["inst_id"] or "")
underly0 = str(r["underlying"] or (inst0.split("-")[0] if inst0 else "ETH") or "ETH")
ccy0 = str(r["premium_ccy"] or "").strip().upper()
if not ccy0:
ccy0 = premium_ccy_for_mode(margin_mode_from_inst_id(inst0), underly0)
if ccy0 in ("ETH", "BTC"):
need_idx.add(underly0)
for underly0 in need_idx:
idx_cache[underly0] = _index_px(underly0)
for r in rows:
trade_id = int(r["id"])
history_key = f"local_opt:{trade_id}"
@@ -357,21 +555,112 @@ def sync_options_from_local_trades(
ccy = str(r["premium_ccy"] or "").strip().upper()
if not ccy:
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
if underly not in idx_cache:
idx_cache[underly] = _index_px(underly)
idx = idx_cache.get(underly)
pnl = _safe_float(r["realized_pnl"])
if pnl is None:
paid0 = _safe_float(r["premium_paid"])
recv0 = _safe_float(r["premium_received"])
if paid0 is not None and recv0 is not None:
pnl = recv0 - paid0
close_idx = _safe_float(r["close_index_px"])
open_idx = _safe_float(r["open_index_px"])
idx = None
if close_idx is not None and close_idx > 0:
idx = close_idx
elif open_idx is not None and open_idx > 0:
idx = open_idx
else:
idx = idx_cache.get(underly)
prem = _safe_float(r["premium_paid"])
pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx)
prem_u = _to_usdt(prem, ccy=ccy, idx=idx)
if ccy in ("ETH", "BTC") and idx is None:
pnl_u = pnl
# 仅期权开/平手续费折 U(不含现货);先解析再算真盈亏
ofee = abs(_safe_float(r["open_fee"]) or 0.0)
cfee = abs(_safe_float(r["close_fee"]) or 0.0)
ofee, cfee = _resolve_option_fees_coin(
conn,
r,
open_fee=ofee,
close_fee=cfee,
premium_ccy=ccy,
)
# 币本位/初始权利金U 冻结不变;刷新只按卖回价重算真盈亏U
stored_initial = _safe_float(r["initial_premium_usdt"])
open_idx_for_prem = open_idx if (open_idx is not None and open_idx > 0) else idx
sell_idx = close_idx if (close_idx is not None and close_idx > 0) else None
from lib.options.options_pnl_settle_lib import (
resolve_spot_vwap_prices,
resolve_true_pnl_usdt_from_trade,
)
status_label = "已平"
pnl_u = None
# 有桥时卖回价只认 close_sell VWAP;开仓价不覆盖已冻值
opx, cpx, _gate = resolve_spot_vwap_prices(
conn,
bridge_id=r["bridge_id"],
open_index_px=open_idx,
close_index_px=sell_idx,
)
pnl_u, pnl_src = resolve_true_pnl_usdt_from_trade(
premium_paid=prem,
premium_received=r["premium_received"],
realized_pnl=r["realized_pnl"],
open_index_px=opx,
close_index_px=cpx,
initial_premium_usdt=stored_initial,
open_fee=ofee,
close_fee=cfee,
premium_ccy=ccy,
fee_ccy=str(r["premium_ccy"] or ccy or ""),
conn=conn,
bridge_id=r["bridge_id"],
)
if pnl_u is not None and pnl_src not in ("none", "pending_sell"):
try:
# 卖回 VWAP 写入 close;开仓价/初始权利金仅缺省时补
heal_initial = stored_initial
if heal_initial is None and prem is not None and opx is not None and opx > 0:
heal_initial = round(float(prem) * float(opx), 4)
conn.execute(
"""
UPDATE options_trades
SET 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 = ?
""",
(pnl_u, opx, cpx, heal_initial, trade_id),
)
if (open_idx is None or open_idx <= 0) and opx is not None and opx > 0:
open_idx = opx
open_idx_for_prem = opx
if cpx is not None and cpx > 0:
sell_idx = cpx
if stored_initial is None and heal_initial is not None:
stored_initial = heal_initial
except Exception:
pass
else:
status_label = "缺价未核"
pnl_u = None
# 初始权利金U:优先开仓冻结值
prem_u = stored_initial
if prem_u is None:
prem_u = _to_usdt(prem, ccy=ccy, idx=open_idx_for_prem)
if ccy in ("ETH", "BTC") and prem_u is None:
prem_u = prem
# 手续费列仅展示(已含在历史币盈亏内,真盈亏不再扣)
fee_u = None
if ccy in ("ETH", "BTC"):
fee_parts = 0.0
has_fee = False
if ofee > 0 and open_idx_for_prem is not None and open_idx_for_prem > 0:
fee_parts += ofee * float(open_idx_for_prem)
has_fee = True
if cfee > 0 and sell_idx is not None and sell_idx > 0:
fee_parts += cfee * float(sell_idx)
has_fee = True
if has_fee:
fee_u = round(fee_parts, 4)
elif ofee > 0 or cfee > 0:
fee_u = round(ofee + cfee, 4)
profit_rate = None
if pnl_u is not None and prem_u is not None and abs(float(prem_u)) > 1e-12:
profit_rate = round(float(pnl_u) / float(prem_u), 6)
opened_at = r["created_at"]
closed_at = r["closed_at"]
action = upsert_option_history_row(
@@ -388,10 +677,13 @@ def sync_options_from_local_trades(
"open_avg_px": r["open_quote"],
"close_avg_px": r["close_quote"],
"premium_paid": prem_u if prem_u is not None else prem,
"realized_pnl": pnl_u if pnl_u is not None else pnl,
"realized_pnl": pnl_u,
"initial_premium_usdt": prem_u,
"profit_rate": profit_rate,
"option_fee_usdt": fee_u,
"created_at": opened_at,
"closed_at": closed_at,
"status_label": "已平",
"status_label": status_label,
},
)
if action == "inserted":
@@ -627,9 +919,33 @@ def ensure_local_review_synced(
repair_swapped_closed_at_signal_note,
)
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
note_swap = repair_swapped_closed_at_signal_note(conn)
if backfill_exchange_pnl and ex is not None:
ts_migrate: dict[str, Any] = {}
note_swap: dict[str, Any] = {}
try:
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
except Exception as e:
ts_migrate = {"ok": False, "msg": str(e)}
try:
note_swap = repair_swapped_closed_at_signal_note(conn)
except Exception as e:
note_swap = {"ok": False, "msg": str(e)}
# 提交后再拉历史/行情,避免模拟盘 broker 二次开库与本连接互锁
try:
conn.commit()
except Exception:
pass
# 模拟盘用现金冻结,勿再用交易所历史+实时指数覆盖
sim_mode = False
try:
from lib.instance.runtime_settings_lib import ensure_runtime_settings_table, runtime_get
from lib.sim.mode_lib import MODE_SIM, default_trading_mode
ensure_runtime_settings_table(conn)
mode = runtime_get(conn, "trading.mode")
sim_mode = (str(mode or "").strip().lower() or default_trading_mode()) == MODE_SIM
except Exception:
sim_mode = False
if backfill_exchange_pnl and ex is not None and not sim_mode:
try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.options.options_monitor_lib import (
@@ -638,12 +954,21 @@ def ensure_local_review_synced(
hist = fetch_all_option_positions_history(ex, limit=200)
backfill_closed_options_realized_pnl_from_history(conn, hist)
conn.commit()
except Exception:
pass
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
try:
conn.rollback()
except Exception:
pass
try:
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
except sqlite3.OperationalError as e:
out = {"ok": False, "msg": f"sync locked: {e}"}
if isinstance(out, dict):
out["ts_migrate"] = ts_migrate
out["note_swap"] = note_swap
if out.get("ok") is not False:
out["ok"] = True
return out
@@ -653,6 +978,12 @@ def _row_to_dict(row: Any) -> dict[str, Any]:
def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]:
out = dict(row)
# 盈利率缺失时按 复盘盈亏/初始权利金 补算
if _safe_float(out.get("profit_rate")) is None:
pnl = _safe_float(out.get("realized_pnl_total"))
prem = _safe_float(out.get("initial_premium_usdt"))
if pnl is not None and prem is not None and abs(prem) > 1e-12:
out["profit_rate"] = round(pnl / prem, 6)
out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type"))
out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS)
legs = []
@@ -669,6 +1000,7 @@ def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -
out["strategy_tag"] = entry.get("strategy_tag")
out["direction_view"] = entry.get("direction_view")
out["entry_logic"] = entry.get("entry_logic")
out["prev_entry_tf"] = entry.get("prev_entry_tf")
out["result_tag"] = entry.get("result_tag")
out["reviewed_at"] = entry.get("reviewed_at") or entry.get("updated_at")
else:
@@ -676,6 +1008,7 @@ def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -
out["strategy_tag"] = None
out["direction_view"] = None
out["entry_logic"] = None
out["prev_entry_tf"] = None
out["result_tag"] = None
out["reviewed_at"] = None
return out
@@ -837,7 +1170,7 @@ def list_review_trades(
rows = conn.execute(
f"""
SELECT t.*, e.id AS entry_id, e.strategy_tag AS e_strategy_tag,
e.direction_view, e.entry_logic, e.exit_reason, e.followed_plan,
e.direction_view, e.entry_logic, e.prev_entry_tf, e.exit_reason, e.followed_plan,
e.mistake_tags, e.result_tag, e.note, e.images_json, e.image,
e.reviewed_at, e.updated_at
FROM options_review_trades t
@@ -858,6 +1191,7 @@ def list_review_trades(
"strategy_tag": d.pop("e_strategy_tag", None),
"direction_view": d.pop("direction_view", None),
"entry_logic": d.pop("entry_logic", None),
"prev_entry_tf": d.pop("prev_entry_tf", None),
"exit_reason": d.pop("exit_reason", None),
"followed_plan": d.pop("followed_plan", None),
"mistake_tags": d.pop("mistake_tags", None),
@@ -874,6 +1208,7 @@ def list_review_trades(
"e_strategy_tag",
"direction_view",
"entry_logic",
"prev_entry_tf",
"exit_reason",
"followed_plan",
"mistake_tags",
@@ -924,10 +1259,14 @@ def save_review_entry(
primary = None
if images:
primary = str(images[0].get("file") or "").strip() or None
prev_tf = str(payload.get("prev_entry_tf") or "").strip().lower()
if prev_tf and prev_tf not in ("15m", "1h", "2h", "4h", "8h", "12h"):
prev_tf = ""
fields = {
"strategy_tag": str(payload.get("strategy_tag") or "").strip() or None,
"direction_view": str(payload.get("direction_view") or "").strip() or None,
"entry_logic": str(payload.get("entry_logic") or "").strip() or None,
"prev_entry_tf": prev_tf or None,
"exit_reason": str(payload.get("exit_reason") or "").strip() or None,
"followed_plan": str(payload.get("followed_plan") or "").strip() or None,
"mistake_tags": str(payload.get("mistake_tags") or "").strip() or None,
+61 -12
View File
@@ -275,7 +275,16 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
ex, _err = _require_ex(cfg)
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
return jsonify(result)
if isinstance(result, dict) and result.get("ok") is not False:
result["ok"] = True
return jsonify(result if isinstance(result, dict) else {"ok": True, "result": result})
except Exception as e:
print(f"[options_review_sync] {e}", flush=True)
try:
conn.rollback()
except Exception:
pass
return jsonify({"ok": False, "msg": f"同步失败: {e}"}), 500
finally:
conn.close()
@@ -284,16 +293,32 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
def api_options_review_trades():
conn = cfg["get_db"]()
try:
# 翻页可跳过同步,仅刷新当前卡片列表
do_sync = (request.args.get("sync") or "1").strip().lower() not in (
"0",
"false",
"no",
# 默认不同步:列表先读缓存;完整同步走 POST /api/options/review/sync
do_sync = (request.args.get("sync") or "0").strip().lower() in (
"1",
"true",
"yes",
)
if do_sync:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
try:
ex, _err = _require_ex(cfg)
# 列表路径不做历史回填,避免模拟盘握库二次开连接卡死
ensure_local_review_synced(
conn,
ex=ex if ex is not None else None,
backfill_exchange_pnl=False,
)
conn.commit()
except Exception as sync_err:
# 同步撞锁时仍返回已有列表,避免整页 500
print(f"[options_review_trades] sync skipped: {sync_err}", flush=True)
try:
conn.rollback()
except Exception:
pass
init_options_review_tables(conn)
else:
init_options_review_tables(conn)
filt = dict(
source_type=_review_source_for_mode(request.args.get("source_type")),
underlying=(request.args.get("underlying") or "").strip() or None,
@@ -326,6 +351,9 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
"pages": pages,
}
)
except Exception as e:
print(f"[options_review_trades] {e}", flush=True)
return jsonify({"ok": False, "msg": f"加载交易记录失败: {e}"}), 500
finally:
conn.close()
@@ -431,9 +459,26 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
def api_options_review_stats():
conn = cfg["get_db"]()
try:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
# 默认不同步:与 trades?sync=1 并行时避免双写撞锁;需要时再 sync=1
do_sync = (request.args.get("sync") or "0").strip().lower() in (
"1",
"true",
"yes",
)
if do_sync:
try:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
except Exception as sync_err:
print(f"[options_review_stats] sync skipped: {sync_err}", flush=True)
try:
conn.rollback()
except Exception:
pass
init_options_review_tables(conn)
else:
init_options_review_tables(conn)
stats = compute_review_stats(
conn,
source_type=_review_source_for_mode(request.args.get("source_type")),
@@ -447,6 +492,10 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
)
stats["source_types"] = list(SOURCE_TYPES)
stats["source_labels"] = SOURCE_LABELS
stats["ok"] = True
return jsonify(stats)
except Exception as e:
print(f"[options_review_stats] {e}", flush=True)
return jsonify({"ok": False, "msg": f"加载统计失败: {e}"}), 500
finally:
conn.close()
+681 -42
View File
@@ -13,6 +13,61 @@ BRIDGE_HOLDING = "holding"
BRIDGE_PENDING_SELL = "pending_sell_spot"
BRIDGE_CLOSED = "closed"
# 拉取 instruments 失败时的兜底(与 OKX 现货常见 minSz 对齐)
_SPOT_MIN_SZ_FALLBACK: dict[str, float] = {"ETH": 0.0001, "BTC": 0.00001}
_SPOT_MIN_SZ_CACHE: dict[str, tuple[float, float]] = {} # ccy -> (min_sz, mono_ts)
_SPOT_MIN_SZ_TTL_SEC = 3600.0
def spot_min_sell_sz(ex: Any | None, underlying: str) -> float:
"""ETH/BTC-USDT 现货最小下单量(base ccy);粉尘低于此值不卖、不展示."""
ccy = (underlying or "ETH").strip().upper() or "ETH"
fb = float(_SPOT_MIN_SZ_FALLBACK.get(ccy) or _SPOT_MIN_SZ_FALLBACK["ETH"])
now = time.monotonic()
cached = _SPOT_MIN_SZ_CACHE.get(ccy)
if cached and (now - cached[1]) < _SPOT_MIN_SZ_TTL_SEC:
return float(cached[0])
min_sz = fb
if ex is not None:
try:
inst_id = spot_quote_inst_id(ccy)
rows = ex.public_get_public_instruments({"instType": "SPOT", "instId": inst_id}) or {}
data = rows.get("data") if isinstance(rows, dict) else None
if not data and hasattr(ex, "publicGetPublicInstruments"):
rows = ex.publicGetPublicInstruments({"instType": "SPOT", "instId": inst_id}) or {}
data = rows.get("data") if isinstance(rows, dict) else None
row = (data or [None])[0] if data else None
if isinstance(row, dict):
raw = _safe_float(row.get("minSz"))
if raw is not None and raw > 0:
min_sz = float(raw)
except Exception:
pass
_SPOT_MIN_SZ_CACHE[ccy] = (min_sz, now)
return min_sz
def is_sellable_spot_coin(ex: Any | None, underlying: str, amount: Any) -> bool:
"""数量是否达到现货最小可卖(达到才重试/展示)."""
try:
amt = float(amount or 0)
except (TypeError, ValueError):
return False
if amt <= 0:
return False
return amt + 1e-15 >= spot_min_sell_sz(ex, underlying)
def display_sellable_coin(ex: Any | None, underlying: str, amount: Any) -> float:
"""展示用:不够 minSz 的粉尘归零."""
try:
amt = float(amount or 0)
except (TypeError, ValueError):
return 0.0
if not is_sellable_spot_coin(ex, underlying, amt):
return 0.0
return amt
def ensure_bridge_table(conn: sqlite3.Connection) -> None:
conn.execute(
@@ -41,6 +96,148 @@ def ensure_bridge_table(conn: sqlite3.Connection) -> None:
ON options_spot_bridge(status)
"""
)
# 多笔买卖 VWAP:买侧累加(含缓冲);平仓后卖回单独累加(不含开仓多余卖回)
for ddl in (
"ALTER TABLE options_spot_bridge ADD COLUMN buy_usdt_spent REAL",
"ALTER TABLE options_spot_bridge ADD COLUMN buy_coin_accum REAL",
"ALTER TABLE options_spot_bridge ADD COLUMN close_sell_coin REAL",
"ALTER TABLE options_spot_bridge ADD COLUMN close_sell_usdt REAL",
):
try:
conn.execute(ddl)
except Exception:
pass
def add_bridge_buy_fill(
conn: sqlite3.Connection,
bridge_id: int,
*,
usdt_spent: float | None,
coin_bought: float | None,
) -> None:
"""累加开仓买币成交,供买侧 VWAP=ΣUSDT/Σ币."""
ensure_bridge_table(conn)
u = _safe_float(usdt_spent)
c = _safe_float(coin_bought)
if bridge_id is None or u is None or c is None or u <= 0 or c <= 0:
return
row = conn.execute(
"SELECT buy_usdt_spent, buy_coin_accum FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if not row:
return
prev_u = _safe_float(row["buy_usdt_spent"] if hasattr(row, "keys") else row[0]) or 0.0
prev_c = _safe_float(row["buy_coin_accum"] if hasattr(row, "keys") else row[1]) or 0.0
conn.execute(
"""
UPDATE options_spot_bridge
SET buy_usdt_spent = ?, buy_coin_accum = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(round(prev_u + float(u), 8), round(prev_c + float(c), 8), int(bridge_id)),
)
def add_bridge_close_sell_fill(
conn: sqlite3.Connection,
bridge_id: int,
*,
usdt_recovered: float | None,
coin_sold: float | None,
) -> None:
"""累加平仓后卖回成交(多笔),供卖回 VWAP=ΣUSDT/Σ币;同时累加 usdt_recovered."""
ensure_bridge_table(conn)
u = _safe_float(usdt_recovered)
c = _safe_float(coin_sold)
if bridge_id is None:
return
if u is not None and u > 0:
add_bridge_usdt_recovered(conn, int(bridge_id), u)
if (u is None or u <= 0) and (c is None or c <= 0):
return
row = conn.execute(
"""
SELECT close_sell_usdt, close_sell_coin, coin_sold
FROM options_spot_bridge WHERE id=?
""",
(int(bridge_id),),
).fetchone()
if not row:
return
prev_u = _safe_float(row["close_sell_usdt"] if hasattr(row, "keys") else row[0]) or 0.0
prev_c = _safe_float(row["close_sell_coin"] if hasattr(row, "keys") else row[1]) or 0.0
prev_sold = _safe_float(row["coin_sold"] if hasattr(row, "keys") else row[2]) or 0.0
new_u = prev_u + (float(u) if u is not None and u > 0 else 0.0)
new_c = prev_c + (float(c) if c is not None and c > 0 else 0.0)
new_sold = prev_sold + (float(c) if c is not None and c > 0 else 0.0)
conn.execute(
"""
UPDATE options_spot_bridge
SET close_sell_usdt = ?, close_sell_coin = ?, coin_sold = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(round(new_u, 8), round(new_c, 8), round(new_sold, 8), int(bridge_id)),
)
def bridge_buy_vwap(conn: sqlite3.Connection, bridge_id: int | None) -> float | None:
"""开仓买币 VWAP(USDT/币);多笔加权.
只用 buy_usdt_spent/buy_coin_accum.禁止 budget/coin_bought:
开仓成功后 coin_bought 会被改成权利金量,用 budget/缩水量会把开仓价抬飞.
"""
if bridge_id is None:
return None
ensure_bridge_table(conn)
row = conn.execute(
"SELECT buy_usdt_spent, buy_coin_accum FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if not row:
return None
spent = _safe_float(row["buy_usdt_spent"] if hasattr(row, "keys") else row[0])
coins = _safe_float(row["buy_coin_accum"] if hasattr(row, "keys") else row[1])
if spent is not None and coins is not None and spent > 0 and coins > 0:
return round(float(spent) / float(coins), 8)
return None
def bridge_sell_vwap(conn: sqlite3.Connection, bridge_id: int | None) -> float | None:
"""平仓后卖回 VWAP(USDT/币);仅 close_sell_* 多笔加权.
禁止回退 usdt_recovered/coin_sold:后者常混有开仓多余卖回,会污染卖回均价.
"""
if bridge_id is None:
return None
ensure_bridge_table(conn)
row = conn.execute(
"SELECT close_sell_usdt, close_sell_coin FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if not row:
return None
u = _safe_float(row["close_sell_usdt"] if hasattr(row, "keys") else row[0])
c = _safe_float(row["close_sell_coin"] if hasattr(row, "keys") else row[1])
if u is not None and c is not None and u > 0 and c > 0:
return round(float(u) / float(c), 8)
return None
def bridge_sell_complete(conn: sqlite3.Connection, bridge_id: int | None) -> bool:
"""平仓卖回是否已完成(桥已关闭)."""
if bridge_id is None:
return False
ensure_bridge_table(conn)
row = conn.execute(
"SELECT status FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if not row:
return False
return str(row["status"] if hasattr(row, "keys") else row[0] or "") == BRIDGE_CLOSED
def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]:
@@ -96,6 +293,63 @@ def insert_bridge(
return int(cur.lastrowid)
def add_bridge_usdt_recovered(
conn: sqlite3.Connection,
bridge_id: int,
delta_usdt: float | None,
) -> float | None:
"""累加桥上卖币收回的 USDT(开仓多余卖回 + 平仓后卖回)."""
ensure_bridge_table(conn)
try:
d = float(delta_usdt or 0)
except (TypeError, ValueError):
return None
if d <= 0 or bridge_id is None:
return None
row = conn.execute(
"SELECT usdt_recovered FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if not row:
return None
prev = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[0]) or 0.0
new_v = round(float(prev) + d, 8)
conn.execute(
"""
UPDATE options_spot_bridge
SET usdt_recovered = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(new_v, int(bridge_id)),
)
return new_v
def cash_pnl_usdt_from_bridge(conn: sqlite3.Connection, bridge_id: int | None) -> float | None:
"""桥上真盈亏(U)=累计卖回 USDT − 开仓买币预算.
与账户 after−before 等价(一次一仓、无中途划转时);可作口径 A 回退.
不是期权腿币盈亏(口径 B).
"""
if bridge_id is None:
return None
ensure_bridge_table(conn)
row = conn.execute(
"""
SELECT budget_usdt, usdt_recovered
FROM options_spot_bridge WHERE id = ?
""",
(int(bridge_id),),
).fetchone()
if not row:
return None
spent = _safe_float(row["budget_usdt"] if hasattr(row, "keys") else row[0])
recovered = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[1])
if spent is None or recovered is None:
return None
return round(float(recovered) - float(spent), 4)
def update_bridge(
conn: sqlite3.Connection,
bridge_id: int,
@@ -221,16 +475,32 @@ def spot_market_sell_coin_to_usdt(
underlying: str,
coin_amount: float | None = None,
) -> dict[str, Any]:
"""交易账户:市价卖出标的币换 USDT.始终卖光交易户可用余额(coin_amount 仅兼容旧调用,不参与定量)."""
"""交易账户:市价卖出标的币换 USDT.
coin_amount 有值时按 min(可用, coin_amount) 卖,避免误卖无关余额;
未传时卖光该币可用(空仓扫尾).
"""
ccy = (underlying or "ETH").upper()
avail = fetch_trading_coin_available(ex, ccy)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
amt = float(avail)
if coin_amount is not None:
try:
lim = float(coin_amount)
except (TypeError, ValueError):
lim = 0.0
if lim > 0:
amt = min(amt, lim)
if amt <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
min_sz = spot_min_sell_sz(ex, ccy)
if amt + 1e-15 < min_sz:
return {"ok": False, "msg": f"{ccy} 可卖数量过小(低于现货最小 {min_sz:g})"}
# 留一点粉尘避免精度拒单
sell_sz = max(0.0, amt * 0.999)
if sell_sz + 1e-15 < min_sz:
return {"ok": False, "msg": f"{ccy} 可卖数量过小(低于现货最小 {min_sz:g})"}
inst_id = spot_quote_inst_id(ccy)
try:
# 现货卖出数量精度:截到 8 位
@@ -274,9 +544,29 @@ def rollback_bought_coin_to_usdt(
reason: str = "",
coin_amount: float | None = None,
) -> dict[str, Any]:
"""买币后开期权失败:卖回 USDT 并关闭桥.卖光交易户可用标的币."""
_ = coin_amount # 兼容旧签名;定量以交易户可用为准
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying)
"""买币后开期权失败:按本桥买入量封顶卖回 USDT 并关闭桥."""
ensure_bridge_table(conn)
cap = coin_amount
if cap is None:
try:
row = conn.execute(
"SELECT coin_bought FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if row is not None:
cap = _safe_float(row["coin_bought"] if hasattr(row, "keys") else row[0])
except Exception:
cap = None
# 卖币(模拟盘)会另开连接写钱包,须先释放本连接事务锁
try:
conn.commit()
except Exception:
pass
sell = spot_market_sell_coin_to_usdt(
ex,
underlying=underlying,
coin_amount=float(cap) if cap is not None and float(cap) > 0 else None,
)
if not sell.get("ok"):
update_bridge(
conn,
@@ -298,58 +588,397 @@ def rollback_bought_coin_to_usdt(
def sell_residual_after_option_flat(
conn: sqlite3.Connection,
conn_or_get_db: Any,
ex: Any,
*,
underlying: str,
inst_id: str | None = None,
) -> dict[str, Any]:
"""期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥."""
ensure_bridge_table(conn)
bridges = list_open_bridges(conn)
target = None
for b in bridges:
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
target = b
break
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying)
if target is None:
"""期权已平:卖光交易户该标的可卖币(一次一仓,含盈利币).
规格:平仓卖回禁止按开仓权利金 coin_bought 封顶;仍有可卖则不关桥、不结算.
可传 get_db 可调用对象(推荐)或已打开的 Connection。
卖币(尤其模拟盘写钱包)期间绝不握着业务连接,否则会 database is locked。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
bid: int | None = None
want_inst = str(inst_id or "").strip()
def _pick_bridge(bridges: list[dict[str, Any]]) -> dict[str, Any] | None:
uly_u = (underlying or "").upper()
matched: list[dict[str, Any]] = []
for b in bridges:
if str(b.get("status")) not in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
continue
if uly_u and str(b.get("underlying") or "").upper() != uly_u:
continue
matched.append(b)
if not matched:
return None
if want_inst:
for b in matched:
if str(b.get("inst_id") or "").strip() == want_inst:
return b
return matched[0]
if get_db is not None:
conn = get_db()
try:
ensure_bridge_table(conn)
target = _pick_bridge(list_open_bridges(conn))
if target is not None:
bid = int(target["id"])
try:
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
else:
conn = conn_or_get_db
ensure_bridge_table(conn)
target = _pick_bridge(list_open_bridges(conn))
if target is not None:
bid = int(target["id"])
try:
conn.commit()
except Exception:
pass
# 粉尘:不够现货最小下单量则忽略,不再重试;有桥则直接关闭
try:
avail_chk = fetch_trading_coin_available(ex, underlying)
except Exception:
avail_chk = None
if avail_chk is not None and not is_sellable_spot_coin(ex, underlying, avail_chk):
dust_msg = "粉尘低于现货最小可卖,已忽略"
if bid is None:
return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True, "sell_complete": True}
def _close_dust(c: sqlite3.Connection) -> dict[str, Any]:
update_bridge(
c,
bid,
status=BRIDGE_CLOSED,
inst_id=inst_id,
message=dust_msg,
close=True,
)
return {
"ok": True,
"msg": dust_msg,
"skipped": True,
"dust": True,
"sell_complete": True,
"bridge_id": bid,
"bridge_status": BRIDGE_CLOSED,
}
if get_db is not None:
conn_d = get_db()
try:
return _close_dust(conn_d)
finally:
try:
conn_d.close()
except Exception:
pass
return _close_dust(conn_or_get_db)
# 一次一仓空仓后卖光可用(含盈利币);禁止用 coin_bought/权利金封顶
# 此处无业务连接:模拟盘 convert 可安全另开写钱包
usdt_before_sell = None
try:
from lib.options.options_account_pnl_lib import fetch_trading_usdt
usdt_before_sell = fetch_trading_usdt({"fetch_options_balances": None}, ex)
except Exception:
usdt_before_sell = None
sell = spot_market_sell_coin_to_usdt(
ex,
underlying=underlying,
coin_amount=None,
)
# 实盘订单回报常无 usdt_recovered:用交易账户 USDT 增量补
if sell.get("ok") and _safe_float(sell.get("usdt_recovered")) is None:
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
from lib.options.options_account_pnl_lib import fetch_trading_usdt
invalidate_options_balance_cache()
usdt_after_sell = fetch_trading_usdt({"fetch_options_balances": None}, ex)
if (
usdt_before_sell is not None
and usdt_after_sell is not None
and float(usdt_after_sell) > float(usdt_before_sell)
):
sell = dict(sell)
sell["usdt_recovered"] = round(
float(usdt_after_sell) - float(usdt_before_sell), 8
)
except Exception:
pass
if bid is None:
if not sell.get("ok"):
msg = str(sell.get("msg") or "")
if "无可用" in msg or "过小" in msg:
return {"ok": True, "msg": "无残留币需卖回", "skipped": True}
return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL}
return {"ok": True, "sell": sell, "bridge_status": None}
bid = int(target["id"])
if not sell.get("ok"):
update_bridge(
conn,
return {"ok": True, "msg": "无残留币需卖回", "skipped": True, "sell_complete": True}
return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL, "sell_complete": False}
# 再确认无可卖残留
try:
left = fetch_trading_coin_available(ex, underlying)
except Exception:
left = None
if left is not None and is_sellable_spot_coin(ex, underlying, left):
return {
"ok": False,
"msg": f"卖回未完成,仍有可卖 {str(underlying or '').upper()}",
"incomplete": True,
"sell_complete": False,
"sell": sell,
}
return {"ok": True, "sell": sell, "bridge_status": None, "sell_complete": True}
def _apply_bridge(c: sqlite3.Connection) -> dict[str, Any]:
if not sell.get("ok"):
msg = str(sell.get("msg") or "卖回 USDT 失败")
# 交易所拒单「过小」也按粉尘关闭桥,避免一直 pending 重试
if "过小" in msg or "minimum" in msg.lower() or "min sz" in msg.lower():
update_bridge(
c,
bid,
status=BRIDGE_CLOSED,
inst_id=inst_id,
message="粉尘低于现货最小可卖,已忽略",
close=True,
)
return {
"ok": True,
"msg": "粉尘低于现货最小可卖,已忽略",
"skipped": True,
"dust": True,
"sell_complete": True,
"bridge_id": bid,
"bridge_status": BRIDGE_CLOSED,
}
update_bridge(
c,
bid,
status=BRIDGE_PENDING_SELL,
inst_id=inst_id,
message=msg,
)
return {
"ok": False,
"msg": msg,
"bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL,
"sell_complete": False,
}
recovered = _safe_float(sell.get("usdt_recovered"))
sold_amt = _safe_float(sell.get("coin_sold"))
# 缺 USDT 回报时用成交价×币量补,保证 close_sell VWAP 能落库
if (recovered is None or recovered <= 0) and sold_amt is not None and sold_amt > 0:
fill_px = _safe_float(sell.get("fill_px"))
if fill_px is not None and fill_px > 0:
recovered = round(float(sold_amt) * float(fill_px), 8)
sell = dict(sell)
sell["usdt_recovered"] = recovered
# 平仓后卖回(可多笔):累加 close_sell_* 供卖回 VWAP;开仓多余卖回不走这里
add_bridge_close_sell_fill(
c,
bid,
status=BRIDGE_PENDING_SELL,
inst_id=inst_id,
message=str(sell.get("msg") or "卖回 USDT 失败"),
usdt_recovered=recovered,
coin_sold=sold_amt,
)
# 卖后仍有可卖 → 不关桥、不结算(盈利币未卖回时曾误冻亏损)
try:
left = fetch_trading_coin_available(ex, underlying)
except Exception:
left = None
if left is not None and is_sellable_spot_coin(ex, underlying, left):
left_msg = f"卖回未完成,仍有可卖 {str(underlying or '').upper()}({float(left):g})"
update_bridge(
c,
bid,
status=BRIDGE_PENDING_SELL,
sell_ord_id=str(sell.get("ord_id") or ""),
inst_id=inst_id,
message=left_msg,
)
return {
"ok": False,
"msg": left_msg,
"incomplete": True,
"sell_complete": False,
"sell": sell,
"bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL,
}
sell_vwap = bridge_sell_vwap(c, bid)
update_bridge(
c,
bid,
status=BRIDGE_CLOSED,
sell_ord_id=str(sell.get("ord_id") or ""),
inst_id=inst_id,
message="期权已平,币已卖回 USDT",
close=True,
)
# 卖回 VWAP 写入关联已平仓(覆盖平仓时指数占位价)
if sell_vwap is not None and float(sell_vwap) > 0:
try:
c.execute(
"""
UPDATE options_trades
SET close_index_px = ?
WHERE bridge_id = ? AND status = 'closed'
""",
(float(sell_vwap), int(bid)),
)
except Exception:
pass
return {
"ok": False,
"msg": sell.get("msg") or "卖回 USDT 失败",
"ok": True,
"sell": sell,
"bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL,
"bridge_status": BRIDGE_CLOSED,
"sell_complete": True,
"sell_vwap": sell_vwap,
}
update_bridge(
conn,
bid,
status=BRIDGE_CLOSED,
sell_ord_id=str(sell.get("ord_id") or ""),
coin_sold=_safe_float(sell.get("coin_sold")),
inst_id=inst_id,
message="期权已平,币已卖回 USDT",
close=True,
)
return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED}
if get_db is not None:
conn2 = get_db()
try:
return _apply_bridge(conn2)
finally:
try:
conn2.close()
except Exception:
pass
return _apply_bridge(conn_or_get_db)
def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
def clear_dust_pending_sell_bridges(
conn_or_get_db: Any,
ex: Any | None,
) -> int:
"""待卖回桥若交易户只剩粉尘(或已空),关闭桥并返回关闭条数.
推荐传 get_db:余额查询(模拟盘会另开连接)绝不能套在未关闭的业务连接上。
"""
if ex is None:
return 0
get_db = conn_or_get_db if callable(conn_or_get_db) else None
def _pending(c: sqlite3.Connection) -> list[dict[str, Any]]:
# pending_sell 始终可清粉尘;holding 仅在无对应标的期权仓时清(避免误关持仓中桥)
return [
b
for b in list_open_bridges(c)
if str(b.get("status") or "") in (BRIDGE_PENDING_SELL, BRIDGE_HOLDING)
]
live_uly: set[str] = set()
positions_ok = False
try:
from lib.exchange.okx_options_lib import fetch_option_positions
raw_pos = fetch_option_positions(ex)
if raw_pos is not None:
positions_ok = True
for p in raw_pos 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 inst:
live_uly.add(inst.split("-")[0].upper())
except Exception:
positions_ok = False
live_uly = set()
if get_db is not None:
conn = get_db()
try:
pending = _pending(conn)
try:
conn.commit()
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
else:
conn = conn_or_get_db
pending = _pending(conn)
try:
conn.commit()
except Exception:
pass
to_close: list[dict[str, Any]] = []
for b in pending:
st = str(b.get("status") or "")
uly = str(b.get("underlying") or "ETH").upper() or "ETH"
# holding 仅在「确认无该标的期权仓」时才可按粉尘关闭;持仓查询失败绝不关 holding
if st == BRIDGE_HOLDING and (not positions_ok or uly in live_uly):
continue
try:
avail = fetch_trading_coin_available(ex, uly)
except Exception:
continue
# 余额查不到时:用桥上记录的买入量判断粉尘,避免永久拦开仓
if avail is None:
cb = _safe_float(b.get("coin_bought"))
if cb is None or is_sellable_spot_coin(ex, uly, cb):
continue
to_close.append(b)
continue
if is_sellable_spot_coin(ex, uly, avail):
continue
to_close.append(b)
if not to_close:
return 0
def _close_all(c: sqlite3.Connection) -> int:
n = 0
for b in to_close:
try:
update_bridge(
c,
int(b["id"]),
status=BRIDGE_CLOSED,
message="粉尘低于现货最小可卖,已忽略",
close=True,
)
n += 1
except Exception:
pass
return n
if get_db is not None:
conn2 = get_db()
try:
return _close_all(conn2)
finally:
try:
conn2.close()
except Exception:
pass
return _close_all(conn_or_get_db)
def bridge_blocks_new_open_msg(
conn: sqlite3.Connection,
ex: Any | None = None,
) -> str | None:
_ = ex # 清粉尘请调用方先 clear_dust_pending_sell_bridges(get_db, ex)
bridges = list_open_bridges(conn)
if not bridges:
return None
@@ -363,8 +992,18 @@ def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
return "存在未完成的币本位资金桥,暂不可开仓"
def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None:
def mode_switch_block_msg(
conn: sqlite3.Connection,
ex: Any | None = None,
*,
get_db: Any | None = None,
) -> str | None:
"""有单笔期权仓或未完成桥时禁止切换本位."""
try:
if get_db is not None:
clear_dust_pending_sell_bridges(get_db, ex)
except Exception:
pass
if has_unfinished_bridge(conn):
return "存在未完成的币本位资金桥,禁止切换期权本位模式"
if ex is not None:
+647 -77
View File
@@ -59,10 +59,16 @@ def _underlying_index_usdt(ex: Any, underly: str) -> float | None:
return None
def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]:
"""
统计用:币本位 realized_pnl(ETH/BTC) 按指数折成 U;USDC 原样.
折算失败的币仓剔除盈亏字段,避免把「币数量」当成 U.
def history_pnl_to_usdt(
history: list[dict[str, Any]],
ex: Any = None,
*,
prefer_frozen_usdt: bool = True,
) -> list[dict[str, Any]]:
"""折 U.
prefer_frozen_usdt=True:优先行内 realized_pnl_usdt(实盘 exchange freeze / 旧兼容).
prefer_frozen_usdt=False:口径 B — 币盈亏 × 平仓卖回价(close_index_px),忽略账户差分 A.
"""
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
@@ -73,6 +79,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
if r.get("status") == "open":
out.append(r)
continue
frozen = _safe_float(r.get("realized_pnl_usdt"))
if prefer_frozen_usdt and frozen is not None:
r["realized_pnl"] = round(float(frozen), 4)
out.append(r)
continue
pnl = _safe_float(r.get("realized_pnl"))
if pnl is None:
out.append(r)
@@ -83,9 +94,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
if not ccy:
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
if ccy in ("ETH", "BTC"):
if underly not in idx_cache:
idx_cache[underly] = _underlying_index_usdt(ex, underly)
idx = idx_cache.get(underly)
idx = _safe_float(r.get("close_index_px"))
if idx is None or idx <= 0:
if underly not in idx_cache:
idx_cache[underly] = _underlying_index_usdt(ex, underly)
idx = idx_cache.get(underly)
if idx is None or idx <= 0:
r["realized_pnl"] = None
else:
@@ -96,6 +109,11 @@ def history_pnl_to_usdt(history: list[dict[str, Any]], ex: Any = None) -> list[d
return out
def history_pnl_to_usdt_stats(history: list[dict[str, Any]], ex: Any = None) -> list[dict[str, Any]]:
"""数据统计口径 B:期权历史币盈亏 × 平仓卖回价."""
return history_pnl_to_usdt(history, ex, prefer_frozen_usdt=False)
def _parse_ts(raw: Any) -> datetime | None:
if raw is None or raw == "":
return None
@@ -185,87 +203,635 @@ def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[st
}
def compute_options_stats(get_db) -> dict[str, Any]:
_STATS_WINDOWS = ("today", "7d", "30d", "all")
def normalize_stats_window(raw: Any) -> str:
w = str(raw or "7d").strip().lower()
if w in ("1d", "day", "今日"):
return "today"
if w in ("7", "week"):
return "7d"
if w in ("30", "month"):
return "30d"
if w in ("0", "全部", "total"):
return "all"
return w if w in _STATS_WINDOWS else "7d"
def stats_window_bounds(window: str, *, now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
"""返回闭区间起止(本地/业务时区近似用本地 now).all → (None, None)."""
w = normalize_stats_window(window)
end = now or datetime.now()
if w == "all":
return None, None
if w == "today":
start = end.replace(hour=0, minute=0, second=0, microsecond=0)
return start, end
days = 7 if w == "7d" else 30
from datetime import timedelta
start = end - timedelta(days=days)
return start, end
def filter_history_for_stats_window(
history: list[dict[str, Any]],
window: str,
*,
now: datetime | None = None,
) -> list[dict[str, Any]]:
"""窗口内已平仓 + 当前持仓(open)."""
start, end = stats_window_bounds(window, now=now)
out: list[dict[str, Any]] = []
for row in history or []:
if row.get("status") == "open":
out.append(row)
continue
if start is None:
out.append(row)
continue
ts = _parse_ts(row.get("closed_at")) or _parse_ts(row.get("created_at"))
if ts is None:
continue
if ts < start:
continue
if end is not None and ts > end:
continue
out.append(row)
return out
def compute_options_stats_for_window(
history: list[dict[str, Any]],
window: str,
*,
now: datetime | None = None,
) -> dict[str, Any]:
w = normalize_stats_window(window)
filtered = filter_history_for_stats_window(history, w, now=now)
stats = compute_options_stats_from_history(filtered)
start, end = stats_window_bounds(w, now=now)
stats["window"] = w
stats["window_start"] = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
stats["window_end"] = end.strftime("%Y-%m-%d %H:%M:%S") if end else None
return stats
def _row_get(row: Any, key: str, default: Any = None) -> Any:
try:
if hasattr(row, "keys") and key in row.keys():
return row[key]
except Exception:
pass
if isinstance(row, dict):
return row.get(key, default)
return default
def _closed_trade_dicts(closed_rows) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in closed_rows or []:
out.append(
{
"status": "closed",
"realized_pnl": _row_get(row, "realized_pnl"),
# 口径 B 不读账户 realized_pnl_usdt;保留键位兼容旧调用
"realized_pnl_usdt": None,
"close_index_px": _row_get(row, "close_index_px"),
"created_at": _row_get(row, "created_at"),
"closed_at": _row_get(row, "closed_at"),
"inst_id": _row_get(row, "inst_id") or "",
"underlying": _row_get(row, "underlying") or "",
"premium_ccy": _row_get(row, "premium_ccy") or "",
}
)
return out
# 口径 B:凡有币盈亏的已平均计入;U 由 history_pnl_to_usdt_stats 按卖回价折算
_CLOSED_STATS_SELECT = """
SELECT realized_pnl, close_index_px,
created_at, closed_at, inst_id, underlying, premium_ccy
FROM options_trades
WHERE status = 'closed'
AND realized_pnl IS NOT NULL
"""
def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int:
"""旧已平仓缺冻结 U 时按 close_index/当前指数写一次,之后不再跟盘变.
注意:已有 realized_pnl 视为净额直接冻结,不再二次扣 open_fee/close_fee
(避免对 OKX realizedPnl 或已扣费本地净额重复减费).
"""
init_options_tables(conn)
rows = conn.execute(
"""
SELECT id, realized_pnl, realized_pnl_usdt, close_index_px,
inst_id, underlying, premium_ccy
FROM options_trades
WHERE status = 'closed'
AND realized_pnl_usdt IS NULL
AND realized_pnl IS NOT NULL
AND trading_usdt_before IS NULL
ORDER BY id DESC
LIMIT 300
"""
).fetchall()
if not rows:
return 0
from lib.options.options_pnl_settle_lib import (
freeze_pnl_to_usdt,
resolve_close_index_px,
)
updated = 0
for row in rows:
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper()
idx = _safe_float(_row_get(row, "close_index_px"))
if idx is None or idx <= 0:
idx = resolve_close_index_px(
ex,
inst_id=str(_row_get(row, "inst_id") or ""),
underlying=str(_row_get(row, "underlying") or ""),
)
pnl = _safe_float(_row_get(row, "realized_pnl"))
if pnl is None:
continue
settled = freeze_pnl_to_usdt(pnl, premium_ccy=ccy or "USDC", index_px=idx)
usdt = settled.get("realized_pnl_usdt")
close_idx = settled.get("close_index_px")
if usdt is None:
continue
conn.execute(
"""
UPDATE options_trades
SET realized_pnl_usdt = ?,
close_index_px = COALESCE(close_index_px, ?)
WHERE id = ? AND realized_pnl_usdt IS NULL
""",
(float(usdt), close_idx, int(row["id"])),
)
updated += 1
return updated
def recompute_closed_realized_pnl_usdt(conn, ex: Any = None) -> int:
"""强制按「平仓收回×平仓指数 − 开仓成本U − 手续费」重算已平冻结 U.
旧逻辑用 (收回−已付)×平仓指数,等于把开仓成本也按平仓价重估,ETH 上涨时会偏亏,
导致合计盈亏与余额对不上.有 budget_usdt/open_index_px 时优先用真实开仓成本.
"""
init_options_tables(conn)
rows = conn.execute(
"""
SELECT id, realized_pnl, realized_pnl_usdt, close_index_px, open_index_px,
inst_id, underlying, premium_ccy, premium_paid, premium_received,
open_fee, close_fee, fee_ccy, budget_usdt, close_quote, eth_amount,
open_quote
FROM options_trades
WHERE status = 'closed'
AND premium_paid IS NOT NULL
ORDER BY id DESC
LIMIT 300
"""
).fetchall()
if not rows:
return 0
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl
from lib.options.options_pricing_lib import total_premium
updated = 0
for row in rows:
paid = _safe_float(_row_get(row, "premium_paid"))
recv = _safe_float(_row_get(row, "premium_received"))
if recv is None:
cq = _safe_float(_row_get(row, "close_quote"))
eth = _safe_float(_row_get(row, "eth_amount"))
if cq is not None and eth is not None and eth > 0:
recv = float(total_premium(cq, eth))
if paid is None or recv is None:
continue
ofee = abs(_safe_float(_row_get(row, "open_fee")) or 0.0)
cfee = abs(_safe_float(_row_get(row, "close_fee")) or 0.0)
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper() or "ETH"
close_idx = _safe_float(_row_get(row, "close_index_px"))
open_idx = _safe_float(_row_get(row, "open_index_px"))
budget = _safe_float(_row_get(row, "budget_usdt"))
if close_idx is None or close_idx <= 0:
close_idx = resolve_close_index_px(
ex,
inst_id=str(_row_get(row, "inst_id") or ""),
underlying=str(_row_get(row, "underlying") or ""),
)
if (open_idx is None or open_idx <= 0) and budget and paid > 1e-12:
open_idx = float(budget) / float(paid)
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=recv,
open_fee=ofee,
close_fee=cfee,
premium_ccy=ccy,
index_px=close_idx,
open_index_px=open_idx,
budget_usdt=budget,
fee_ccy=str(_row_get(row, "fee_ccy") or ccy),
)
new_usdt = settled.get("realized_pnl_usdt")
if new_usdt is None:
continue
old_usdt = _safe_float(_row_get(row, "realized_pnl_usdt"))
new_pnl = settled.get("realized_pnl")
# 有变化才写,避免无意义刷库
if (
old_usdt is not None
and abs(float(old_usdt) - float(new_usdt)) < 1e-6
and _safe_float(_row_get(row, "realized_pnl")) is not None
and new_pnl is not None
and abs(float(_row_get(row, "realized_pnl")) - float(new_pnl)) < 1e-10
):
continue
conn.execute(
"""
UPDATE options_trades
SET realized_pnl = COALESCE(?, realized_pnl),
close_index_px = COALESCE(?, close_index_px),
open_index_px = COALESCE(open_index_px, ?),
realized_pnl_usdt = ?
WHERE id = ?
""",
(
new_pnl,
settled.get("close_index_px"),
settled.get("open_index_px"),
float(new_usdt),
int(row["id"]),
),
)
updated += 1
return updated
def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int:
"""兼容旧名:改为强制按开仓成本重算冻结 U."""
return recompute_closed_realized_pnl_usdt(conn, ex)
def _sim_net_usdt_converts_for_trade(
conn,
*,
underlying: str,
start_ts: str,
end_ts: str,
) -> float | None:
"""从 sim_ledger 汇总该标的现货兑换的 USDT 净变动(买币为负、卖回为正)."""
coin = (underlying or "").strip().upper()
if coin not in ("ETH", "BTC") or not start_ts or not end_ts:
return None
try:
rows = conn.execute(
"""
SELECT amount, note FROM sim_ledger_entries
WHERE kind = 'convert'
AND UPPER(ccy) = 'USDT'
AND account = 'trading'
AND ts >= ?
AND ts <= ?
""",
(str(start_ts), str(end_ts)),
).fetchall()
except Exception:
return None
total = 0.0
n = 0
for row in rows or []:
note = str(_row_get(row, "note") or "").upper()
amt = _safe_float(_row_get(row, "amount"))
if amt is None:
continue
related = (
f"{coin}/USDT" in note
or f"{coin}-USDT" in note
or note.strip() == f"FROM {coin}"
)
if not related:
continue
total += float(amt)
n += 1
if n < 1:
return None
return round(total, 4)
def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
"""兼容入口:已按交易账户结算(pnl_settled=1)的永不改写.
有 trading_usdt_before 的待结算单留给卖回结算;
仅无账户快照的旧单才回退权利金×指数冻结.
"""
del ex
init_options_tables(conn)
# 账户快照路径由 sell_residual / maybe_sell 结算,此处不覆盖
return 0
def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]:
"""本地已平(模拟盘口径) + 当前 open."""
init_options_tables(conn)
try:
# 权利金净盈亏冻结 U(纠正旧版把买币预算差额当盈利的数据)
sync_sim_cash_realized_pnl_usdt(conn, ex)
except Exception:
pass
try:
freeze_missing_realized_pnl_usdt(conn, ex)
except Exception:
pass
closed_rows = conn.execute(_CLOSED_STATS_SELECT).fetchall()
open_rows = conn.execute(
"SELECT created_at FROM options_trades WHERE status = 'open'"
).fetchall()
history = _closed_trade_dicts(closed_rows)
for row in open_rows:
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
return history
def _load_exchange_pnl_freeze_map(conn) -> dict[str, dict[str, Any]]:
init_options_tables(conn)
out: dict[str, dict[str, Any]] = {}
try:
rows = conn.execute(
"""
SELECT history_key, realized_pnl_usdt, close_index_px, realized_pnl
FROM options_exchange_pnl_freeze
"""
).fetchall()
except Exception:
return out
for row in rows or []:
key = str(_row_get(row, "history_key") or "").strip()
if not key:
continue
out[key] = {
"realized_pnl_usdt": _row_get(row, "realized_pnl_usdt"),
"close_index_px": _row_get(row, "close_index_px"),
"realized_pnl": _row_get(row, "realized_pnl"),
}
return out
def apply_exchange_pnl_freeze(
conn,
history: list[dict[str, Any]],
ex: Any = None,
) -> list[dict[str, Any]]:
"""写冻结表.行内已有 close_index_px / _freeze_index_hint 时不打网.
若仍缺指数且传了 ex,会在持连接时补拉(仅兼容旧单测).生产请用
load_live_exchange_stats_history(先网后短事务).
"""
freeze_map = _load_exchange_pnl_freeze_map(conn)
enriched: list[dict[str, Any]] = []
need_idx: dict[str, str] = {}
for row in history or []:
r = dict(row)
if r.get("status") == "open":
enriched.append(r)
continue
key = str(r.get("history_key") or "").strip()
pnl = _safe_float(r.get("realized_pnl"))
if (
key
and pnl is not None
and not (
key in freeze_map
and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None
)
and _safe_float(r.get("close_index_px")) is None
and _safe_float(r.get("_freeze_index_hint")) is None
):
underly = str(r.get("underlying") or "").strip().upper()
inst = str(r.get("inst_id") or "")
if not underly and inst:
underly = inst.split("-")[0].strip().upper()
if underly:
need_idx[underly] = inst
enriched.append(r)
if need_idx and ex is not None:
from lib.options.options_pnl_settle_lib import resolve_close_index_px
idx_cache: dict[str, float | None] = {}
for underly, inst in need_idx.items():
idx_cache[underly] = resolve_close_index_px(
ex, inst_id=inst, underlying=underly
)
for r in enriched:
if r.get("status") == "open":
continue
underly = str(r.get("underlying") or "").strip().upper()
inst = str(r.get("inst_id") or "")
if not underly and inst:
underly = inst.split("-")[0].strip().upper()
if underly in idx_cache and _safe_float(r.get("close_index_px")) is None:
r["_freeze_index_hint"] = idx_cache.get(underly)
return _apply_exchange_pnl_freeze_with_hints(conn, enriched, freeze_map)
def compute_options_stats(get_db, ex: Any = None) -> dict[str, Any]:
"""模拟盘数据统计:口径 B(历史币盈亏×卖回价)."""
conn = get_db()
try:
init_options_tables(conn)
closed_rows = conn.execute(
"""
SELECT realized_pnl, created_at, closed_at
FROM options_trades
WHERE status = 'closed' AND realized_pnl IS NOT NULL
"""
).fetchall()
open_rows = conn.execute(
"""
SELECT created_at FROM options_trades WHERE status = 'open'
"""
).fetchall()
return _stats_from_option_trade_rows(closed_rows, open_rows)
history = load_local_options_stats_history(conn, ex)
try:
conn.commit()
except Exception:
pass
return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
finally:
conn.close()
def _stats_from_option_trade_rows(closed_rows, open_rows) -> dict[str, Any]:
wins: list[float] = []
losses: list[float] = []
win_holds: list[float] = []
loss_holds: list[float] = []
all_holds: list[float] = []
now = datetime.now()
def load_live_exchange_stats_history(cfg: dict[str, Any], ex: Any) -> list[dict[str, Any]]:
"""实盘统计用历史:先拉交易所,再无连接时取指数,最后短事务写冻结.
for row in closed_rows:
pnl = float(row["realized_pnl"])
hold = _hold_seconds(row["created_at"], row["closed_at"])
if hold is not None:
all_holds.append(hold)
if pnl > 0:
wins.append(pnl)
if hold is not None:
win_holds.append(hold)
elif pnl < 0:
losses.append(pnl)
if hold is not None:
loss_holds.append(hold)
禁止在持有 DB 连接时打行情/历史网,否则会拖死持仓与中控轮询.
"""
from lib.options.options_history_lib import load_options_history
from lib.options.options_pnl_settle_lib import resolve_close_index_px
open_holds: list[float] = []
for row in open_rows:
start = _parse_ts(row["created_at"])
if start is None:
history = load_options_history(ex, cfg) or []
get_db = cfg["get_db"]
conn = get_db()
try:
freeze_map = _load_exchange_pnl_freeze_map(conn)
try:
conn.commit()
except Exception:
pass
finally:
conn.close()
need_idx: dict[str, str] = {}
for row in history:
if row.get("status") == "open":
continue
sec = (now - start).total_seconds()
if sec >= 0:
open_holds.append(sec)
key = str(row.get("history_key") or "").strip()
if not key or _safe_float(row.get("realized_pnl")) is None:
continue
if key in freeze_map and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None:
continue
underly = str(row.get("underlying") or "").strip().upper()
inst = str(row.get("inst_id") or "")
if not underly and inst:
underly = inst.split("-")[0].strip().upper()
if underly and _safe_float(row.get("close_index_px")) is None:
need_idx[underly] = inst
total_closed = len(wins) + len(losses)
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
avg_win = sum(wins) / len(wins) if wins else None
avg_loss = sum(losses) / len(losses) if losses else None
idx_cache: dict[str, float | None] = {}
for underly, inst in need_idx.items():
idx_cache[underly] = resolve_close_index_px(ex, inst_id=inst, underlying=underly)
total_profit = round(sum(wins), 4) if wins else 0.0
total_loss = round(abs(sum(losses)), 4) if losses else 0.0
net_realized = round(sum(wins) + sum(losses), 4)
return {
"total_closed": total_closed,
"win_count": len(wins),
"loss_count": len(losses),
"win_rate": win_rate,
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
"avg_win": round(avg_win, 4) if avg_win is not None else None,
"avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None,
"total_profit": total_profit,
"total_loss": total_loss,
"net_realized_pnl": net_realized,
"avg_hold_sec": _avg_seconds(all_holds),
"avg_win_hold_sec": _avg_seconds(win_holds),
"avg_loss_hold_sec": _avg_seconds(loss_holds),
"open_count": len(open_holds),
"avg_open_hold_sec": _avg_seconds(open_holds),
}
enriched: list[dict[str, Any]] = []
for row in history:
r = dict(row)
if r.get("status") != "open":
underly = str(r.get("underlying") or "").strip().upper()
inst = str(r.get("inst_id") or "")
if not underly and inst:
underly = inst.split("-")[0].strip().upper()
key = str(r.get("history_key") or "").strip()
if underly in idx_cache and _safe_float(r.get("close_index_px")) is None:
if not (
key in freeze_map
and _safe_float(freeze_map[key].get("realized_pnl_usdt")) is not None
):
r["_freeze_index_hint"] = idx_cache.get(underly)
enriched.append(r)
conn = get_db()
try:
out = _apply_exchange_pnl_freeze_with_hints(conn, enriched, freeze_map)
try:
conn.commit()
except Exception:
pass
return out
finally:
conn.close()
def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) -> dict[str, Any]:
"""顶栏总交易/胜率/盈亏比:纯期权(options_trades),全站导航共用,按列表窗过滤."""
def compute_options_stats_live_exchange(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
"""实盘:与「期权历史」同一数据源;口径 B=币盈亏×卖回价(freeze)."""
history = load_live_exchange_stats_history(cfg, ex)
return compute_options_stats_from_history(
history_pnl_to_usdt(history, ex, prefer_frozen_usdt=True)
)
def _apply_exchange_pnl_freeze_with_hints(
conn,
history: list[dict[str, Any]],
freeze_map: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""短事务写冻结;指数只用来自行内 _freeze_index_hint / 已有 close_index_px."""
from lib.options.options_pnl_settle_lib import settle_exchange_realized_pnl
from lib.options.options_time_lib import options_app_now_str
init_options_tables(conn)
fmap = freeze_map if freeze_map is not None else _load_exchange_pnl_freeze_map(conn)
out: list[dict[str, Any]] = []
for row in history or []:
r = dict(row)
hint = r.pop("_freeze_index_hint", None)
if r.get("status") == "open":
out.append(r)
continue
key = str(r.get("history_key") or "").strip()
pnl = _safe_float(r.get("realized_pnl"))
if not key or pnl is None:
out.append(r)
continue
frozen = fmap.get(key)
if frozen is not None and _safe_float(frozen.get("realized_pnl_usdt")) is not None:
r["realized_pnl_usdt"] = frozen.get("realized_pnl_usdt")
r["close_index_px"] = frozen.get("close_index_px")
out.append(r)
continue
ccy = str(r.get("premium_ccy") or "").strip().upper()
idx = _safe_float(r.get("close_index_px")) or _safe_float(hint)
settled = settle_exchange_realized_pnl(
realized_pnl=pnl,
premium_ccy=ccy or "ETH",
index_px=idx,
)
usdt = settled.get("realized_pnl_usdt")
close_idx = settled.get("close_index_px")
if usdt is not None:
try:
conn.execute(
"""
INSERT INTO options_exchange_pnl_freeze(
history_key, realized_pnl, realized_pnl_usdt, close_index_px,
premium_ccy, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(history_key) DO UPDATE SET
realized_pnl = excluded.realized_pnl,
realized_pnl_usdt = excluded.realized_pnl_usdt,
close_index_px = excluded.close_index_px,
premium_ccy = excluded.premium_ccy,
updated_at = excluded.updated_at
""",
(
key,
float(pnl),
float(usdt),
close_idx,
ccy or "",
options_app_now_str(),
),
)
except Exception:
pass
r["realized_pnl_usdt"] = float(usdt)
r["close_index_px"] = close_idx
out.append(r)
return out
def compute_options_stats_auto(cfg: dict[str, Any], ex: Any = None) -> dict[str, Any]:
"""模拟盘走本地结算;实盘走交易所期权历史原生盈亏."""
get_db = cfg.get("get_db")
try:
from lib.sim.mode_lib import is_sim_mode
if callable(get_db) and is_sim_mode(get_db):
return compute_options_stats(get_db, ex)
except Exception:
pass
if ex is None:
ex = cfg.get("exchange_options")
return compute_options_stats_live_exchange(cfg, ex)
def _stats_from_option_trade_rows(closed_rows, open_rows, *, ex: Any = None) -> dict[str, Any]:
"""兼容旧调用;内部统一折 U 后走 compute_options_stats_from_history."""
history = _closed_trade_dicts(closed_rows)
for row in open_rows or []:
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
return compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
def header_options_stats_for_window(
conn,
list_window: dict[str, Any],
app_tz,
ex: Any = None,
) -> dict[str, Any]:
"""顶栏总交易/胜率/盈亏比:口径 B(历史币盈亏×卖回价)."""
from lib.common.history_window_lib import utc_window_to_bj_sql_strings
init_options_tables(conn)
@@ -274,7 +840,8 @@ def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) -
)
closed_rows = conn.execute(
"""
SELECT realized_pnl, created_at, closed_at
SELECT realized_pnl, close_index_px,
created_at, closed_at, inst_id, underlying, premium_ccy
FROM options_trades
WHERE status = 'closed'
AND realized_pnl IS NOT NULL
@@ -286,7 +853,10 @@ def header_options_stats_for_window(conn, list_window: dict[str, Any], app_tz) -
open_rows = conn.execute(
"SELECT created_at FROM options_trades WHERE status = 'open'"
).fetchall()
stats = _stats_from_option_trade_rows(closed_rows, open_rows)
history = _closed_trade_dicts(closed_rows)
for row in open_rows:
history.append({"status": "open", "created_at": _row_get(row, "created_at")})
stats = compute_options_stats_from_history(history_pnl_to_usdt_stats(history, ex))
return {
"total": int(stats.get("total_closed") or 0),
"rate": float(stats.get("win_rate") or 0),
+65 -36
View File
@@ -360,7 +360,7 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
def run_options_target_closes(
conn: sqlite3.Connection,
conn_or_get_db: Any,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
@@ -371,17 +371,17 @@ def run_options_target_closes(
) -> int:
"""
扫描 active 目标委托;指数到位后限价平仓.
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
未完全成交进入 closing,仅重试平仓不再推送.
返回本次新触发(并推送)的条数.
可传 get_db:平仓在无业务连接时执行,避免嵌套锁库。
"""
get_db = conn_or_get_db if callable(conn_or_get_db) else None
conn = conn_or_get_db() if get_db else conn_or_get_db
ensure_target_tables(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
live_ids = {k for k in pos_by_inst if k}
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
_commit_monitor(conn)
# 先处理已挂单等待成交的,绝不再发微信
retry_jobs: list[dict[str, Any]] = []
for mon in list_closing_targets(conn):
inst_id = str(mon.get("inst_id") or "")
if not inst_id:
@@ -390,30 +390,9 @@ def run_options_target_closes(
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn)
continue
result = close_fn(inst_id)
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
if result.get("already_flat") or _result_fully_done(result):
mark_monitor(
conn,
int(mon["id"]),
status="triggered",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位限价平仓完成",
)
_commit_monitor(conn)
continue
mark_monitor(
conn,
int(mon["id"]),
status="closing",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
)
_commit_monitor(conn)
retry_jobs.append(dict(mon))
triggered = 0
trigger_jobs: list[tuple[dict[str, Any], float, float]] = []
for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "")
target = _safe_float(mon.get("target_index"))
@@ -431,35 +410,80 @@ def run_options_target_closes(
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue
trigger_jobs.append((dict(mon), float(idx), float(target)))
_commit_monitor(conn)
if get_db is not None:
try:
conn.close()
except Exception:
pass
conn = None
def _ensure_conn() -> sqlite3.Connection:
nonlocal conn
if conn is not None:
return conn
assert get_db is not None
conn = get_db()
ensure_target_tables(conn)
return conn
for mon in retry_jobs:
inst_id = str(mon.get("inst_id") or "")
result = close_fn(inst_id)
c = _ensure_conn()
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
if result.get("already_flat") or _result_fully_done(result):
mark_monitor(
c,
int(mon["id"]),
status="triggered",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位限价平仓完成",
)
else:
mark_monitor(
c,
int(mon["id"]),
status="closing",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
)
_commit_monitor(c)
triggered = 0
for mon, idx, target in trigger_jobs:
inst_id = str(mon.get("inst_id") or "")
result = close_fn(inst_id)
c = _ensure_conn()
if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn)
mark_monitor(c, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(c)
continue
if not result.get("ok"):
mark_monitor(
conn,
c,
int(mon["id"]),
status="active",
trigger_idx=idx,
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
)
_commit_monitor(conn)
_commit_monitor(c)
continue
done = _result_fully_done(result)
status = "triggered" if done else "closing"
mark_monitor(
conn,
c,
int(mon["id"]),
status=status,
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
)
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
_commit_monitor(conn)
_commit_monitor(c)
triggered += 1
_notify_target_close(
cfg,
@@ -469,6 +493,11 @@ def run_options_target_closes(
target=target,
idx=idx,
result=result,
conn=conn,
conn=c,
)
if get_db is not None and conn is not None:
try:
conn.close()
except Exception:
pass
return triggered
+14 -5
View File
@@ -112,11 +112,16 @@ def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str,
init_options_tables(conn)
trades = _shift_table("options_trades", ("created_at", "closed_at"))
except sqlite3.OperationalError:
return {"skipped": 0, "trades": 0, "review": 0, "hidden": 0, "locked": 1}
except Exception:
trades = 0
review = _shift_table("options_review_trades", ("opened_at", "closed_at"))
hidden = _shift_table("options_review_hidden", ("closed_at",))
try:
review = _shift_table("options_review_trades", ("opened_at", "closed_at"))
hidden = _shift_table("options_review_hidden", ("closed_at",))
except sqlite3.OperationalError:
return {"skipped": 0, "trades": trades, "review": 0, "hidden": 0, "locked": 1}
set_sync_state(conn, _MIGRATE_FLAG, "1")
return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden}
@@ -129,7 +134,7 @@ def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str,
幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修).
"""
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import set_sync_state
from lib.options.options_review_lib import get_sync_state, set_sync_state
init_options_review_tables(conn)
trades_fixed = 0
@@ -174,9 +179,13 @@ def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str,
(note, hk, closed),
)
review_fixed += int(cur.rowcount or 0)
except sqlite3.OperationalError:
# 并发刷新撞锁时跳过本轮修复,不影响列表读取
return {"trades": 0, "review": 0, "locked": 1}
except Exception:
pass
# 复盘表里仍残留非时间戳 closed_at 的,若能从 local_opt 源修好则已处理;否则仅标记完成
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
# 已标记过则不再写同步状态,减少并发写冲突
if get_sync_state(conn, _SWAP_REPAIR_FLAG) != "1":
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
return {"trades": trades_fixed, "review": review_fixed}
+147
View File
@@ -0,0 +1,147 @@
"""期权账户内划转执行(本机设置 / 中控 Bridge 共用)."""
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 _pick_stable(bal: dict[str, Any] | None, *, account: str, ccy: str) -> float | None:
if not isinstance(bal, dict):
return None
acct = "trading" if account == "trading" else "funding"
ccy_l = (ccy or "usdt").strip().lower() or "usdt"
avail = _safe_float(bal.get(f"{acct}_{ccy_l}_avail"))
if avail is not None:
return round(float(avail), 8)
total = _safe_float(bal.get(f"{acct}_{ccy_l}"))
if total is not None:
return round(float(total), 8)
return None
def execute_options_internal_transfer(
cfg: dict[str, Any],
*,
amount: Any,
from_acct: str = "funding",
to_acct: str = "trading",
ccy: str = "USDT",
) -> dict[str, Any]:
"""资金户↔交易户划转;持仓/待卖回拒绝.成功后附带最新 USDT 余额."""
from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg
from lib.options.options_db import init_options_tables
ex = cfg.get("exchange_options")
ready_fn = cfg.get("options_api_ready")
if callable(ready_fn):
ok, reason = ready_fn(ex)
if not ok:
return {"ok": False, "msg": reason or "期权 API 未配置"}
if ex is None:
return {"ok": False, "msg": "交易所未就绪"}
src = (from_acct or "funding").strip().lower()
dst = (to_acct or "trading").strip().lower()
if src not in ("funding", "trading") or dst not in ("funding", "trading"):
return {"ok": False, "msg": "仅支持 funding/trading"}
if src == dst:
return {"ok": False, "msg": "转出与转入账户不能相同"}
ccy_u = (ccy or "USDT").strip().upper() or "USDT"
if ccy_u not in ("USDT", "USDC"):
return {"ok": False, "msg": "仅支持 USDT/USDC"}
amt = _safe_float(amount)
if amt is None or amt <= 0:
return {"ok": False, "msg": "数量无效"}
get_db = cfg.get("get_db")
if not callable(get_db):
return {"ok": False, "msg": "数据库未就绪"}
conn = get_db()
try:
block = transfer_blocked_by_position_msg(conn, ex)
if block:
return {"ok": False, "msg": block, "transfer_blocked": True}
finally:
conn.close()
xfer = cfg.get("transfer_ccy")
if not callable(xfer):
try:
import lib.exchange.okx_options_lib as _opt_lib
xfer = getattr(_opt_lib, "transfer_ccy", None)
except Exception:
xfer = None
if not callable(xfer):
return {"ok": False, "msg": "划转不可用"}
result = xfer(ex, ccy_u, float(amt), src, dst)
if not isinstance(result, dict):
return {"ok": False, "msg": "划转返回异常"}
out = dict(result)
if not out.get("ok"):
out.setdefault("msg", "划转失败")
return out
conn2 = get_db()
try:
init_options_tables(conn2)
conn2.execute(
"""
INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
VALUES (?, ?, ?, ?, 'ok', ?)
""",
(ccy_u, float(amt), src, dst, ""),
)
conn2.commit()
except Exception:
try:
conn2.rollback()
except Exception:
pass
finally:
conn2.close()
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
from lib.instance.instance_live_push_lib import notify_instance_balance_changed
invalidate_options_balance_cache()
app_mod = cfg.get("app_module")
if app_mod is not None and hasattr(app_mod, "invalidate_account_balance_cache"):
app_mod.invalidate_account_balance_cache()
notify_instance_balance_changed()
except Exception:
pass
bal = {}
try:
fetch_bal = cfg.get("fetch_options_balances")
if callable(fetch_bal):
bal = fetch_bal(ex) or {}
except Exception:
bal = {}
trading_usdt = _pick_stable(bal, account="trading", ccy="USDT")
funding_usdt = _pick_stable(bal, account="funding", ccy="USDT")
out.update(
{
"ok": True,
"msg": out.get("msg") or "划转成功",
"ccy": ccy_u,
"amount": round(float(amt), 8),
"from": src,
"to": dst,
"trading_usdt": trading_usdt,
"funding_usdt": funding_usdt,
"transfer_blocked": False,
}
)
return out
+56
View File
@@ -0,0 +1,56 @@
"""期权标的白名单:OKX_OPTIONS_UNDERLYINGS + DEFAULT_UNDERLY."""
from __future__ import annotations
import os
from typing import Any
_ALLOWED = ("ETH", "BTC")
def parse_options_underlyings() -> list[str]:
"""env 白名单;空或缺省为 ETH,BTC.仅认 ETH/BTC."""
raw = (os.getenv("OKX_OPTIONS_UNDERLYINGS") or "").strip()
if not raw:
return ["ETH", "BTC"]
out: list[str] = []
for part in raw.replace(";", ",").split(","):
u = part.strip().upper()
if u in _ALLOWED and u not in out:
out.append(u)
return out or ["ETH", "BTC"]
def resolve_default_underly(allowed: list[str] | None = None) -> str:
"""DEFAULT 落在白名单内;否则取白名单第一项."""
allow = list(allowed) if allowed is not None else parse_options_underlyings()
if not allow:
allow = ["ETH", "BTC"]
d = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
if d in allow:
return d
return allow[0]
def underly_from_inst_id(inst_id: str) -> str:
return (str(inst_id or "").split("-")[0] or "").strip().upper()
def underly_not_allowed_msg(underlying: str, allowed: list[str] | None = None) -> str | None:
"""白名单外返回拒因;允许则 None."""
allow = list(allowed) if allowed is not None else parse_options_underlyings()
u = (underlying or "").strip().upper()
if u in allow:
return None
return f"标的 {u or '—'} 不在本机白名单({','.join(allow) or '—'})"
def inst_underly_not_allowed_msg(inst_id: str, allowed: list[str] | None = None) -> str | None:
return underly_not_allowed_msg(underly_from_inst_id(inst_id), allowed)
def underly_cfg_snapshot() -> dict[str, Any]:
allow = parse_options_underlyings()
return {
"allowed_underlys": allow,
"default_underly": resolve_default_underly(allow),
}
+26 -12
View File
@@ -1,6 +1,9 @@
{% set _uly_allowed = options_allowed_underlys if options_allowed_underlys is defined and options_allowed_underlys else ['ETH', 'BTC'] %}
{% set _uly_default = options_default_underly | default(_uly_allowed[0] if _uly_allowed else 'ETH') %}
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-default-underly="{{ _uly_default }}"
data-allowed-underlys="{{ _uly_allowed | join(',') }}"
data-budget-buffer="{{ options_budget_buffer | default(1) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}"
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
@@ -13,6 +16,15 @@
{% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
<div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div>
{% endif %}
<div class="opt-cond-order-card" id="opt-cond-order-card" hidden>
<div class="opt-cond-order-head">
<strong>中控条件单</strong>
<span class="opt-cond-st" id="opt-cond-order-st"></span>
<span class="opt-cond-lock-note">已接收 · 禁止手动开仓</span>
</div>
<div class="opt-cond-order-grid" id="opt-cond-order-grid"></div>
<div class="opt-cond-order-msg" id="opt-cond-order-msg"></div>
</div>
<div class="options-dual-grid">
<div class="card options-order-card"{% if options_open_allowed is defined and not options_open_allowed %} style="opacity:.72"{% endif %}>
@@ -27,9 +39,9 @@
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(1)|float) }}</strong> 算张数(env 可改)。默认选中打满。</li>
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code> 默认):按最大可开张数×卖一权利金×现货缓冲(<code>OKX_OPTIONS_COIN_SPOT_BUY_BUFFER</code> 默认1.10)买 ETH/BTC,不全额兑换;平仓后卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。</li>
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code> 默认):按最大可开张数×卖一权利金×现货缓冲(<code>OKX_OPTIONS_COIN_SPOT_BUY_BUFFER</code> 默认1.05)买 ETH/BTC,不全额兑换;平仓后卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。</li>
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
</ul>
<p class="options-settings-subtitle" style="margin:10px 0 4px">买一平仓</p>
@@ -45,8 +57,9 @@
</div>
</details>
<div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
{% for u in _uly_allowed %}
<button type="button" class="btn-secondary opt-uly-btn{% if u == _uly_default %} active{% endif %}" data-uly="{{ u }}">{{ u }}</button>
{% endfor %}
<select id="opt-exp-select"><option value="">选择到期日</option></select>
<span class="opt-chain-view-group">
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
@@ -74,6 +87,7 @@
<th>合约</th>
<th>卖一/张</th>
<th title="USDC:指数÷卖一;币本位:1÷卖一(卖一为币报价)">杠杆</th>
<th title="OKX 行情 Delta(约 -1~1)">Delta</th>
<th>买一/张</th>
<th>到期平衡</th>
<th>平衡价差</th>
@@ -97,7 +111,7 @@
</tr>
</thead>
<tbody id="opt-strike-tbody">
<tr><td colspan="9" class="muted">请选择到期日</td></tr>
<tr><td colspan="10" class="muted">请选择到期日</td></tr>
</tbody>
</table>
</div>
@@ -150,13 +164,13 @@
<div class="form-row options-order-mode-row">
<div class="opt-size-mode-bar">
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
<input type="radio" name="opt-size-mode" value="sheets">
<span>指定张数</span>
</label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}>
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
<input type="radio" name="opt-size-mode" value="budget_full"{% if not compound_on %} checked{% endif %}{% if compound_on %} disabled{% endif %}>
<span>按可用余额打满</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}>
@@ -197,7 +211,7 @@
<div class="options-pos-head-actions">
<div id="opt-spot-sell-head" class="opt-spot-sell-head" hidden>
<span class="opt-spot-sell-avail" id="opt-spot-sell-avail-text"></span>
<button type="button" class="btn-primary opt-spot-sell-retry-btn" id="opt-spot-sell-retry-btn">重试卖回 ETH</button>
<button type="button" class="btn-primary opt-spot-sell-retry-btn" id="opt-spot-sell-retry-btn">重试卖回</button>
</div>
<button type="button" class="btn-secondary opt-pos-refresh-btn" id="opt-refresh-positions" title="刷新" aria-label="刷新">↻</button>
</div>
@@ -336,7 +350,7 @@
<th>张数</th>
<th>权利金</th>
<th>状态</th>
<th>盈亏</th>
<th title="仅期权腿币收益,不含现货买/卖回">盈亏</th>
<th>时间</th>
<th>操作</th>
</tr>
@@ -355,4 +369,4 @@
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=65"></script>
<script src="/static/options_panel.js?v=73"></script>
@@ -76,7 +76,8 @@
}
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem;overflow-x:auto}
.or-reviewed-table{min-width:980px}
.or-reviewed-table{min-width:1180px}
.or-trades-table{min-width:1080px}
.or-kpi-row{
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
gap:8px;margin-bottom:12px;
@@ -228,7 +229,7 @@
<span class="or-step" aria-hidden="true">1</span>
<div>
<div class="or-section-title" id="or-list-title">期权交易记录</div>
<p class="or-section-desc">点「复盘」填写表单;已复盘仍保留在此,也可在下方查看详情。</p>
<p class="or-section-desc">期权盈亏=期权历史币盈亏×平仓价(有卖回 VWAP 优先,否则用冻结平仓价)。缺价标「缺价未核」。期权历史仅记币收益。</p>
</div>
</div>
<div class="options-strike-table-wrap or-trades-table-wrap" id="or-trades-wrap">
@@ -237,7 +238,10 @@
<tr>
<th>类型</th>
<th>标的/合约</th>
<th>盈亏</th>
<th>初始权利金</th>
<th title="期权历史币盈亏×平仓价">期权盈亏</th>
<th>盈利率</th>
<th title="仅期权开/平手续费">手续费</th>
<th>开仓时间</th>
<th>平仓时间</th>
<th>持有</th>
@@ -245,7 +249,7 @@
</tr>
</thead>
<tbody id="or-trades-tbody">
<tr><td colspan="7" class="muted">加载中…</td></tr>
<tr><td colspan="10" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
@@ -280,6 +284,9 @@
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-pnl" name="or_f_pnl" placeholder="盈亏(U)"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-initial-prem" name="or_f_initial_prem" placeholder="初始权利金(U)" readonly autocomplete="off">
<input type="text" id="or-f-profit-rate" name="or_f_profit_rate" placeholder="盈利率" readonly autocomplete="off">
<input type="text" id="or-f-option-fee" name="or_f_option_fee" placeholder="期权手续费(U)" readonly autocomplete="off" title="仅期权开/平手续费,不含现货">
<input type="text" id="or-f-hold" name="or_f_hold" placeholder="持有时长" readonly autocomplete="off">
</div>
<div class="or-form-grid2">
@@ -314,6 +321,15 @@
<select id="or-f-entry" title="入场逻辑" style="width:100%;margin-bottom:6px;box-sizing:border-box">
<option value="">入场逻辑</option>
</select>
<select id="or-f-prev-tf" title="入场周期" style="width:100%;margin-bottom:6px;box-sizing:border-box">
<option value="">入场周期</option>
<option value="15m">15m</option>
<option value="1h">1h</option>
<option value="2h">2h</option>
<option value="4h">4h</option>
<option value="8h">8h</option>
<option value="12h">12h</option>
</select>
<div class="journal-upload-slots" id="or-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %}
@@ -329,7 +345,7 @@
<div class="form-row or-chart-options journal-chart-options hidden" id="or-chart-options" style="flex-wrap:wrap;align-items:center">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem">
<input type="checkbox" id="or-exchange-chart" value="true">
<input type="checkbox" id="or-exchange-chart" value="true" checked>
保存后后台生成 K 线图并作为截图(不阻塞保存)
</label>
<label style="font-size:.82rem;color:#9aa">周期1</label>
@@ -394,7 +410,9 @@
<th>类型</th>
<th>标的/合约</th>
<th>方向</th>
<th>初始权利金</th>
<th>盈亏</th>
<th>盈利率</th>
<th>开仓时间</th>
<th>平仓时间</th>
<th>持仓时长</th>
@@ -405,7 +423,7 @@
</tr>
</thead>
<tbody id="or-reviewed-tbody">
<tr><td colspan="11" class="muted">加载中…</td></tr>
<tr><td colspan="13" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
@@ -447,4 +465,4 @@
</section>
</div>
<script src="/static/options_review.js?v=23"></script>
<script src="/static/options_review.js?v=30"></script>
+162 -37
View File
@@ -273,6 +273,8 @@ class SimBroker:
side = (direction or "").lower().strip()
if side not in ("long", "short"):
raise ValueError("direction 须为 long 或 short")
# 行情先拉,再开库,避免握锁等网络
bid, ask = _ticker_bid_ask(exchange, symbol)
conn = self.get_db()
try:
row = conn.execute(
@@ -289,7 +291,6 @@ class SimBroker:
if close_c <= 0:
raise ValueError("平仓张数无效")
fr = sim_fee_rate(fee_rate)
bid, ask = _ticker_bid_ask(exchange, symbol)
qty = close_c * ct_sz
pr = perp_fill(side=side, action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr)
if side == "long":
@@ -358,16 +359,30 @@ class SimBroker:
side: str,
sheets: float,
avg_px: float,
fee: float = 0.0,
fee_ccy: str | None = None,
) -> None:
conn = self.get_db()
try:
ccy = str(fee_ccy or _premium_ccy_for_inst(inst_id) or "").strip().upper()
conn.execute(
"""
INSERT OR REPLACE INTO sim_option_orders(
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
) VALUES (?,?,?,?,?,'filled',?,?)
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz,
fee, fee_ccy, created_at
) VALUES (?,?,?,?,?,'filled',?,?,?,?)
""",
(ord_id, inst_id, side, float(sheets), float(avg_px), float(sheets), _now()),
(
ord_id,
inst_id,
side,
float(sheets),
float(avg_px),
float(sheets),
float(fee or 0),
ccy,
_now(),
),
)
conn.commit()
finally:
@@ -382,6 +397,11 @@ class SimBroker:
).fetchone()
if not row:
return None
keys = set(row.keys()) if hasattr(row, "keys") else set()
fee = float(row["fee"]) if "fee" in keys and row["fee"] is not None else 0.0
fee_ccy = str(row["fee_ccy"] or "").strip().upper() if "fee_ccy" in keys else ""
if not fee_ccy:
fee_ccy = _premium_ccy_for_inst(row["inst_id"])
return {
"ok": True,
"ord_id": row["ord_id"],
@@ -390,7 +410,10 @@ class SimBroker:
"state": row["state"],
"acc_fill_sz": float(row["acc_fill_sz"]),
"avg_px": float(row["avg_px"]),
"fee": -abs(fee) if fee else 0.0,
"fee_ccy": fee_ccy,
"sim": True,
"raw": {"fee": -abs(fee) if fee else 0.0, "feeCcy": fee_ccy, "sim": True},
}
finally:
conn.close()
@@ -468,15 +491,23 @@ class SimBroker:
ord_id = f"sim-opt-{uuid.uuid4().hex[:16]}"
self._store_option_order(
ord_id=ord_id, inst_id=inst_id, side="buy", sheets=n, avg_px=pr.fill_px
ord_id=ord_id,
inst_id=inst_id,
side="buy",
sheets=n,
avg_px=pr.fill_px,
fee=float(pr.fee or 0),
fee_ccy=prem_ccy,
)
return {
"ok": True,
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
"raw": {"sim": True},
"raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy},
"px": pr.fill_px,
"ord_type": "ioc",
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
"fee": float(pr.fee or 0),
"fee_ccy": prem_ccy,
"info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "fill": pr.to_dict()},
}
def sell_option_close(
@@ -492,6 +523,10 @@ class SimBroker:
n = int(sheets)
if n < 1:
return {"ok": False, "msg": "张数至少为 1"}
# 行情先拉,再开库,避免握锁等网络/嵌套写钱包
bid, ask, _ = _option_bid_ask(exchange_options, inst_id)
if price is not None and float(price) > 0:
bid = max(float(bid), float(price)) if float(price) > 0 else float(bid)
conn = self.get_db()
try:
row = conn.execute(
@@ -506,9 +541,6 @@ class SimBroker:
if close_n <= 0:
return {"ok": False, "msg": "平仓张数无效"}
fr = sim_fee_rate(fee_rate)
bid, ask, _ = _option_bid_ask(exchange_options, inst_id)
if price is not None and float(price) > 0:
bid = max(float(bid), float(price)) if float(price) > 0 else float(bid)
qty = close_n * ct_mult
pr = option_fill(action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr)
credit = pr.notional - pr.fee
@@ -538,16 +570,25 @@ class SimBroker:
note=f"sell {inst_id} x{close_n}@{pr.fill_px}",
)
ord_id = f"sim-opt-close-{uuid.uuid4().hex[:16]}"
prem_ccy = _premium_ccy_for_inst(inst_id)
self._store_option_order(
ord_id=ord_id, inst_id=inst_id, side="sell", sheets=close_n, avg_px=pr.fill_px
ord_id=ord_id,
inst_id=inst_id,
side="sell",
sheets=close_n,
avg_px=pr.fill_px,
fee=float(pr.fee or 0),
fee_ccy=prem_ccy,
)
return {
"ok": True,
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
"raw": {"sim": True},
"raw": {"sim": True, "fee": pr.fee, "feeCcy": prem_ccy},
"px": pr.fill_px,
"ord_type": "ioc",
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
"fee": float(pr.fee or 0),
"fee_ccy": prem_ccy,
"info": {"sim": True, "fee": pr.fee, "fee_ccy": prem_ccy, "fill": pr.to_dict()},
}
def convert_usdt_usdc(
@@ -749,40 +790,100 @@ class SimBroker:
prem_ccy = _premium_ccy_for_inst(inst_id)
# 币本位到期兑付用币数量: 实值/指数 × 张数 × 乘数
# 注意: option_expiry_pnl 的 intrinsic 是 U,减权利金时币本位不能直接用
if prem_ccy in ("ETH", "BTC") and float(spot) > 0:
settle_recv = round(
max(0.0, (intrinsic_u / float(spot)) * sheets * ct_mult),
8,
)
# 币盈亏 = 兑付币 − 仓位权利金币(与 options_trades.premium_paid 同口径)
pnl = round(float(settle_recv) - float(prem), 8)
else:
settle_recv = round(max(0.0, prem + pnl), 4)
pnl = round(float(pnl), 4)
conn = self.get_db()
try:
conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,))
try:
closed_at = _now()
conn.execute(
open_rows = conn.execute(
"""
UPDATE options_trades
SET status = 'closed',
close_quote = ?,
premium_received = ?,
realized_pnl = ?,
closed_at = COALESCE(closed_at, ?),
signal_note = CASE
WHEN signal_note IS NULL OR TRIM(signal_note) = ''
THEN '到期结算'
ELSE signal_note
END
WHERE inst_id = ? AND status = 'open'
SELECT id, premium_paid, open_fee, close_fee
FROM options_trades
WHERE inst_id=? AND status='open'
""",
(
round(intrinsic_u, 6),
settle_recv,
round(pnl, 4),
closed_at,
inst_id,
),
)
(inst_id,),
).fetchall()
open_ids = [int(r["id"]) for r in open_rows]
total_paid = 0.0
for r in open_rows:
try:
total_paid += float(r["premium_paid"] or 0)
except (TypeError, ValueError):
pass
n_open = len(open_rows) or 1
for r in open_rows:
paid_i = 0.0
try:
paid_i = float(r["premium_paid"] or 0)
except (TypeError, ValueError):
paid_i = 0.0
ofee_i = 0.0
try:
ofee_i = abs(float(r["open_fee"] or 0))
except (TypeError, ValueError):
ofee_i = 0.0
# 多笔开仓按权利金占比拆兑付;单笔拿全部
if n_open == 1:
recv_i = float(settle_recv)
elif total_paid > 1e-12:
recv_i = float(settle_recv) * (paid_i / total_paid)
else:
recv_i = float(settle_recv) / float(n_open)
if prem_ccy in ("ETH", "BTC"):
recv_i = round(recv_i, 8)
pnl_i = round(recv_i - paid_i - ofee_i, 8)
else:
recv_i = round(recv_i, 4)
pnl_i = round(recv_i - paid_i - ofee_i, 4)
conn.execute(
"""
UPDATE options_trades
SET status = 'closed',
close_quote = ?,
premium_received = ?,
realized_pnl = ?,
close_index_px = COALESCE(close_index_px, ?),
closed_at = COALESCE(closed_at, ?),
signal_note = CASE
WHEN signal_note IS NULL OR TRIM(signal_note) = ''
THEN '到期结算'
ELSE signal_note
END
WHERE id = ? AND status = 'open'
""",
(
round(intrinsic_u, 6),
recv_i,
pnl_i,
float(spot),
closed_at,
int(r["id"]),
),
)
if open_ids:
from lib.options.options_notify_lib import (
active_options_notify_cfg,
notify_options_close_trade_ids,
)
notify_options_close_trade_ids(
active_options_notify_cfg(),
conn,
open_ids,
reason="到期结算",
)
except Exception:
pass
conn.commit()
@@ -810,6 +911,20 @@ class SimBroker:
forget_close_gate_for_inst(inst_id)
except Exception:
pass
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat
if margin_mode_from_inst_id(inst_id) == "coin":
uly = inst_id.split("-")[0].upper() if "-" in inst_id else "ETH"
sell_residual_after_option_flat(
self.get_db,
exchange,
underlying=uly,
inst_id=inst_id,
)
except Exception as e:
print(f"[sim_expiry_sell] {inst_id}: {e}", flush=True)
settled.append(
{
"inst_id": inst_id,
@@ -1146,10 +1261,17 @@ class SimBroker:
conn = self.get_db()
try:
init_options_tables(conn)
try:
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
sync_sim_cash_realized_pnl_usdt(conn, exchange)
conn.commit()
except Exception:
pass
sql = """
SELECT id, inst_id, underlying, sheets, open_quote, close_quote,
premium_paid, premium_received, realized_pnl, premium_ccy,
created_at, closed_at
premium_paid, premium_received, realized_pnl, realized_pnl_usdt,
premium_ccy, created_at, closed_at
FROM options_trades
WHERE status = 'closed'
"""
@@ -1176,6 +1298,7 @@ class SimBroker:
paid = float(r["premium_paid"] or 0) if r["premium_paid"] is not None else None
recv = float(r["premium_received"] or 0) if r["premium_received"] is not None else None
pnl = float(r["realized_pnl"]) if r["realized_pnl"] is not None else None
pnl_usdt = float(r["realized_pnl_usdt"]) if r["realized_pnl_usdt"] is not None else None
if pnl is None and paid is not None and recv is not None:
pnl = round(recv - paid, 8)
if pnl is None:
@@ -1209,6 +1332,7 @@ class SimBroker:
"closeAvgPx": str(close_px) if close_px is not None else "",
"closeTotalPos": str(sheets),
"openMaxPos": str(sheets),
# 期权历史展示币数量盈亏;U 口径只写 _sim_realized_pnl_usdt 供统计
"realizedPnl": str(round(pnl, 8)),
"pnl": str(round(pnl, 8)),
"pnlRatio": str(round(pnl_ratio, 8)) if pnl_ratio is not None else "",
@@ -1217,8 +1341,9 @@ class SimBroker:
"type": "2",
"optType": opt_type or "",
"stk": str(strike) if strike is not None else "",
"_sim_premium_ccy": ccy,
"_sim_premium_paid": paid,
"_sim_premium_ccy": ccy,
"_sim_realized_pnl_usdt": pnl_usdt,
}
)
return out

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