61 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:44:45 +08:00
dekun e26a67176c fix(hub): default HUB_ALLOW_PUBLIC on to avoid cloud 403 forbidden
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:31:59 +08:00
100 changed files with 8355 additions and 820 deletions
+3 -4
View File
@@ -76,10 +76,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true
# Binance API Key(需开通合约,万向划转等权限)
BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY
# Binance API Secret
BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET
# Binance API(仅服务器 .env 配置;新机保持为空,填真钥后 pm2 restart --update-env;勿用占位符以免鉴权狂打)
BINANCE_API_KEY=
BINANCE_API_SECRET=
# 保证金模式:cross=全仓,isolated=逐仓
BINANCE_MARGIN_MODE=cross
# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向
+40 -5
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
@@ -347,8 +352,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip()
BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip()
BINANCE_API_KEY = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
BINANCE_API_SECRET = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
# hedge=双向持仓(需 positionSide);oneway / single=单向持仓
_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
@@ -490,6 +495,8 @@ if BINANCE_API_KEY and BINANCE_API_SECRET:
exchange.apiKey = BINANCE_API_KEY
exchange.secret = BINANCE_API_SECRET
MARKETS_LOADED = False
# 鉴权失败后停止私有 API(资金/持仓),避免坏钥反复请求;尤其 Gate 易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0,
"funding_usdt": None,
@@ -2859,6 +2866,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (BINANCE_API_KEY and BINANCE_API_SECRET):
@@ -2893,9 +2902,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(BINANCE_API_KEY and BINANCE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, BINANCE_API_KEY, BINANCE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
BINANCE_API_KEY = ""
BINANCE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _float_balance_field(val):
if val is None or val == "":
return None
@@ -3178,11 +3201,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt()
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total()
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -3585,7 +3612,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False):
global MARKETS_LOADED
if force or not MARKETS_LOADED:
exchange.load_markets(reload=force)
try:
exchange.load_markets(reload=force)
except Exception as e:
# 坏钥时立刻去掉签名再拉公开 markets,避免反复鉴权(尤其勿拖累同机 Gate)
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True
+3 -4
View File
@@ -74,10 +74,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true
# Gate API Key(实盘)
GATE_API_KEY=REPLACE_WITH_GATE_API_KEY
# Gate API Secret(实盘)
GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET
# Gate API(仅服务器 .env 配置;新机保持为空,填真钥后重启;错误密钥反复请求易导致 Gate 封 IP)
GATE_API_KEY=
GATE_API_SECRET=
# 保证金模式:cross=全仓,isolated=逐仓
GATE_TD_MODE=cross
# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤
+115 -27
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
@@ -346,8 +351,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip()
GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip()
GATE_API_KEY = normalize_api_credential(os.getenv("GATE_API_KEY"))
GATE_API_SECRET = normalize_api_credential(os.getenv("GATE_API_SECRET"))
GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平)
@@ -478,6 +483,8 @@ if GATE_API_KEY and GATE_API_SECRET:
exchange.apiKey = GATE_API_KEY
exchange.secret = GATE_API_SECRET
MARKETS_LOADED = False
# 鉴权失败后停止私有 API,避免坏钥反复签名;Gate 尤其易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0,
"funding_usdt": None,
@@ -2547,6 +2554,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (GATE_API_KEY and GATE_API_SECRET):
@@ -2581,9 +2590,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(GATE_API_KEY and GATE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, GATE_API_KEY, GATE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
GATE_API_KEY = ""
GATE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启;勿反复试错以免 Gate 封 IP): {exc}"
)
def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2841,11 +2864,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt()
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"])
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -3345,7 +3372,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False):
global MARKETS_LOADED
if force or not MARKETS_LOADED:
exchange.load_markets(reload=force)
try:
exchange.load_markets(reload=force)
except Exception as e:
# Gate 对无效签名/坏钥敏感,失败后立即改公开 markets,勿反复带钥请求
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True
@@ -3761,46 +3796,99 @@ def _coerce_float(*values):
return None
def _gate_is_cross_margin(position, info):
mode = str(position.get("marginMode") or info.get("pos_margin_mode") or "").lower()
if "cross" in mode:
return True
lev = _coerce_float(info.get("leverage"), position.get("leverage"))
return lev is not None and lev == 0
def _gate_effective_leverage(position, info, order_leverage=None):
lev = _coerce_float(position.get("leverage"), info.get("leverage"))
if lev is not None and lev > 0:
return lev
cross_lev = _coerce_float(info.get("cross_leverage_limit"))
if cross_lev is not None and cross_lev > 0:
return cross_lev
if order_leverage is not None:
try:
ol = float(order_leverage)
if ol > 0:
return ol
except (TypeError, ValueError):
pass
return None
def _gate_estimated_initial_margin(notional, leverage):
"""Gate App 口径:仓位价值/杠杆 + 预估平仓 taker 费(0.075%)."""
if notional is None or notional <= 0 or leverage is None or leverage <= 0:
return None
return notional / float(leverage) + notional * 0.00075
def _gate_margin_matches_unrealized(margin, unrealized):
if margin is None or unrealized is None:
return False
return abs(float(margin) - float(unrealized)) <= max(0.02, abs(float(unrealized)) * 0.05)
def _gate_resolve_initial_margin(position, info, *, notional, unrealized, order_leverage=None):
"""全仓下 API margin 偶发等于 unrealised_pnl;优先 value/杠杆,逐仓仍信 API."""
api_margin = _coerce_float(
info.get("initial_margin"),
position.get("initialMargin"),
position.get("collateral"),
position.get("margin"),
info.get("margin"),
info.get("iso_margin"),
info.get("position_margin"),
info.get("initialMargin"),
)
eff_lev = _gate_effective_leverage(position, info, order_leverage)
estimated = _gate_estimated_initial_margin(notional, eff_lev) if eff_lev else None
if _gate_is_cross_margin(position, info):
if estimated and estimated > 0:
if (
api_margin is None
or api_margin <= 0
or _gate_margin_matches_unrealized(api_margin, unrealized)
or api_margin < estimated * 0.6
):
return estimated
if api_margin is not None and api_margin > 0 and not _gate_margin_matches_unrealized(
api_margin, unrealized
):
return api_margin
return estimated
if api_margin is not None and api_margin > 0:
return api_margin
return estimated
def parse_ccxt_position_metrics(position, order_leverage=None):
"""
ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底).
App仓位保证金对齐时优先用 initialMargin;缺失时再尝试 info 内字段.
全仓优先 value/cross_leverage_limit(+平仓费);API margin unrealised_pnl 则弃用.
"""
if not position:
return None
p = position
info = p.get("info", {}) or {}
# Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
if initial is None or initial <= 0:
initial = _coerce_float(
info.get("margin"),
info.get("cross_margin"),
info.get("iso_margin"),
info.get("initial_margin"),
info.get("position_margin"),
info.get("initialMargin"),
)
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
if notional is None or notional <= 0:
notional = _coerce_float(info.get("value"))
if notional is not None:
notional = abs(notional)
# 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近)
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
try:
lev = float(order_leverage)
if lev > 0:
approx = notional / lev
if approx > 0:
initial = approx
except (TypeError, ValueError):
pass
unrealized = _coerce_float(
p.get("unrealizedPnl"),
info.get("unrealised_pnl"),
info.get("unrealized_pnl"),
)
initial = _gate_resolve_initial_margin(
p, info, notional=notional, unrealized=unrealized, order_leverage=order_leverage
)
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice"))
out = {}
if initial is not None and initial > 0:
+26 -4
View File
@@ -80,12 +80,13 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true
# =============================================================================
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2)
# OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示)
# 新机保持为空;填真钥后 pm2 restart --update-env(含子代理)
# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填
# =============================================================================
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE
OKX_API_KEY=
OKX_API_SECRET=
OKX_API_PASSPHRASE=
# 保证金模式:cross=全仓,isolated=逐仓
OKX_TD_MODE=cross
# 持仓模式:hedge=双向持仓,net=单向净持仓
@@ -113,8 +114,21 @@ OKX_OPTIONS_ENABLED=false
# OKX_OPTIONS_API_SECRET=
# OKX_OPTIONS_API_PASSPHRASE=
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95
# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走)
OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10
OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false
OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
OKX_TRADE_MODE=options
@@ -130,6 +144,14 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值)
OKX_OPTIONS_CLOSE_GATE_MODE=premium
# 全局倍数可选;留空则按本位用下方 COIN/USDC
OKX_OPTIONS_CLOSE_RECYCLE_MULT=
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2
OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
+125 -20
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
@@ -360,9 +365,9 @@ def _promote_legacy_options_api_keys() -> None:
_promote_legacy_options_api_keys()
OKX_API_KEY = os.getenv("OKX_API_KEY", "")
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "")
OKX_API_KEY = normalize_api_credential(os.getenv("OKX_API_KEY"))
OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET"))
OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on")
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
@@ -506,6 +511,7 @@ if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE:
exchange_options.password = OKX_API_PASSPHRASE
MARKETS_LOADED = False
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0,
"funding_usdt": None,
@@ -2485,6 +2491,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_okx_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
@@ -2517,6 +2525,23 @@ def order_row_key_signal_type(row):
return None
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
try:
strip_ccxt_credentials(exchange_options)
except Exception:
pass
OKX_API_KEY = ""
OKX_API_SECRET = ""
OKX_API_PASSPHRASE = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2637,8 +2662,9 @@ def get_exchange_capitals(force=False):
ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding
ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
except Exception:
pass
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
@@ -2869,7 +2895,14 @@ def build_okx_order_params(direction, reduce_only=False):
def ensure_markets_loaded(force=False):
global MARKETS_LOADED
if force or not MARKETS_LOADED:
exchange.load_markets(reload=force)
try:
exchange.load_markets(reload=force)
except Exception as e:
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True
@@ -2985,6 +3018,8 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_
def exchange_private_api_configured():
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
@@ -6608,7 +6643,6 @@ def render_main_page(page="trade", embed_mode=None):
from lib.instance.instance_embed_context_lib import (
embed_render_plan,
minimal_stats_bundle,
options_funding_label,
profit_loss_ratio_from_trades,
show_perp_funds_enabled,
total_funds_usdt,
@@ -6626,22 +6660,39 @@ def render_main_page(page="trade", embed_mode=None):
options_funding_usdc = None
options_funding_usdt = None
options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
if (
OKX_OPTIONS_ENABLED
and exchange_options.apiKey
and embed_mode != "fragment"
):
try:
from lib.exchange.okx_options_lib import options_header_balances
from lib.exchange.okx_options_lib import options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances(
exchange_options
)
_op = options_header_balance_pack(exchange_options)
options_trading_usdc = _op.get("trading_usdc")
options_funding_usdc = _op.get("funding_usdc")
options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
except Exception:
options_trading_usdc = None
options_funding_usdc = None
options_funding_usdt = None
options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
recommended_capital = get_recommended_capital(current_capital)
key_list = (
conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else []
@@ -6793,6 +6844,11 @@ def render_main_page(page="trade", embed_mode=None):
options_funding_usdt=options_funding_usdt,
options_trading_usdc=options_trading_usdc,
options_trading_usdt=options_trading_usdt,
options_funding_eth=options_funding_eth,
options_trading_eth=options_trading_eth,
options_trading_btc=options_trading_btc,
options_margin_mode=options_margin_mode,
options_underly=options_underly,
trading_day=trading_day,
daily_start_capital=DAILY_START_CAPITAL,
current_capital=current_capital,
@@ -6857,10 +6913,10 @@ def render_main_page(page="trade", embed_mode=None):
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
key_rule_ctx=key_rule_ctx,
funds_fmt=format_funds_u,
options_funding_label=options_funding_label,
# options_funding_label / trading_account_label 由 embed_context_extras 注入,勿重复写进 dict
exchange_display=EXCHANGE_DISPLAY_NAME,
options_enabled=OKX_OPTIONS_ENABLED,
show_perp_funds=_show_perp_funds,
show_perp_funds=_show_perp_funds or (options_margin_mode == "coin"),
options_nav_visible=True,
okx_trade_mode=_okx_trade_mode,
options_open_allowed=_okx_trade_mode == "options",
@@ -6875,6 +6931,17 @@ def render_main_page(page="trade", embed_mode=None):
hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
options_compound_full_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_usdc=float(
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300"
),
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
@@ -7039,19 +7106,38 @@ def api_account_snapshot():
options_funding_usdc = None
options_funding_usdt = None
options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
options_index_px = None
if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
try:
from lib.exchange.okx_options_lib import options_header_balances
from lib.exchange.okx_options_lib import fetch_index_price, options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances(
exchange_options,
force=force_refresh,
)
_op = options_header_balance_pack(exchange_options, force=force_refresh)
options_trading_usdc = _op.get("trading_usdc")
options_funding_usdc = _op.get("funding_usdc")
options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
options_index_px = fetch_index_price(exchange_options, options_underly)
except Exception:
options_trading_usdc = None
options_funding_usdc = None
options_funding_usdt = None
options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
options_index_px = None
recommended_capital = get_recommended_capital(current_capital)
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
@@ -7122,10 +7208,12 @@ def api_account_snapshot():
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl)
# 币本位期权盈亏单位为币,禁止与永续 U 混加(且 merge 只保留 2 位会把 0.0019 抹成 0)
if options_margin_mode != "coin":
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl)
except Exception:
options_unrealized_pnl = None
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx")
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin")
return jsonify({
"funding_usdt": funding_usdt,
"current_capital": current_capital,
@@ -7134,6 +7222,12 @@ def api_account_snapshot():
"options_funding_usdt": options_funding_usdt,
"options_trading_usdc": options_trading_usdc,
"options_trading_usdt": options_trading_usdt,
"options_funding_eth": options_funding_eth,
"options_trading_eth": options_trading_eth,
"options_trading_btc": options_trading_btc,
"options_margin_mode": options_margin_mode,
"options_underly": options_underly,
"options_index_px": options_index_px,
"total_funds": total_funds_usdt(
funding_usdt if _show_perp_funds else None,
current_capital if _show_perp_funds else None,
@@ -7533,9 +7627,14 @@ def api_price_snapshot():
)
options_unrealized_pnl = None
options_index_px = None
options_margin_mode = None
options_underly = None
if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
try:
from lib.options.options_positions_lib import sum_options_net_pnl_usdc
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
from lib.exchange.okx_options_lib import fetch_index_price
opt_cfg = app.extensions.get("options_cfg")
if opt_cfg:
@@ -7544,6 +7643,9 @@ def api_price_snapshot():
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
options_margin_mode = normalize_options_margin_mode()
options_underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = fetch_index_price(exchange_options, options_underly)
except Exception:
options_unrealized_pnl = None
@@ -7554,6 +7656,9 @@ def api_price_snapshot():
"position_marks": position_marks,
"positions_raw_count": len(all_swap_positions),
"options_unrealized_pnl": options_unrealized_pnl,
"options_index_px": options_index_px,
"options_margin_mode": options_margin_mode,
"options_underly": options_underly,
**force_close_template_context(
FORCE_CLOSE_ENABLED,
FORCE_CLOSE_BJ_HOUR,
+2 -2
View File
@@ -40,9 +40,9 @@ bash /opt/crypto_monitor/deploy/manage.sh
- 登录账号: **admin**
- 登录密码: **admin123**
- 浏览器配置: 各所 **env 配置**(API,风控) + 中控 **系统设置**
- 浏览器配置: 各所 **env 配置**(风控) + 中控 **系统设置**;**交易所 API 仅服务器 `.env` 手改**(新机默认为空)
**无需 SSH 编辑 `.env` 填 API**;密钥由 `bootstrap_deploy_secrets.py` 自动生成.
**无需 SSH 编辑 `.env` 填通信/登录类密钥**;交易所 API 须在服务器写入各所 `.env`(新机为空).
| 地址 | 端口 |
|------|------|
@@ -0,0 +1,279 @@
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
> 状态:**已实现首版**(按本文落地;改需求先改本文).
> 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**.
> **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为).
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md)
---
## 1. 背景与动机
当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平.
实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量.
币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大.
---
## 2. 目标(首版)
1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥).
2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量).
3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT.
4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**.
5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`.
6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**.
7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持.
8. **中控不代下期权单**,但监控/快照/持仓卡片等 **只读字段须能识别币本位**(见 §7.5).
9. **不涉及 Gate** 任何业务改动.
---
## 3. 不做(首版外)
- 对冲计划(永期/期期)币本位腿或双模式混开
- 盘中按单笔切换本位(必须 env + 重启/无仓校验)
- 按权利金精确计算后再买现货(明确不做;见 §5)
- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转)
- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则)
- 多笔并行单笔期权仓(维持「一次一仓」)
- **中控代下 / 中控内嵌开平仓按钮**触发币本位或 USDC 期权下单(开平仍只在 OKX 实例页)
- **任何 Gate 相关改动**(含为「顺便统一」去动 Gate 模板或共享路径上的 Gate 分支)
---
## 4. 模式开关与互斥
### 4.1 env(草案)
| 变量 | 含义 | 默认 |
|------|------|------|
| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `coin` |
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` |
| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` |
| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) |
| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` |
| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) |
| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) |
| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 现货买入相对权利金倍数(也可写 `0.10`=+10%) | `1.10` |
开仓买币:**先按预算估最大可开张数 → 买币 USDT ≈ 张数×卖一权利金×现货缓冲**,不全额把预算换成币。
说明:
- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`.
- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`.
- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致).
### 4.2 切换门禁
| 条件 | 行为 |
|------|------|
| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc``coin` |
| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 |
| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC |
| 无仓且无半成品 | 允许改 env 并重启后生效 |
启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价.
---
## 5. 币本位资金桥与开平流水
### 5.1 开仓(先买满,再开满)
```
1. 读取交易账户 USDT 可用
2. 计算 budget_usdt(§4.1)
3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致)
4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权
- 受:最小张数、卖一深度、单笔一仓规则约束
- 不要求「币数量精确等于权利金」;允许开满后仍残留部分币
5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding
```
### 5.2 平仓(先平期权,再卖回 USDT)
```
1. 按现有纪律买一限价卖出期权(可分批深度)
2. 期权仓清零(或本轮目标完成)后:
现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT
3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利
```
### 5.3 失败回滚(必须)
| 失败点 | 处理 |
|--------|------|
| 现货买入失败 | 不开期权;报错 |
| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 |
| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 |
| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 |
---
## 6. 复利与「人工转走」
### 6.1 口径
- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算).
- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」.
- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`):
- **默认关闭** → 纯靠人工转走控规模.
- **开启** → `min(账户×0.95, N)`,防止单笔过大.
### 6.2 与永续「全仓」的关系
思想同类(吃可用 × 缓冲),但资产不同:
- 永续全仓:USDT 保证金 × 杠杆 → 合约名义
- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金
**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合.
### 6.3 一次一仓
复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品.
---
## 7. 产品与 UI
### 7.1 模式可见性
- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`.
- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态.
- USDC 模式保持现有 USDC 余额与预算展示.
### 7.2 开仓按钮文案(示例)
- 币本位:`买币并开仓(预算 ≈ xx USDT)`
- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT.
### 7.3 对冲
- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算.
- 不在此模式自动把对冲预算改成 USDT 桥.
### 7.4 复盘字段(建议)
单笔 round-trip 尽量可拆:
- 期权腿盈亏(币或折合 USDT)
- 桥兑换盈亏(买币成本 vs 卖币回收)
- 合计 USDT 变化(对复利最有意义)
首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**.
### 7.5 中控只读(要做)与不下单(不做)
中控保持现有分工:**监控只读 + 点「期权」进 OKX 实例操作**;本方案**不**在中控增加开平仓/买币桥按钮.
只读侧须能区分并展示币本位,避免仍按「一律 USDC 权利金」误读.实例上报快照/期权字段建议至少包含:
| 字段(名可调) | 含义 |
|--------------|------|
| `options_margin_mode` | `usdc` \| `coin` |
| 持仓行可辨本位 | 合约族/结算币/标签,卡片上能看出「币本位」或「USDC」 |
| 币本位时预算口径 | 可选:交易户 USDT、本轮 `×0.95` 预估预算、是否触达 N 上限 |
| 桥状态(若有半成品) | 如 `holding` / `pending_sell_spot`(待卖回 USDT),中控只展示与告警,不代执行 |
展示落点(与现网对齐即可,不新开中控交易页):
- OKX 账户监控里的期权区块 / 期权持仓卡片
- 推给教练等用的监控快照文案(若已注入期权行,须带本位标记,避免 AI/人工当成 USDC)
**不做:**中控代下单、中控触发买 ETH/卖 ETH、中控改 env 切模式.
---
## 8. 技术要点
### 8.1 合约与报价
- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径.
- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设).
- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套.
### 8.2 模块建议
| 块 | 职责 |
|----|------|
| 模式读取 + 门禁 | env、有仓拒切、启动一致性 |
| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 |
| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 |
| 定价/张数 | 币本位分支 |
| UI/API | 预算预览、确认、半成品提示 |
| 中控只读 | 消费实例快照中的 `options_margin_mode` 等字段;卡片/文案可识别币本位;**无下单 API** |
| Gate | **不纳入**;禁止为本次需求修改 Gate 树 |
现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同.
共享 `lib/options*` / 快照序列化若调整:仅扩展 OKX 期权载荷;Binance/Gate 账户快照路径保持原样.
### 8.3 权限与账户
- API 需具备:交易账户现货市价、期权开平.
- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划).
### 8.4 测试
- 预算计算:复利开/关、上限开/关、余额边界.
- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功.
- 门禁:有仓切换拒绝;一次一仓.
- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC.
- 中控只读:快照含本位字段时卡片/文案可区分 `usdc`/`coin`.
- Gate:本次 diff **不应出现** `crypto_monitor_gate/` 业务文件变更.
---
## 9. 验收标准
1. `usdc` 模式:单笔期权行为与现网一致.
2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警).
3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时).
4. 上限开关默认关;开启后预算不超过 N.
5. 有持仓或半成品时切换模式被拒绝.
6. 币本位下对冲不能误开币本位腿.
7. 开仓失败自动卖回 USDT,不留下无主现货.
8. 中控:**无**期权下单入口新增;监控/快照/持仓只读能看出当前为币本位或 USDC.
9. Gate:无相关代码改动;Gate 实例行为与改前一致.
---
## 10. 实现顺序建议
1. 模式 env + 有仓/半成品门禁 + OKX 实例 UI 展示当前模式
2. 现货桥(买/卖/回滚/待卖回) + 单测
3. 币本位合约适配 + 卖一开/买一平接入编排
4. 复利预算预览与开仓确认
5. 上限开关
6. 快照字段上报 + **中控只读识别币本位**(卡片/文案;不下单)
7. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔
---
## 11. 决策摘要(已拍板)
| 决策 | 结论 |
|------|------|
| 对冲 | 暂不接币本位 |
| 单笔模式 | env:`usdc``coin` |
| 有持仓切换 | **拒绝** |
| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) |
| 复利 | 交易账户 USDT × 0.95;人工转走控规模 |
| 单笔不超过 N U | **独立开关,默认关闭** |
| 中控 | **不下单**;只读字段/快照**能识别币本位** |
| Gate | **本次不改** |
| 动机 | 币本位流动性往往优于 USDC,利于成交 |
---
## 12. 风险与说明
- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显.
- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待.
- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准.
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
+5 -4
View File
@@ -14,6 +14,7 @@
| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 |
| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 |
| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 |
| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` |
| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 |
| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 |
@@ -61,15 +62,14 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本
| 中文名 | 说明 | 重启 |
|--------|------|------|
| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 |
| API Key | 账户 API Key(永续+期权共用) | 需重启 |
| API Secret | 账户 API Secret | 需重启 |
| API Passphrase | 仅 OKX 显示 | 需重启 |
| 保证金模式 | 全仓 / 逐仓 | 需重启 |
| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 |
| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 |
| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 |
| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 |
**交易所 API Key / Secret / Passphrase 不在本页**:请 SSH 编辑各所 `crypto_monitor_*/.env`,新机部署后应为空;配好真钥后重启对应 Flask 与子代理(`pm2 restart … --update-env`).占位符或错误密钥会导致鉴权失败,Gate 上反复请求还可能封 IP.
**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥.
---
@@ -196,6 +196,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
- 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG`
- 数据:`DB_PATH`,`UPLOAD_DIR`
- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空)
- 关键位门控:全部 `KEY_*`,`KLINE_*`
- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*`
- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY`
@@ -212,7 +213,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 能力 | env 配置 | 系统设置 | 中控系统设置 |
|------|----------|----------|--------------|
| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 |
| 交易所 API | ✅(各所自配) | ❌ | ❌ |
| 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ |
| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) |
| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 |
| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ |
+1 -1
View File
@@ -75,7 +75,7 @@ python3 scripts/bootstrap_deploy_secrets.py
## 4. 实例 env 配置页变更
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配.
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.企业微信交易执行等仍各所自配;**交易所 API 仅服务器 `.env`**,前端不再展示.
实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入.
@@ -0,0 +1,233 @@
# 实盘下单 · 盘口深度预览 — 开发方案
> 状态:**方案待实现**(按本文落地;改需求先改本文).
> 范围:**三所实例**实盘下单监控(Binance / OKX / Gate);中控嵌入同一表单时一并带上.
> 相关:[manual-order-rr-preview.md](./manual-order-rr-preview.md) · [position-sizing-mode.md](./position-sizing-mode.md) · 期权侧已有「卖一开 / 买一平」深度硬约束(本方案**不照搬硬挡**,首版以预览为主).
---
## 1. 背景与问题
实盘下单表单目前只展示 **标的现价/标记价**,再按止损与计仓模式算出预估风险 / 预估 RR.
- **资金小**:名义仓位通常远小于盘口前几档,市价成交贴近买卖一,现价参考够用.
- **资金大**(尤其 `POSITION_SIZING_MODE=full_margin`):名义 = 可用保证金 × 缓冲 × 杠杆,容易到数十万 U. 市价单会沿对手盘穿档,入场均价偏离「现价」后,止损距离与有效盈亏比都会偏.
典型例子:
| 条件 | 含义 |
|------|------|
| 可用约 1 万 U,20 倍杠杆,全仓 | 计划名义约 **20 万 U** |
| **市价做空** | 立刻卖出 ≈ 20 万 U 名义 → 吃 **买单(bid)** |
| **市价做多** | 立刻买入 ≈ 20 万 U 名义 → 吃 **卖单(ask)** |
用户需要的不是整本订单簿娱乐墙,而是回答:
> 当前计划名义下,对手盘前几档**能不能接住**,接住后的**预估均价 / 滑点**大概多少?
---
## 2. 目标(首版)
在「实盘下单监控」开仓区增加 **计划名义 vs 对手盘深度** 的只读预览:
1. 按当前表单算出的 **计划名义(USDT)****方向**,取对应一侧盘口.
2. 从最优档往外累加,直到累计名义 ≥ 计划名义(或盘口耗尽).
3. 展示:吃到第几档、累计可吸收名义、预估成交均价(VWAP)、相对参考价的滑点(bps 或 %).
4. **不拦截下单**(首版);可选标黄提示,见 §6.
与现有「预估风险 / 预估盈利 / 预估盈亏比」并列,作为下单前参考,不替代服务端风控与交易所真实成交.
---
## 3. 不做(首版外)
- 完整 20/50 档盘口图、深度图动画、WebSocket 持续推送盘口(首版 REST 轮询即可)
- 按深度 **自动缩仓****禁止开仓**(期权硬约束那套;列为二期,见 §10)
- 限价挂单的「挂单价到盘口距离」专项(可后加;首版聚焦市价吃单路径)
- 平仓/止损单穿档预估(开仓侧先做;平仓可二期)
- 改开仓逻辑、改计仓公式、改交易所下单路径
- 中控独立深度页或跨所聚合盘口
---
## 4. 产品规则
### 4.1 对手盘方向
| 用户方向 | 市价开仓动作 | 累加侧 |
|----------|--------------|--------|
| 做多(long) | 买入 | **卖盘 asks**(卖一 → 卖 N) |
| 做空(short) | 卖出 | **买盘 bids**(买一 → 买 N) |
### 4.2 计划名义从哪来
与现有开仓计仓一致,优先复用服务端已有 sizing 口径(避免前后端各算一套):
| 计仓模式 | 计划名义 |
|----------|----------|
| `full_margin` | `notional_value` ≈ 可用 × 缓冲 × 杠杆(与 `compute_full_margin_sizing` 一致) |
| `risk`(以损定仓) | 由风险金额与止损距离反推的仓位名义(与现开仓 `add_order` 路径一致) |
表单未填齐止损/方向/币种、或无法取可用保证金时:深度预览显示「—」,不报错打断填写.
### 4.3 参考价与滑点
- **参考价**:优先与表单现价条同一口径(标记价/最新价,跟现有 `symbol_live_price` / `order_defaults` 一致).
- **预估均价(VWAP)**:按所吃各档 `价格 × 该档名义` 加权.
- **滑点**:
- 做多: `(vwap - ref) / ref`(越正越差)
- 做空: `(ref - vwap) / ref`(越正越差)
- 展示可用 **bps**(1 bps = 0.01%)或 `%`,UI 统一一种即可(建议 bps,大单更直观).
### 4.4 盘口档数
- 请求深度建议 **520 档**(实现时三所取各自 API 稳妥上限,默认 20).
- 累加只展示「覆盖计划名义所需」的档位摘要,不必把未吃到的远档全部渲染.
- 若累加后仍 `< 计划名义`:明确写 **深度不足 / 缺口约 X U**,不要伪装成已完全覆盖.
### 4.5 文案示例(空单 20 万 U)
```
对手盘(买):买一~买4 累计约 23.1 万 U · 预估均价 63480(相对现价约 5 bps)
```
深度不足时:
```
对手盘(买):前 20 档累计约 12.4 万 U · 缺口约 7.6 万 U · 预估均价按已有档估算(仅供参考)
```
---
## 5. 界面位置
放在实盘下单开仓区、现有预览条附近,避免抢主按钮视觉:
| 区域 | 建议 |
|------|------|
| 现价条旁或下方 | 一行摘要即可(§4.5) |
| `#order-plan-preview` | 可增一项「盘口深度」或独立 `#order-depth-preview` |
| 详细档位 | 首版可不展开;若展开,仅列出已累加到的那几档(价/量/累计名义) |
小资金且滑点低于阈值时,可用灰色弱提示「前 N 档已覆盖,滑点可忽略」,避免噪音.
---
## 6. 提示阈值(软提示,不挡单)
建议可配置(`.env`,有默认值),仅影响颜色/文案:
| 变量(草案) | 含义 | 默认建议 |
|------------|------|----------|
| `MANUAL_DEPTH_WARN_BPS` | 预估滑点 ≥ 此值标黄 | `5` |
| `MANUAL_DEPTH_ALERT_BPS` | 预估滑点 ≥ 此值标红/强调 | `15` |
| `MANUAL_DEPTH_SHORTFALL_WARN` | 累计名义 < 计划名义时强调 | 开 |
首版:**不**因此 `disabled` 开仓按钮;与期权「无卖一禁止开仓」区分开.
---
## 7. 技术设计
### 7.1 API(三所各暴露,或抽到 `lib/` 共用 handler)
建议新增(名称可微调):
`GET /api/order_depth_preview`
| 参数 | 说明 |
|------|------|
| `symbol` | 与开仓表单一致 |
| `direction` | `long` / `short` |
| `sl` / `sl_pct` / `fixed_rr` / `sltp_mode` 等 | 以损定仓算名义时需要;全仓模式可只传 symbol+direction |
| 或直接传 `notional_usdt` | 若前端已从其它 preview API 拿到名义,可减少重复计算(**二选一,实现时定一种主路径**) |
响应草案:
```json
{
"ok": true,
"side": "bid",
"ref_px": 63512.3,
"plan_notional_usdt": 200000,
"covered_notional_usdt": 231000,
"shortfall_usdt": 0,
"levels_used": 4,
"vwap": 63480.0,
"slippage_bps": 5.1,
"levels": [
{"px": 63510, "sz": "...", "notional_usdt": 50000, "cum_notional_usdt": 50000}
],
"msg": ""
}
```
失败(拉盘口失败、币种无效):`ok=false` + 简短 `msg`;前端显示「深度暂不可用」,不影响开仓。
### 7.2 交易所盘口
| 所 | 合约盘口 | 注意 |
|----|----------|------|
| Binance | USD-M 深度 | 数量单位换算成 USDT 名义 |
| OKX | swap books | 同左;与期权 `fetch_option_book_depth` **分开**,勿混用期权接口 |
| Gate | futures order book | 同左 |
公共逻辑建议落在 `lib/trade/`(例如 `manual_order_depth_preview_lib.py`):输入档位列表 + 计划名义 + 方向 → 输出 VWAP / 缺口 / levels_used.
各所只负责 **拉 book + 单位换算成 USDT 名义**.
### 7.3 前端
- 共享脚本(建议):`lib/common/static/manual_order_depth_preview.js`
-`manual_order_rr_preview.js` 同样在币种/方向/止损/模式变更时 debounce 刷新
- 轮询间隔建议 3~5s(仅表单可见且字段有效时);切页或无焦点可停
- 三所 `index` / 嵌入 fragment 引入同一脚本
### 7.4 测试
- 纯函数:给定假盘口 + 名义,断言 `levels_used` / `vwap` / `shortfall`
- 方向: long 只吃 ask, short 只吃 bid
- 深度不足与刚好覆盖边界
- 不要求联调真盘口也能合入(真盘口可手工验一次 BTC/山寨对比)
---
## 8. 验收标准
1. 全仓 + 已知杠杆下,预览「计划名义」与开仓实际计仓名义同量级(允许四舍五入误差).
2. 市价空只反映买盘累加;市价多只反映卖盘累加.
3. BTC 厚盘:小名义常显示「前 1~2 档已覆盖、滑点很低」.
4. 人为放大名义或选薄流动性标的:能看到多档累加或「深度不足」.
5. 拉盘口失败时不阻断开仓按钮.
6. 中控嵌入实盘下单同样可见(与实例页同源表单).
---
## 9. 实现顺序建议
1. `lib/trade` 累加/VWAP 纯函数 + 单测
2. 一所(建议 OKX 或当前主力所)拉 book + API + 前端一行预览
3. 抽换算差异,补 Binance / Gate
4. 接入软提示阈值与文案打磨
5. 文档验收记录补进本文或 `docs/更新文档.md`
---
## 10. 二期(明确不做进首版)
| 项 | 说明 |
|----|------|
| 深度不够自动缩名义 | 类似期权 `cap_by_ask_depth` |
| 滑点超阈值二次确认 / 禁止市价 | 产品确认后再做硬门禁 |
| 平仓与止损穿档预估 | 持仓卡或平仓按钮旁 |
| WS 盘口 | 降低 REST 压力、更即时 |
| 限价开仓:挂单价相对盘口位置 | 另一套提示 |
---
## 11. 决策摘要(已拍板)
- **要做**:按计划名义展示「覆盖该名义所需」的对手盘摘要 + 预估均价/滑点.
- **做空看买单,做多看卖单**.
- **首版只展示 + 软提示,不挡单**.
- **不为小资金做整屏盘口墙**;大名义时深度预览才有关键决策价值.
+1
View File
@@ -6,6 +6,7 @@
| 标签 | 指向提交 | 说明 |
|------|----------|------|
| `snapshot/20260820` | `2028251` | 2026-08-20:币本位期权开发前快照;含盘口深度预览方案、OKX单笔期权币本位+USDT桥+复利开发方案;对冲暂不接币本位 |
| `snapshot/20260728-2` | `05864d7` | 2026-07-28 午后:振幅统计改为波动点数→振幅占比、两日振幅(例25日16:00→27日16:00);去掉买跨/永期对照 |
| `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 |
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
+30
View File
@@ -4,6 +4,36 @@
---
## 2026-08-20 · OKX 单笔期权币本位 + USDT 桥 + 复利
### 修改原因
币本位期权流动性往往好于 USDC;操作者仍用 USDT 思考本金。需 env 切换本位、自动 USDT↔币桥、交易户 USDT×0.95 复利;对冲仍仅 USDC;中控只读识别本位;不改 Gate。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `lib/options/options_margin_mode_lib.py` | 本位/合约族/USDT 预算/按币算张数 |
| `lib/options/options_spot_bridge_lib.py` | 买币/卖回/桥状态表/回滚 |
| `lib/options/options_coin_open_lib.py` | 买满→开满编排;平后卖回 |
| `options_register` / `okx_options_lib` / close_exec | 链族切换、开平接入、retry-sell |
| `options_hub_lib` + 中控 `app.js` / AI context | 只读字段识别币本位 |
| `hedge_plan_register` | 币本位禁止开对冲 |
| `env_*` / `.env.example` | 新 env;MARGIN_MODE 需重启;有仓拒切 |
| `docs/OKX单笔期权-币本位与USDT桥-开发方案.md` | 方案(已有) |
### 交付之后的验收
1. `OKX_OPTIONS_MARGIN_MODE=usdc` 行为与现网一致.
2. `=coin` 时链为 `ETH-USD`(非 `_UM`);开仓走买币再开期权;失败回滚卖币.
3. 平仓清空后卖回本桥币量;失败可 `POST /api/options/spot-bridge/retry-sell`.
4. 预算默认交易户 USDT×0.95;上限开关默认关.
5. 中控期权卡显示本位标签;无下单.
6. Gate 无改动.
---
## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复
### 修改原因
+16 -7
View File
@@ -47,6 +47,13 @@
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
### 2.4 翻倍出场(可选)
- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
- 可随时关闭;与目标位监控并行,谁先达标谁平.
- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
---
## 3. 监控逻辑
@@ -58,19 +65,21 @@
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 |
| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 |
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
---
## 4. 平仓校验(门控)
| 门控 | 手动买一平 | 目标自动平 | 说明 |
|------|------------|------------|------|
| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 |
| 锁定买一价 | | ✅ | 下单价 = 通过校验时的买一 |
| 市价兜底 | | | 永不市价 |
| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 |
|------|------------|------------|----------|------|
| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 |
| 回收 ≥ 权利金×(1+倍数) | ❌ | | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 |
| 锁定买一价 | | | ✅ | 下单价 = 通过校验时的买一 |
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
---
+1 -1
View File
@@ -66,7 +66,7 @@
| 网页登录密码 | ✅ | 本区块 |
| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 |
| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 |
| 交易所 API | ❌ | **env 配置** 页(各所自配) |
| 交易所 API | ❌ | **仅服务器** 各所 `.env`(`*_API_KEY` 等;前端 env 页已移除) |
| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) |
### 操作流程
+45 -41
View File
@@ -1166,12 +1166,9 @@
renderListStrikes();
renderTStrikes();
if (d.index_px) {
const idx = Number(d.index_px);
if ($("hp-target-up") && !$("hp-target-up").value) {
$("hp-target-up").value = String(Math.round(idx * 1.03));
}
if ($("hp-target-down") && !$("hp-target-down").value) {
$("hp-target-down").value = String(Math.round(idx * 0.97));
// 盈亏比默认2,不随指数自动改写
if ($("hp-profit-rr") && !$("hp-profit-rr").value) {
$("hp-profit-rr").value = "2";
}
}
}
@@ -1581,8 +1578,7 @@
if ($("hp-contracts")) $("hp-contracts").value = "";
if ($("hp-tp")) $("hp-tp").value = "";
if ($("hp-sl")) $("hp-sl").value = "";
if ($("hp-target-up")) $("hp-target-up").value = "";
if ($("hp-target-down")) $("hp-target-down").value = "";
if ($("hp-profit-rr")) $("hp-profit-rr").value = "2";
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
if ($("hp-oo-sheets-a")) {
@@ -1618,16 +1614,12 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值");
}
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
body = {
plan_type: "options_options",
target_price_up: up,
target_price_down: down,
target_price: up,
index_px: indexPx() || (up + down) / 2,
profit_rr: rr,
index_px: indexPx() || 0,
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
};
@@ -1719,22 +1711,28 @@
fmt(s.premium_paid) +
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
} else {
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total;
const dnTot = s.at_target_down_total;
const rrTarget = s.profit_rr != null ? s.profit_rr : null;
let rrLine = "";
if (s.rr_at_up != null || s.rr_at_down != null) {
if (rrTarget != null) {
rrLine =
" · 目标盈亏比 " +
fmt(rrTarget, 2) +
'<span class="muted">(盈利金额/总权利金)</span>';
} else if (s.rr_at_up != null || s.rr_at_down != null) {
rrLine =
" · 盈亏比 上破 " +
fmtRr(s.rr_at_up) +
(dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") +
(s.at_target_down_total != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") +
'<span class="muted">(亏=全额保费 ' +
fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) +
"</span>";
}
const aTot = s.at_rr_a_full_total != null ? s.at_rr_a_full_total : s.at_target_up_total;
const bTot = s.at_rr_b_full_total != null ? s.at_rr_b_full_total : s.at_target_down_total;
summary.innerHTML =
"上破 " +
fmtPnlHtml(upTot) +
(dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") +
(rrTarget != null ? "腿A达标 " : "上破 ") +
fmtPnlHtml(aTot) +
(bTot != null ? (rrTarget != null ? " · 腿B达标 " : " · 下破 ") + fmtPnlHtml(bTot) : "") +
" · 到期现价 " +
fmtPnlHtml(s.expiry_flat_total) +
" · 保费 " +
@@ -2057,8 +2055,7 @@
"hp-tp",
"hp-sl",
"hp-sheets",
"hp-target-up",
"hp-target-down",
"hp-profit-rr",
]);
if ($("hp-preview-btn"))
$("hp-preview-btn").addEventListener("click", function () {
@@ -2171,6 +2168,9 @@
if (p.plan_type === "perp_options") {
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
}
if (p.profit_rr != null && Number(p.profit_rr) > 0) {
return "盈亏比 " + fmt(p.profit_rr, 2);
}
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
}
@@ -2330,8 +2330,9 @@
target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平",
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
oo_rest_closing: "期期残值平·清亏损腿中",
oo_rest_closed: "期期残值平·两腿已平",
orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期",
@@ -2405,12 +2406,19 @@
fmt(p.perp_size, 4) +
"</div>";
} else {
html +=
"<div><span class=\"muted\">目标价</span> 上破 " +
fmt(p.target_price_up || p.target_price) +
" · 下破 " +
fmt(p.target_price_down || p.target_price) +
"</div>";
if (p.profit_rr != null && Number(p.profit_rr) > 0) {
html +=
"<div><span class=\"muted\">盈亏比</span> " +
fmt(p.profit_rr, 2) +
" <span class=\"muted\">(盈利金额/总权利金)</span></div>";
} else {
html +=
"<div><span class=\"muted\">目标价</span> 上破 " +
fmt(p.target_price_up || p.target_price) +
" · 下破 " +
fmt(p.target_price_down || p.target_price) +
"</div>";
}
}
html +=
"<div><span class=\"muted\">权利金合计</span> " +
@@ -2626,17 +2634,13 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值");
}
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
body = {
plan_type: "options_options",
underlying: state.underlying,
target_price_up: up,
target_price_down: down,
target_price: up,
index_px: indexPx() || (up + down) / 2,
profit_rr: rr,
index_px: indexPx() || 0,
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
oo_sheets_mode: state.ooSheetsMode || "same_sheets",
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
+69 -1
View File
@@ -202,6 +202,7 @@
function renderEnvFieldRow(field) {
const row = document.createElement("div");
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
row.dataset.envKey = field.key;
const label = document.createElement("label");
label.className = "env-field-label";
label.htmlFor = "env-f-" + field.key;
@@ -294,6 +295,10 @@
input.dataset.envKey = field.key;
input.className = "env-field-input";
row.appendChild(input);
if (field.hidden) {
row.hidden = true;
row.style.display = "none";
}
return row;
}
@@ -345,9 +350,69 @@
body.appendChild(panelsWrap);
body.dataset.envModeSectionIdx = String(modeSectionIdx);
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
bindMarginModeGateVisibility(body);
return body;
}
function envFieldRowByKey(body, key) {
if (!body || !key) return null;
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
if (byRow) return byRow;
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
return input ? input.closest(".env-field-row") : null;
}
function setEnvRowHidden(row, hidden) {
if (!row) return;
row.hidden = !!hidden;
row.style.display = hidden ? "none" : "";
}
function syncCompoundBudgetVisibility(body) {
if (!body) return;
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
if (!budgetRow) return;
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
setEnvRowHidden(budgetRow, compoundOn);
}
function syncMarginModeGateVisibility(body) {
if (!body) return;
const modeSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
);
const mode = String((modeSel && modeSel.value) || "coin").toLowerCase();
const coinMode = mode !== "usdc";
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC"), coinMode);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), !coinMode);
}
function bindCompoundBudgetVisibility(body) {
if (!body) return;
syncCompoundBudgetVisibility(body);
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
compoundSel.dataset.compoundBudgetBound = "1";
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
}
function bindMarginModeGateVisibility(body) {
if (!body) return;
syncMarginModeGateVisibility(body);
const modeSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
);
if (!modeSel || modeSel.dataset.marginGateBound === "1") return;
modeSel.dataset.marginGateBound = "1";
modeSel.addEventListener("change", () => syncMarginModeGateVisibility(body));
}
function bindTradeModeAutoRefresh(body) {
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
@@ -542,7 +607,10 @@
loadEnvConfig(false);
const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body");
if (body) bindTradeModeAutoRefresh(body);
if (body) {
bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
}
if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__);
}
+126 -7
View File
@@ -690,6 +690,14 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
white-space: nowrap;
}
#current-capital,
.stat-strip-item [data-funds-field="current-capital"],
.inst-phone-chip [data-funds-field="current-capital"] {
white-space: pre-line;
line-height: 1.2;
font-size: 0.82rem;
}
.stat-strip-item--primary .label {
font-size: 0.76rem;
}
@@ -2494,6 +2502,11 @@ html[data-theme="light"] .journal-detail-img-thumb {
min-width: 0;
}
/* display:flex 会盖掉 UA [hidden];全仓复利开时隐藏单笔预算等依赖此规则 */
.env-field-row[hidden] {
display: none !important;
}
.env-field-row--restart .env-field-label {
color: #d4c4a0;
}
@@ -4419,6 +4432,9 @@ html[data-theme="light"] .opt-pending-item {
.opt-size-mode-chip {
position: relative;
}
.opt-size-mode-chip[hidden] {
display: none !important;
}
.opt-size-mode-chip input[type="radio"] {
position: absolute;
opacity: 0;
@@ -4442,6 +4458,22 @@ html[data-theme="light"] .opt-pending-item {
min-height: 32px;
box-sizing: border-box;
}
.options-estimate-row .opt-profit-exit-mult,
.options-page-wrap .opt-pos-profit-exit-mult {
width: 4.5rem;
min-width: 0;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-page-wrap .opt-profit-exit-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
white-space: nowrap;
}
.options-estimate-row .k {
color: #8892b0;
}
@@ -5165,36 +5197,43 @@ html[data-theme="light"] .opt-source-badge--oo {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
font-size: 0.72rem;
}
.opt-history-table th:nth-child(1),
.opt-history-table td:nth-child(1) {
width: 34%;
width: 22%;
}
.opt-history-table th:nth-child(2),
.opt-history-table td:nth-child(2) {
width: 7%;
width: 6%;
}
.opt-history-table th:nth-child(3),
.opt-history-table td:nth-child(3) {
width: 11%;
width: 14%;
white-space: nowrap;
}
.opt-history-table th:nth-child(4),
.opt-history-table td:nth-child(4) {
width: 9%;
width: 8%;
}
.opt-history-table th:nth-child(5),
.opt-history-table td:nth-child(5) {
width: 11%;
width: 16%;
white-space: nowrap;
}
.opt-history-table th:nth-child(6),
.opt-history-table td:nth-child(6) {
width: 20%;
width: 22%;
}
.opt-history-table th:nth-child(7),
.opt-history-table td:nth-child(7) {
width: 8%;
width: 12%;
text-align: center;
}
.opt-history-table .opt-hist-pnl {
white-space: nowrap;
}
.opt-hist-time {
font-size: 0.64rem;
white-space: nowrap;
@@ -5240,6 +5279,86 @@ html[data-theme="light"] .opt-source-badge--oo {
.options-pos-head h2 {
margin: 0;
}
.options-pos-head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
}
.opt-bridge-sell-hint {
font-size: 0.78rem;
line-height: 1.35;
max-width: 280px;
text-align: right;
}
.opt-retry-sell-coin-btn--pending {
border-color: #ffb347;
color: #ffb347;
}
.options-page-wrap .opt-pos-transfer {
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.options-page-wrap .opt-pos-transfer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
list-style: none;
font-weight: 600;
font-size: 0.88rem;
margin: 0 0 8px;
}
.options-page-wrap .opt-pos-transfer-head::-webkit-details-marker {
display: none;
}
.options-page-wrap .opt-pos-transfer[open] .opt-pos-transfer-closed-hint {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-open-hint {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-body {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-head {
margin-bottom: 0;
}
.options-page-wrap .opt-pos-transfer-body .options-settings-subtitle {
margin-bottom: 6px;
}
.options-page-wrap .opt-pos-transfer-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0;
}
.options-page-wrap .opt-pos-transfer-form select,
.options-page-wrap .opt-pos-transfer-form input[type="number"] {
font-size: 0.74rem;
}
.options-page-wrap .opt-pos-transfer-form input[type="number"] {
width: 96px;
max-width: 30vw;
}
.options-page-wrap .opt-pos-xfer-msg {
margin-top: 6px;
font-size: 0.76rem;
min-height: 1.1em;
}
.options-page-wrap .opt-pos-xfer-msg.opt-error {
color: #ff7b72;
}
.options-page-wrap .opt-pos-xfer-msg.opt-success {
color: #3dd68c;
}
.opt-pos-card {
margin-bottom: 10px;
}
File diff suppressed because it is too large Load Diff
+79 -11
View File
@@ -34,6 +34,61 @@
return Number(v).toFixed(2);
}
function posPremiumCcy(p) {
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
if (ccy) return ccy;
const mode = String((p && p.margin_mode) || "").toLowerCase();
const inst = String((p && p.inst_id) || "");
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
}
return "USDC";
}
function fmtPremiumAmt(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
if (unit === "ETH" || unit === "BTC") {
let s = n.toFixed(8).replace(/\.?0+$/, "");
return s || "0";
}
return fmtUsdc(n);
}
function spotPxOf(p) {
const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px));
return Number.isFinite(n) && n > 0 ? n : null;
}
function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) {
if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—";
const n = Number(coinAmt);
const unit = String(ccy || "ETH").toUpperCase();
if (unit !== "ETH" && unit !== "BTC") {
const sign = signed && n > 0 ? "+" : "";
return sign + fmtUsdc(n) + "U";
}
const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : "";
const coinTxt = coinSign + absCoin + " " + unit;
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
const u = n * px;
const absU = Math.abs(u).toFixed(2);
const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : "";
return coinTxt + " / " + uSign + absU + "U";
}
function fmtNetPnlDual(net, p) {
const ccy = posPremiumCcy(p);
if (ccy === "USDC") {
if (net == null || Number.isNaN(Number(net))) return "—";
return fmtUsdc(Number(net)) + "U";
}
return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true);
}
function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
@@ -96,7 +151,7 @@
}
const gate = preview.close_gate || {};
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟");
return "目标门控: " + (preview.close_gate_msg || gate.msg || "门控未过(见 env 目标平仓门控)");
}
return "";
}
@@ -136,9 +191,10 @@
return (net / prem) * 100;
}
function fmtClosePreview(preview, premiumPaid, hub) {
function fmtClosePreview(preview, premiumPaid, hub, p) {
if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received);
const ccy = posPremiumCcy(p);
const recvTxt = fmtPremiumAmt(preview.total_received, ccy);
let cls = "";
const prem = Number(premiumPaid);
const recv = Number(preview.total_received);
@@ -146,7 +202,7 @@
if (recv > prem) cls = " " + pnlCls(1, hub);
else if (recv < prem) cls = " " + pnlCls(-1, hub);
}
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>";
return '<span class="opt-close-value' + cls + '">' + recvTxt + " " + ccy + "</span>";
}
function expiryCdHtml(expMs) {
@@ -168,7 +224,11 @@
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
const closePreview = p.close_preview || {};
const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null);
const premCcy = posPremiumCcy(p);
const premTxt = fmtDisplay(
p.premium_paid_fmt,
p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : null
);
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
let headActions = "";
@@ -182,7 +242,7 @@
const pnlCells = hidePnl
? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(net == null ? "—" : fmt(net, 2)) + "</span></div>" +
(net == null ? "—" : fmtNetPnlDual(net, p)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return (
@@ -202,7 +262,7 @@
: "") +
"</div>" +
'<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
@@ -213,7 +273,7 @@
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" +
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub, p)) + "</span></div>" +
"</div>" +
(function () {
const hint = closeGateHint(closePreview);
@@ -226,6 +286,7 @@
const strike = Number(p.strike);
const tgt = Number(p.target_index);
const prem = Number(p.premium_paid);
const idx = Number(p.idx_px);
let profit = null;
let value = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
@@ -233,10 +294,17 @@
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) {
let premUsd = prem;
if (premCcy !== "USDC" && Number.isFinite(idx) && idx > 0) premUsd = prem * idx;
profit = Math.round((value - premUsd) * 100) / 100;
}
}
}
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
const valueUnit = premCcy !== "USDC" ? " U(估)" : " USDC";
const profitTxt = profit == null
? "—"
: ((profit > 0 ? "+" : "") + fmtUsdc(profit) + (premCcy !== "USDC" ? " U(估)" : " USDC"));
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
@@ -247,7 +315,7 @@
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</span>" +
profitSpan +
'<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
+4 -3
View File
@@ -49,7 +49,7 @@
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2);
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function fmtHold(sec) {
@@ -76,8 +76,9 @@
target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平",
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
oo_rest_closing: "期期残值平·清亏损腿中",
oo_rest_closed: "期期残值平·两腿已平",
orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期",
+18
View File
@@ -95,6 +95,16 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER",
"OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER",
@@ -157,6 +167,14 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("perp_options", "永期对冲"),
("options_options", "期期对冲"),
),
"OKX_OPTIONS_MARGIN_MODE": (
("coin", "币本位(USDT买币桥)"),
("usdc", "USDC(USDⓈ权利金)"),
),
"OKX_OPTIONS_CLOSE_GATE_MODE": (
("premium", "权利金×倍数"),
("net_pnl", "净盈亏(U)阈值"),
),
"HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
("false", "保险模式"),
+124 -12
View File
@@ -19,10 +19,7 @@ from lib.env.env_schema import (
# 各所「交易所与实盘」字段(顺序即页面顺序)
_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(永续+期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("OKX_TD_MODE", "保证金模式", ""),
("OKX_POS_MODE", "持仓模式", ""),
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
@@ -34,17 +31,13 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
),
],
"binance": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("BINANCE_API_KEY", "API Key", "永续子账户"),
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("BINANCE_MARGIN_MODE", "保证金模式", ""),
("BINANCE_POSITION_MODE", "持仓模式", ""),
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
"gate": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("GATE_API_KEY", "API Key", "永续子账户"),
("GATE_API_SECRET", "API Secret", "永续子账户"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("GATE_TD_MODE", "保证金模式", ""),
("GATE_POS_MODE", "持仓模式", ""),
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
@@ -143,8 +136,57 @@ _OPTIONS_SECTION: dict[str, Any] = {
"fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
(
"OKX_OPTIONS_MARGIN_MODE",
"单笔期权本位",
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
(
"OKX_OPTIONS_COIN_COMPOUND",
"币本位按交易户USDT复利",
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
),
(
"OKX_OPTIONS_COIN_BUDGET_USDT",
"币本位固定预算(USDT)",
"仅币本位且复利关闭时生效",
),
(
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"币本位单笔上限开关",
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
),
(
"OKX_OPTIONS_COIN_MAX_USDT",
"币本位单笔上限(USDT)",
"仅上限开关开启时生效",
),
(
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"币本位现货买入缓冲",
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
),
(
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)",
@@ -166,6 +208,36 @@ _OPTIONS_SECTION: dict[str, Any] = {
"链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
),
(
"OKX_OPTIONS_CLOSE_GATE_MODE",
"目标平仓门控模式",
"premium=可回收(U)≥权利金(U)×倍数;net_pnl=净盈亏(U)大于阈值。币本位按指数换算为U",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"门控权利金倍数(全局)",
"可选;填写则覆盖下方分本位默认值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"门控权利金倍数(币本位)",
"默认 1.05;premium 模式下 recyclable(U)≥premium(U)×本值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC",
"门控权利金倍数(USDC)",
"默认 2;premium 模式下 recyclable(U)≥premium(U)×本值",
),
(
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U",
"门控净盈亏下限(U)",
"net_pnl 模式;净盈亏(估)须大于本值,如 0 或 1",
),
(
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"门控持续秒数",
"达标后须持续本秒数才通过,默认 120",
),
],
}
@@ -283,6 +355,11 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"HEDGE_PLAN_OPTION_PRIMARY": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC": "2",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U": "0",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
}
@@ -453,6 +530,7 @@ def build_env_ui_payload(
_build_field(key, label, note, schema, values)
for key, label, note in sec["fields"]
]
fields = _mark_options_env_field_visibility(fields)
groups.append({
"title": sec["title"],
"fields": fields,
@@ -461,6 +539,40 @@ def build_env_ui_payload(
return groups
def _mark_options_env_field_visibility(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按本位/全仓复利隐藏无关项(供 SSR/前端;切换开关仍可再显示)."""
compound_on = True
margin_mode = "coin"
for f in fields:
key = f.get("key")
cur = str(f.get("current") or f.get("default") or "").strip()
if key == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
compound_on = _env_truthy(cur or "true")
elif key == "OKX_OPTIONS_MARGIN_MODE":
margin_mode = (cur or "coin").lower()
if margin_mode not in ("coin", "usdc"):
margin_mode = "coin"
out: list[dict[str, Any]] = []
for f in fields:
item = dict(f)
key = item.get("key")
hide = False
if key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
hide = True
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC" and margin_mode == "coin":
hide = True
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and margin_mode == "usdc":
hide = True
if hide:
item["hidden"] = True
out.append(item)
return out
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""兼容旧调用名;实际走 _mark_options_env_field_visibility."""
return _mark_options_env_field_visibility(fields)
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
allowed = ui_allowed_keys(exchange_key)
return {k: v for k, v in (updates or {}).items() if k in allowed}
+94
View File
@@ -0,0 +1,94 @@
"""交易所 API 凭证规范化.
新机 .env 密钥应为空;示例占位符不得注入 ccxt,否则鉴权失败且
(尤其 Gate)反复签名请求易触发 IP 封禁.
"""
from __future__ import annotations
from typing import Any, Optional
_PLACEHOLDER_EXACT = frozenset(
{
"你的密钥",
"your-api-key",
"your_api_key",
"your-api-secret",
"your_api_secret",
"todo",
"xxx",
"changeme",
}
)
def normalize_api_credential(value: Optional[str]) -> str:
"""去空白;空/占位符一律视为未配置."""
s = (value or "").strip().strip('"').strip("'")
if not s:
return ""
upper = s.upper()
if upper.startswith("REPLACE_WITH"):
return ""
if upper.startswith("CHANGE_TO"):
return ""
if s.lower() in _PLACEHOLDER_EXACT:
return ""
return s
def credentials_configured(*parts: Optional[str]) -> bool:
return all(bool(normalize_api_credential(p)) for p in parts)
def is_exchange_auth_error(exc: BaseException) -> bool:
"""鉴权/无效 Key 类错误(用于停掉后续签名请求,避免 Gate 封 IP)."""
name = type(exc).__name__
if name in ("AuthenticationError", "PermissionDenied", "InvalidNonce"):
return True
msg = str(exc)
markers = (
"Invalid Api-Key",
"Invalid API-key",
"Invalid API Key",
"INVALID_KEY",
"Invalid key",
"API key is invalid",
"api key not found",
"Signature",
"INVALID_SIGNATURE",
"401",
"-2008",
"-2014",
"-2015",
"10003", # Gate: invalid key often
"INVALID_KEY",
)
low = msg.lower()
if "api" in low and ("key" in low or "sign" in low) and (
"invalid" in low or "incorrect" in low or "not found" in low
):
return True
return any(m in msg for m in markers)
def strip_ccxt_credentials(exchange: Any) -> None:
"""内存中清空密钥,后续只走公开接口,避免继续带坏钥签名."""
try:
exchange.apiKey = ""
except Exception:
pass
try:
exchange.secret = ""
except Exception:
pass
try:
exchange.password = ""
except Exception:
pass
def load_markets_public_fallback(exchange: Any, *, reload: bool = False) -> None:
"""鉴权失败后去掉密钥再拉公开 markets(最多再请求一次)."""
strip_ccxt_credentials(exchange)
exchange.load_markets(reload=reload)
+252 -68
View File
@@ -13,24 +13,19 @@ import ccxt
from lib.options.options_pricing_lib import (
expiry_breakeven_from_ask,
idx_distance_to_be,
intrinsic_px_per_unit,
is_shallow_itm,
option_moneyness,
option_moneyness_label,
strike_distance_to_be,
)
_OKX_OPTION_ERR_ZH: dict[str, str] = {
"51008": "资金账户 USDT 可用余额不足",
"51008": "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够;USDC 模式请确认 USDC 足够)",
"51018": "期权账户不能持有净空头头寸",
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
}
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
row: dict[str, Any] | None = None
@@ -51,10 +46,25 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
pass
if row:
code = str(row.get("sCode") or "")
msg = str(row.get("sMsg") or "").strip()
low = msg.lower()
if code == "51008":
# 勿写死「资金账户 USDT」:USDC 模式常因交易户 USDC 不足;币本位则是标的币不足
if "usdc" in low:
return "交易账户 USDC 可用余额不足"
if "usdt" in low:
return "USDT 可用余额不足"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
if is_coin_margin_mode():
return "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够,或减少张数)"
except Exception:
pass
return _OKX_OPTION_ERR_ZH["51008"]
zh = _OKX_OPTION_ERR_ZH.get(code)
if zh:
return zh
msg = str(row.get("sMsg") or "").strip()
if msg:
return msg
if exc is not None:
@@ -65,6 +75,28 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
return "下单失败"
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活
_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock()
_OPTION_INSTRUMENTS_CACHE_TTL = 90.0
_OPTION_INSTRUMENTS_STALE_MAX = 600.0
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
with _OPTION_INSTRUMENTS_CACHE_LOCK:
if inst_family:
_OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None)
else:
_OPTION_INSTRUMENTS_CACHE.clear()
def td_mode_for_option_buy(configured: str | None = None) -> str:
"""OKX 买入期权(多头)必须使用逐仓."""
mode = (configured or "isolated").strip().lower()
@@ -141,6 +173,21 @@ def format_usdc_amount(v: float | None) -> str | None:
return f"{float(v):.2f}"
def format_premium_amount(v: float | None, *, ccy: str | None = "USDC") -> str | None:
"""权利金/回收金额文案:USDC 2 位;币本位 ETH/BTC 最多 8 位去尾零."""
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
unit = (ccy or "USDC").strip().upper() or "USDC"
if unit in ("ETH", "BTC"):
txt = f"{n:.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{n:.2f}"
def is_option_full_close_history(raw: dict[str, Any]) -> bool:
"""仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓."""
close_type = str(raw.get("type") or "").strip()
@@ -206,15 +253,6 @@ def tick_sz_and_ct_mult(
return tick_sz, ct_mult or 0.01
def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None:
o = (opt_type or "").upper()
if o == "C" and index_px > strike:
return float(index_px) - float(strike)
if o == "P" and index_px < strike:
return float(strike) - float(index_px)
return None
def _resolve_chain_quote(
*,
ticker: dict[str, Any],
@@ -222,6 +260,7 @@ def _resolve_chain_quote(
opt_type: str,
strike: float,
index_px: float,
inst_id: str | None = None,
) -> dict[str, Any]:
"""链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一)."""
tick_sz = meta.get("tickSz")
@@ -231,12 +270,13 @@ def _resolve_chain_quote(
ask_sz = _safe_float(ticker.get("askSz"))
bid_sz = _safe_float(ticker.get("bidSz"))
ask_estimated = False
iid = (inst_id or str(meta.get("instId") or "")).strip()
if ask is None and mark is not None and mark > 0:
ask = round_option_px(mark, tick_sz, "buy")
ask_estimated = True
if ask is None:
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px)
intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
if intrinsic is not None and intrinsic > 0:
ask = round_option_px(intrinsic, tick_sz, "buy")
ask_estimated = True
@@ -244,7 +284,7 @@ def _resolve_chain_quote(
if bid is None and mark is not None and mark > 0:
bid = round_option_px(mark, tick_sz, "sell")
if bid is None:
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px)
intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
if intrinsic is not None and intrinsic > 0:
bid = round_option_px(intrinsic, tick_sz, "sell")
@@ -407,25 +447,31 @@ def fetch_option_instrument_meta(ex: ccxt.okx, inst_id: str) -> dict[str, Any] |
family = inst_family_from_inst_id(inst_id)
if not family:
return None
# 优先从全族缓存取,避免每选一腿再打 instruments
try:
cached_rows = fetch_option_instruments(ex, family, allow_stale=True)
for r in cached_rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r
except Exception:
pass
last_err: BaseException | None = None
for attempt in range(3):
for attempt in range(2):
try:
rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family, "instId": inst_id}
).get("data") or []
if rows and isinstance(rows[0], dict):
return rows[0]
rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family}
).get("data") or []
rows = fetch_option_instruments(ex, family, allow_stale=True)
for r in rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r
return None
except Exception as e:
last_err = e
if _is_okx_rate_limit(e) and attempt < 2:
time.sleep(0.45 * (attempt + 1))
if _is_okx_rate_limit(e) and attempt < 1:
time.sleep(1.2)
continue
break
if last_err is not None and _is_okx_rate_limit(last_err):
@@ -480,8 +526,8 @@ def fetch_account_balances_by_type(
ex: ccxt.okx,
account_type: str,
) -> tuple[dict[str, float | None], dict[str, float | None]]:
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try:
bal = ex.fetch_balance(params={"type": account_type})
for c in out:
@@ -496,8 +542,8 @@ def fetch_funding_balances_via_asset_api(
ex: ccxt.okx,
) -> tuple[dict[str, float | None], dict[str, float | None]]:
"""OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确."""
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try:
resp = ex.private_get_asset_balances({})
for row in (resp or {}).get("data") or []:
@@ -580,24 +626,34 @@ def fetch_options_balances(
funding = _merge_balance_maps(funding, asset_funding)
funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail)
trading, trading_avail = fetch_account_balances_by_type(ex, "trading")
if trading.get("USDC") is None:
# OKX 统一账户:option 客户端拉 type=trading 常缺 USDT/币;用 swap 补齐缺失项
if any(trading.get(c) is None for c in ("USDT", "USDC", "ETH", "BTC")):
swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap")
if swap_bal.get("USDC") is not None:
trading["USDC"] = swap_bal["USDC"]
if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None:
trading_avail["USDC"] = swap_avail["USDC"]
for ccy in ("USDT", "USDC", "USDG", "ETH", "BTC"):
if trading.get(ccy) is None and swap_bal.get(ccy) is not None:
trading[ccy] = swap_bal[ccy]
if trading_avail.get(ccy) is None and swap_avail.get(ccy) is not None:
trading_avail[ccy] = swap_avail[ccy]
result = {
"scope": "main",
"funding_usdt": funding.get("USDT"),
"funding_usdc": funding.get("USDC"),
"funding_usdg": funding.get("USDG"),
"funding_eth": funding.get("ETH"),
"funding_btc": funding.get("BTC"),
"funding_usdt_avail": funding_avail.get("USDT"),
"funding_usdc_avail": funding_avail.get("USDC"),
"funding_eth_avail": funding_avail.get("ETH"),
"funding_btc_avail": funding_avail.get("BTC"),
"trading_usdt": trading.get("USDT"),
"trading_usdc": trading.get("USDC"),
"trading_usdg": trading.get("USDG"),
"trading_eth": trading.get("ETH"),
"trading_btc": trading.get("BTC"),
"trading_usdt_avail": trading_avail.get("USDT"),
"trading_usdc_avail": trading_avail.get("USDC"),
"trading_eth_avail": trading_avail.get("ETH"),
"trading_btc_avail": trading_avail.get("BTC"),
}
_OPTIONS_BALANCE_CACHE["updated_at"] = now
_OPTIONS_BALANCE_CACHE["data"] = result
@@ -613,22 +669,63 @@ def options_header_balances(
返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt)
"""
pack = options_header_balance_pack(ex, force=force)
return (
pack.get("trading_usdc"),
pack.get("funding_usdc"),
pack.get("funding_usdt"),
pack.get("trading_usdt"),
)
def options_header_balance_pack(
ex: ccxt.okx,
*,
force: bool = False,
) -> dict[str, Any]:
"""顶栏/快照用期权资金包(含币本位 ETH/BTC)."""
import os
bal = fetch_options_balances(ex, force=force)
def _round(v: Any) -> float | None:
def _round(v: Any, nd: int = 2) -> float | None:
if v is None:
return None
try:
return round(float(v), 2)
return round(float(v), nd)
except (TypeError, ValueError):
return None
return (
_round(bal.get("trading_usdc")),
_round(bal.get("funding_usdc")),
_round(bal.get("funding_usdt")),
_round(bal.get("trading_usdt")),
)
def _round_coin(v: Any) -> float | None:
if v is None:
return None
try:
return round(float(v), 8)
except (TypeError, ValueError):
return None
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
except Exception:
margin_mode = "usdc"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
coin_key = "btc" if underly == "BTC" else "eth"
return {
"trading_usdc": _round(bal.get("trading_usdc")),
"funding_usdc": _round(bal.get("funding_usdc")),
"funding_usdt": _round(bal.get("funding_usdt")),
"trading_usdt": _round(bal.get("trading_usdt")),
"funding_eth": _round_coin(bal.get("funding_eth")),
"trading_eth": _round_coin(bal.get("trading_eth")),
"funding_btc": _round_coin(bal.get("funding_btc")),
"trading_btc": _round_coin(bal.get("trading_btc")),
"options_margin_mode": margin_mode,
"options_underly": underly,
"funding_coin": _round_coin(bal.get(f"funding_{coin_key}")),
"trading_coin": _round_coin(bal.get(f"trading_{coin_key}")),
}
def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
@@ -645,11 +742,42 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
def fetch_option_instruments(
ex: ccxt.okx,
inst_family: str,
*,
force: bool = False,
allow_stale: bool = True,
) -> list[dict[str, Any]]:
rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": inst_family}
).get("data") or []
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
"""拉取 OPTION instruments;进程内缓存,50011 时回退旧列表."""
family = str(inst_family or "").strip()
if not family:
return []
now = time.time()
with _OPTION_INSTRUMENTS_CACHE_LOCK:
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
if (
not force
and entry is not None
and entry.get("rows") is not None
and now - float(entry.get("updated_at") or 0) < _OPTION_INSTRUMENTS_CACHE_TTL
):
return list(entry["rows"])
try:
rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family}
).get("data") or []
live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
with _OPTION_INSTRUMENTS_CACHE_LOCK:
_OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live}
return list(live)
except Exception as e:
if allow_stale:
with _OPTION_INSTRUMENTS_CACHE_LOCK:
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
if entry is not None and entry.get("rows") is not None:
age = now - float(entry.get("updated_at") or 0)
if age <= _OPTION_INSTRUMENTS_STALE_MAX:
return list(entry["rows"])
raise
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
@@ -674,31 +802,46 @@ def build_option_chain(
itm_only: bool = True,
itm_max_dist_usd: float = 30.0,
index_px: float | None = None,
margin_mode: str | None = None,
inst_family: str | None = None,
) -> dict[str, Any]:
u = (underlying or "ETH").upper()
family = f"{u}-USD_UM"
if inst_family:
family = str(inst_family).strip()
else:
try:
from lib.options.options_margin_mode_lib import inst_family_for_underlying
family = inst_family_for_underlying(u, margin_mode=margin_mode)
except Exception:
family = f"{u}-USD_UM"
uly = f"{u}-USD"
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
chain_margin = "usdc" if "_UM" in family.upper() else "coin"
now_ms = time.time() * 1000
max_ms = now_ms + max_dte_days * 86400 * 1000
instruments_err = ""
instruments: list[dict[str, Any]] = []
for attempt in range(2):
try:
instruments = fetch_option_instruments(ex, family)
instruments_err = ""
if instruments:
break
try:
instruments = fetch_option_instruments(ex, family)
if not instruments:
# 空列表可能是瞬时空;短退避后强制再拉一次(非 50011)
time.sleep(0.5)
instruments = fetch_option_instruments(ex, family, force=True)
if not instruments:
instruments_err = "期权合约列表为空"
except Exception as e:
instruments = []
instruments_err = str(e) or e.__class__.__name__
if attempt == 0:
time.sleep(0.35)
continue
break
if attempt == 0 and not instruments:
time.sleep(0.35)
except Exception as e:
instruments = []
instruments_err = str(e) or e.__class__.__name__
# 限频:再等一下用 stale/缓存,不要连打
if _is_okx_rate_limit(e):
time.sleep(1.5)
try:
instruments = fetch_option_instruments(ex, family, allow_stale=True)
if instruments:
instruments_err = ""
except Exception as e2:
instruments_err = str(e2) or e2.__class__.__name__
tickers = fetch_option_tickers(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0
@@ -731,6 +874,7 @@ def build_option_chain(
opt_type=opt_type,
strike=strike,
index_px=idx,
inst_id=inst_id,
)
ask = q["ask"]
bid = q["bid"]
@@ -742,6 +886,8 @@ def build_option_chain(
strike=strike,
ask_px=ask,
mark_px=mark,
inst_id=inst_id,
margin_mode=chain_margin,
)
mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx)
exp_key = str(exp_ms)
@@ -758,7 +904,7 @@ def build_option_chain(
"mark_px": mark,
"ask_estimated": q["ask_estimated"],
"expiry_be_px": expiry_be,
"dist_expiry_be": idx_distance_to_be(idx, expiry_be),
"dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=opt_type),
"moneyness": mny,
"moneyness_label": option_moneyness_label(mny),
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
@@ -774,6 +920,8 @@ def build_option_chain(
"underlying": u,
"index_px": idx,
"inst_family": family,
"margin_mode": "usdc" if "_UM" in family.upper() else "coin",
"premium_ccy": "USDC" if "_UM" in family.upper() else u,
"expiries": exp_list,
"instruments_count": len(instruments),
}
@@ -884,6 +1032,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
strike=strike,
ask_px=book_ask if can_open else None,
mark_px=mark,
inst_id=inst_id,
)
return {
"ok": True,
@@ -902,7 +1051,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
"open_block_msg": "" if can_open else open_block_msg,
"index_px": idx,
"expiry_be_px": expiry_be,
"dist_expiry_be": idx_distance_to_be(idx, expiry_be),
"dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
"min_sz": int(_safe_float(meta.get("minSz")) or 1),
"tick_sz": tick_sz,
@@ -1312,6 +1461,15 @@ def format_option_history_row(
ctime = _safe_float(raw.get("cTime"))
opt_type, strike = option_fields_from_inst_id(inst_id)
uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "")
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
premium_ccy = premium_ccy_for_mode(row_mode, uly or "ETH")
except Exception:
row_mode = "usdc"
premium_ccy = "USDC"
idx_px = _safe_float(raw.get("idxPx") or raw.get("idx_px"))
if close_type in ("3", "4"):
status_label = "强平"
else:
@@ -1333,12 +1491,17 @@ def format_option_history_row(
"strike": strike,
"sheets": sheets_i,
"eth_amount": eth_amount,
"ct_mult": ct_mult,
"open_avg_px": open_avg,
"open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None,
"close_avg_px": close_avg,
"close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None,
"premium_paid": premium_paid,
"premium_paid_fmt": format_usdc_amount(premium_paid),
"premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"premium_ccy": premium_ccy,
"margin_mode": row_mode,
"margin_mode_label": "币本位" if row_mode == "coin" else "USDC",
"idx_px": idx_px,
"realized_pnl": realized,
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
"status": "closed",
@@ -1361,6 +1524,7 @@ def format_live_option_history_row(
inst_id = str(row.get("inst_id") or "").strip()
pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None
close_ms = open_ms
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
return {
"source": "live",
"history_key": option_history_row_key(
@@ -1382,6 +1546,10 @@ def format_live_option_history_row(
"close_avg_px_fmt": None,
"premium_paid": row.get("premium_paid"),
"premium_paid_fmt": row.get("premium_paid_fmt"),
"premium_ccy": premium_ccy,
"margin_mode": row.get("margin_mode"),
"margin_mode_label": row.get("margin_mode_label"),
"idx_px": row.get("idx_px"),
"realized_pnl": row.get("upl"),
"pnl_ratio_pct": row.get("upl_ratio_pct"),
"status": "open",
@@ -1625,6 +1793,7 @@ def format_position_row(
close_breakeven_idx,
expiry_breakeven_px,
idx_distance_to_be,
strike_distance_to_be,
total_premium,
)
@@ -1642,6 +1811,16 @@ def format_position_row(
opt_type = parsed_type
if strike is None:
strike = parsed_strike
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = premium_ccy_for_mode(row_mode, underly)
except Exception:
row_mode = "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = "USDC"
eth_amount = round(abs(sheets) * ct_mult, 8)
premium_paid = (
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
@@ -1652,6 +1831,8 @@ def format_position_row(
strike=strike,
avg_px=avg,
be_px_api=_safe_float(pos.get("bePx")),
inst_id=inst_id,
margin_mode=row_mode,
)
close_be = close_breakeven_idx(
opt_type=str(opt_type or ""),
@@ -1671,11 +1852,14 @@ def format_position_row(
"mark_px": mark,
"avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None,
"mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None,
"premium_paid_fmt": format_usdc_amount(premium_paid),
"premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"tick_sz": tick_sz,
"ct_mult": ct_mult,
"idx_px": idx_px,
"premium_paid": premium_paid,
"margin_mode": row_mode,
"premium_ccy": premium_ccy,
"underlying": underly,
"upl": upl,
"upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None,
"exp_time": exp_time_ms,
@@ -1685,7 +1869,7 @@ def format_position_row(
"avail_pos": _safe_float(pos.get("availPos")),
"expiry_be_px": expiry_be,
"close_be_px": close_be,
"dist_expiry_be": idx_distance_to_be(idx_px, expiry_be),
"dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
"dist_close_be": idx_distance_to_be(idx_px, close_be),
"raw": pos,
}
+158 -5
View File
@@ -58,6 +58,42 @@ def option_expiry_pnl(
return value - float(premium_paid)
def spot_from_expiry_intrinsic_profit(
*,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
profit: float,
) -> float | None:
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
到期价值=实值×张数×乘数;盈亏=价值权利金 实值/=(profit+权利金)/(张数×乘数).
Call: spot=K+实值/; Put: spot=K实值/.
"""
try:
k = float(strike)
n = float(sheets or 0)
ct = float(ct_mult or 0.01)
prem = float(premium_paid or 0)
pnl = float(profit)
except (TypeError, ValueError):
return None
denom = n * ct
if denom <= 0:
return None
need = (pnl + prem) / denom
if need < 0:
need = 0.0
o = (opt_type or "").strip().upper()
if o in ("C", "CALL"):
return round(k + need, 2)
if o in ("P", "PUT"):
return round(k - need, 2)
return None
def suggest_contracts_from_notional(
*,
notional: float,
@@ -447,11 +483,16 @@ def build_options_options_preview(
target_price: float | None = None,
target_price_up: float | None = None,
target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float,
leg_a: dict[str, Any],
leg_b: dict[str, Any],
) -> dict[str, Any]:
"""期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗."""
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
残值按亏损腿本合约权利金的 20% .
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl(
@@ -463,15 +504,127 @@ def build_options_options_preview(
premium_paid=float(leg.get("premium_paid") or 0),
)
prem_a = float(leg_a.get("premium_paid") or 0)
prem_b = float(leg_b.get("premium_paid") or 0)
prem = prem_a + prem_b
rr = float(profit_rr) if profit_rr is not None else None
# 新:盈亏比情景(不依赖指数上下破价)
if rr is not None and rr > 0:
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
win_profit = rr * prem
a_at_a = win_profit
b_at_a_full = -prem_b
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
b_at_b = win_profit
a_at_b_full = -prem_a
a_at_b_res = -prem_a * 0.8
spot_a = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_a.get("opt_type") or ""),
strike=float(leg_a["strike"]),
sheets=float(leg_a.get("sheets") or 0),
ct_mult=float(leg_a.get("ct_mult") or 0.01),
premium_paid=prem_a,
profit=win_profit,
)
spot_b = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_b.get("opt_type") or ""),
strike=float(leg_b["strike"]),
sheets=float(leg_b.get("sheets") or 0),
ct_mult=float(leg_b.get("ct_mult") or 0.01),
premium_paid=prem_b,
profit=win_profit,
)
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"profit_rr": rr,
"target_price": None,
"target_price_up": None,
"target_price_down": None,
"winner_at_up": "a",
"winner_at_down": "b",
"winner_at_target": "a",
"scenarios": [
{
"id": "rr_leg_a_full",
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_full, 4),
"total": round(a_at_a + b_at_a_full, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_b_full",
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
"spot": spot_b,
"leg_a_pnl": round(a_at_b_full, 4),
"leg_b_pnl": round(b_at_b, 4),
"total": round(a_at_b_full + b_at_b, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_a_residual",
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_res, 4),
"total": round(a_at_a + b_at_a_res, 4),
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
},
{
"id": "expiry_flat",
"label": "到期·现价",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"profit_rr": rr,
"spot_at_rr_a": spot_a,
"spot_at_rr_b": spot_b,
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
"at_target_total": round(a_at_a + b_at_a_full, 4),
"expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
"rr_risk_premium": round(prem, 6),
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
},
}
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
up = target_price_up if target_price_up is not None else target_price
down = target_price_down if target_price_down is not None else target_price
if up is None or down is None:
raise ValueError("缺少上破/下破目标价")
raise ValueError("缺少盈亏比或上破/下破目标价")
up_f = float(up)
down_f = float(down)
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up
@@ -528,8 +681,8 @@ def build_options_options_preview(
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
+21 -4
View File
@@ -72,7 +72,9 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
)
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
# 永期「以期权为主」
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
@@ -264,7 +266,7 @@ def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]])
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
这些目标由 hedge_plan_monitor_lib 执行绝不能写入 options_target_monitors
否则两套监控会同时尝试平掉同一条期权腿
@@ -272,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
rows = conn.execute(
"""
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
l.inst_id, l.opt_type
p.profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options'
@@ -288,9 +290,24 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
row = dict(raw)
inst_id = str(row.get("inst_id") or "")
opt_type = str(row.get("opt_type") or "").upper()
if not inst_id or inst_id in out:
continue
profit_rr = _sf(row.get("profit_rr"))
if profit_rr is not None and profit_rr > 0:
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"profit_rr": profit_rr,
"target_index": None,
"exit_mode": "profit_rr",
"managed_by": "hedge_plan",
}
continue
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target)
if not inst_id or target_f is None or target_f <= 0 or inst_id in out:
if target_f is None or target_f <= 0:
continue
out[inst_id] = {
"plan_id": int(row["plan_id"]),
+229 -53
View File
@@ -173,11 +173,11 @@ def _notify_end_reload(cfg: dict[str, Any], conn: Any, plan_id: int) -> None:
def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
"""盈利腿平后另一腿:close_all(平) / hold_expiry(到期平).
"""盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平).
- 方案C关闭 强制到期平
- 计划未写 oo_close_mode(旧单) 到期平,避免误清残腿
- 新开仓默认写入 close_all
- 新开仓默认写入 close_all(残值平:权利金初始20%且有买一)
"""
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
return "hold_expiry"
@@ -190,6 +190,12 @@ def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
return "close_all"
# 期期亏损腿残值平:当前买一回收 ≤ 本合约初始权利金 × 该比例
OO_LOSS_LEG_RESIDUAL_RATIO = 0.20
# 期期默认盈亏比:盈利金额 / 总权利金
OO_DEFAULT_PROFIT_RR = 2.0
def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
out = []
for x in legs:
@@ -200,6 +206,63 @@ def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) ->
return out
def _oo_quote_bid(cfg: dict[str, Any], inst_id: str) -> tuple[Optional[float], Optional[float]]:
quote_fn = cfg.get("quote_option_contract")
ex_opt = cfg.get("exchange_options")
if not callable(quote_fn) or ex_opt is None or not inst_id:
return None, None
try:
q = quote_fn(ex_opt, inst_id)
if not q.get("ok"):
return None, None
return _sf(q.get("bid")), _sf(q.get("bid_sz"))
except Exception:
return None, None
def _oo_leg_mark_value(leg: dict[str, Any], bid: Optional[float]) -> Optional[float]:
"""买一可回收金额(USDC)= bid × 张数 × ct_mult."""
b = _sf(bid)
if b is None or b < 0:
return None
sheets = float(leg.get("size") or 1)
ct = float(leg.get("ct_mult") or 0.01)
return float(b) * sheets * ct
def _oo_plan_premium_total(plan: dict[str, Any], legs: list[dict[str, Any]]) -> float:
"""双腿总权利金:优先计划字段,否则对期权腿 premium 求和."""
total = _sf(plan.get("premium_total"))
if total is not None and total > 0:
return float(total)
s = 0.0
for leg in legs:
if not str(leg.get("leg_role") or "").startswith("option"):
continue
s += float(leg.get("premium") or 0)
return s
def _oo_leg_profit_rr(
leg: dict[str, Any], bid: Optional[float], *, total_premium: float
) -> Optional[float]:
"""盈亏比 = 该腿盈利金额 / 总权利金;盈利金额 = 买一回收 − 本腿权利金."""
if total_premium <= 0:
return None
leg_prem = float(leg.get("premium") or 0)
value = _oo_leg_mark_value(leg, bid)
if value is None:
return None
return (value - leg_prem) / total_premium
def _oo_resolve_profit_rr(plan: dict[str, Any]) -> Optional[float]:
rr = _sf(plan.get("profit_rr"))
if rr is not None and rr > 0:
return rr
return None
def _finalize_oo_all_closed(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
) -> dict[str, Any]:
@@ -985,7 +1048,12 @@ def _estimate_leg_close_pnl(leg: dict[str, Any], idx: Optional[float], bid: Opti
def _tick_oo_close_rest(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""盈利腿已平后:平模式清残腿(无2×门控,买一失败则下轮重试)."""
"""盈利腿已平后:残值平模式清亏损腿.
条件:买一回收 本合约初始权利金×20%,且买一有流动性;失败或未达条件则下轮重试.
"""
from lib.hedge_plan.hedge_plan_option_primary_lib import option_bid_liquidity_ok
if resolve_oo_rest_close_mode(plan) != "close_all":
return None
open_legs = _oo_option_legs(legs, statuses=("open",))
@@ -998,6 +1066,7 @@ def _tick_oo_close_rest(
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing",
"",
)
@@ -1008,23 +1077,45 @@ def _tick_oo_close_rest(
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
acted = False
waiting = False
for leg in list(open_legs):
close_r = _sell_option(
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
)
inst_id = str(leg.get("inst_id") or "")
sheets = float(leg.get("size") or 1)
premium = float(leg.get("premium") or 0)
bid, bid_sz = _oo_quote_bid(cfg, inst_id)
value = _oo_leg_mark_value(leg, bid)
# 残值门槛:相对本合约初始权利金,买一回收须 ≤ 20%
if premium > 0:
if value is None:
waiting = True
continue
if value > premium * OO_LOSS_LEG_RESIDUAL_RATIO + 1e-12:
waiting = True
continue
liq_ok, liq_msg = option_bid_liquidity_ok(bid, bid_sz, need_sheets=sheets)
if not liq_ok:
waiting = True
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {
"plan_id": plan["id"],
"msg": "残值平等待买一流动性",
"detail": liq_msg,
"waiting": True,
}
close_r = _sell_option(cfg, inst_id=inst_id, sheets=sheets)
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期全平·残腿平仓失败(将重试)",
title="期期残值平·亏损腿平仓失败(将重试)",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
bid = _sf(close_r.get("bid"))
est = _estimate_leg_close_pnl(leg, idx, bid)
bid_fill = _sf(close_r.get("bid")) or bid
est = _estimate_leg_close_pnl(leg, idx, bid_fill)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
@@ -1035,6 +1126,9 @@ def _tick_oo_close_rest(
acted = True
if not acted:
if waiting:
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残值平等待本合约权利金≤20%", "waiting": True}
return None
legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
@@ -1046,10 +1140,126 @@ def _tick_oo_close_rest(
)
def _after_oo_winner_closed(
cfg: dict[str, Any],
conn: Any,
plan: dict[str, Any],
open_legs: list[dict[str, Any]],
best: dict[str, Any],
*,
reason: str,
extra: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""盈利腿已平后:残值平同轮尝试 / 到期平标记 hold_to_expiry."""
rest_mode = resolve_oo_rest_close_mode(plan)
update_plan(conn, int(plan["id"]), close_reason=reason)
mid = dict(plan)
mid["close_reason"] = reason
mid["status"] = "active"
mid["oo_close_mode"] = rest_mode
notify_plan_end(cfg, conn, mid)
out: dict[str, Any] = {
"plan_id": plan["id"],
"close_reason": reason,
"closed_leg": best.get("id"),
"oo_close_mode": rest_mode,
}
if extra:
out.update(extra)
if rest_mode == "close_all":
legs2 = get_plan_legs(conn, int(plan["id"]))
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
if rest:
out["rest"] = rest
return out
for leg in open_legs:
if int(leg.get("id") or 0) == int(best.get("id") or 0):
continue
conn.execute(
"UPDATE hedge_plan_legs SET status=? WHERE id=?",
("hold_to_expiry", leg["id"]),
)
return out
def _tick_oo_profit_rr(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, rr_target: float
) -> Optional[dict[str, Any]]:
"""期期:任一开仓腿盈亏比(该腿盈利金额/总权利金)达目标 → 平盈利腿."""
if not _env_bool("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", True):
return None
open_legs = _oo_option_legs(legs, statuses=("open",))
if len(open_legs) < 2:
return None
total_prem = _oo_plan_premium_total(plan, legs)
if total_prem <= 0:
return None
ranked: list[tuple[float, float, dict[str, Any]]] = []
for leg in open_legs:
bid, _bid_sz = _oo_quote_bid(cfg, str(leg.get("inst_id") or ""))
rr = _oo_leg_profit_rr(leg, bid, total_premium=total_prem)
if rr is None:
continue
value = _oo_leg_mark_value(leg, bid) or 0.0
premium = float(leg.get("premium") or 0)
pnl = value - premium
ranked.append((rr, pnl, leg))
if not ranked:
return None
ranked.sort(key=lambda x: x[0], reverse=True)
best_rr, best_pnl, best = ranked[0]
if best_rr + 1e-12 < float(rr_target) or best_pnl <= 0:
return None
close_r = _sell_option(
cfg, inst_id=str(best.get("inst_id") or ""), sheets=float(best.get("size") or 1)
)
if not close_r.get("ok"):
notify_hedge(
cfg,
build_hedge_alert_message(
title="期期平盈利腿失败",
plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r),
),
)
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "profit_rr_win_leg"
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), closed_pnl, best["id"]),
)
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
return _after_oo_winner_closed(
cfg,
conn,
plan,
open_legs,
best,
reason=reason,
extra={
"profit_rr": best_rr,
"rr_target": float(rr_target),
"total_premium": total_prem,
"index": idx,
},
)
def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
"""期期:优先按盈亏比平盈利腿;旧单无 profit_rr 时回退上/下破目标价."""
rr_target = _oo_resolve_profit_rr(plan)
if rr_target is not None:
return _tick_oo_profit_rr(cfg, conn, plan, legs, rr_target=rr_target)
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None:
return None
@@ -1065,10 +1275,8 @@ def _tick_oo_target(
return None
hit_side: Optional[str] = None
# 上破:现价接近或超过上破目标
if up is not None and idx >= up * 0.998:
hit_side = "up"
# 下破:现价接近或低于下破目标
elif down is not None and idx <= down * 1.002:
hit_side = "down"
if not hit_side:
@@ -1102,52 +1310,20 @@ def _tick_oo_target(
)
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg"
# 选腿用内在估算;落库优先交易所已实现盈亏
closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), closed_pnl, best["id"]),
)
rest_mode = resolve_oo_rest_close_mode(plan)
update_plan(conn, int(plan["id"]), close_reason=reason)
mid = dict(plan)
mid["close_reason"] = reason
mid["status"] = "active"
mid["oo_close_mode"] = rest_mode
notify_plan_end(cfg, conn, mid)
# 全平:同轮尝试清残腿;失败则下轮 _tick_oo_close_rest 重试
if rest_mode == "close_all":
legs2 = get_plan_legs(conn, int(plan["id"]))
rest = _tick_oo_close_rest(cfg, conn, mid, legs2)
out = {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
if rest:
out["rest"] = rest
return out
# 到期平:显式标记残腿 hold_to_expiry
for leg in open_legs:
if int(leg.get("id") or 0) == int(best.get("id") or 0):
continue
conn.execute(
"UPDATE hedge_plan_legs SET status=? WHERE id=?",
("hold_to_expiry", leg["id"]),
)
return {
"plan_id": plan["id"],
"close_reason": reason,
"hit_side": hit_side,
"closed_leg": best.get("id"),
"index": idx,
"oo_close_mode": rest_mode,
}
return _after_oo_winner_closed(
cfg,
conn,
plan,
open_legs,
best,
reason=reason,
extra={"hit_side": hit_side, "index": idx},
)
def _tick_oo_expiry(
+40 -18
View File
@@ -46,13 +46,22 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
]
)
else:
lines.extend(
[
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
rr = plan.get("profit_rr")
if rr not in (None, ""):
lines.extend(
[
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
lines.extend(
[
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
if legs:
for leg in legs:
role = leg.get("leg_role") or ""
@@ -81,8 +90,9 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
"target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿",
"oo_rest_closing": "期期全平·清残腿中",
"oo_rest_closed": "期期全平·两腿已平",
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
"oo_rest_closing": "期期残值平·清亏损腿中",
"oo_rest_closed": "期期残值平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口",
@@ -152,25 +162,37 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed":
side = "上破" if "up" in str(plan.get("close_reason")) else (
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
)
cr = str(plan.get("close_reason") or "")
if "profit_rr" in cr:
side = "盈亏比达标"
elif "up" in cr:
side = "上破"
elif "down" in cr:
side = "下破"
else:
side = "目标"
mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平"):
rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)"
if mode in ("close_all", "全平", "残值平"):
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
else:
rest_txt = "另一腿到期平(持有至到期结算)"
rr = plan.get("profit_rr")
if rr not in (None, ""):
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
else:
detail = (
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
)
notify_hedge(
cfg,
build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"),
detail=(
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
),
detail=detail,
),
)
return True
+24 -19
View File
@@ -1146,20 +1146,30 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
b = body.get("leg_b") or {}
if not a.get("inst_id") or not b.get("inst_id"):
return "请选用两条期权腿"
up = body.get("target_price_up")
down = body.get("target_price_down")
legacy = body.get("target_price")
if up in (None, "") and legacy not in (None, ""):
up = legacy
if down in (None, "") and legacy not in (None, ""):
down = legacy
if up in (None, "") or down in (None, ""):
return "请填写上破与下破目标价"
try:
if float(up) <= float(down):
return "上破目标价必须大于下破目标价"
except (TypeError, ValueError):
return "目标价无效"
rr_raw = body.get("profit_rr")
if rr_raw not in (None, ""):
try:
rr = float(rr_raw)
except (TypeError, ValueError):
return "盈亏比无效"
if rr <= 0:
return "盈亏比须大于0"
else:
# 兼容旧上/下破
up = body.get("target_price_up")
down = body.get("target_price_down")
legacy = body.get("target_price")
if up in (None, "") and legacy not in (None, ""):
up = legacy
if down in (None, "") and legacy not in (None, ""):
down = legacy
if up in (None, "") or down in (None, ""):
return "请填写盈亏比"
try:
if float(up) <= float(down):
return "上破目标价必须大于下破目标价"
except (TypeError, ValueError):
return "目标价无效"
from lib.hedge_plan.hedge_plan_moneyness_lib import (
parse_strike_from_inst,
validate_oo_legs_moneyness,
@@ -1179,11 +1189,6 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
return {"opt_type": opt_type, "strike": strike}
index_px = body.get("index_px")
if index_px in (None, ""):
try:
index_px = (float(up) + float(down)) / 2.0
except (TypeError, ValueError):
index_px = None
money_err = validate_oo_legs_moneyness(
_leg_for_money(a),
_leg_for_money(b),
+57 -21
View File
@@ -537,27 +537,36 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
float(b.get("premium") or 0) if b_ok else 0.0
)
rr_raw = body.get("profit_rr")
try:
profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
except (TypeError, ValueError):
profit_rr = 2.0
if profit_rr <= 0:
profit_rr = 2.0
# 旧字段兼容:不再要求上/下破;有传则原样落库
def _opt_float(key: str, *alts: str) -> float | None:
for k in (key, *alts):
v = body.get(k)
if v not in (None, ""):
try:
return float(v)
except (TypeError, ValueError):
continue
return None
up_f = _opt_float("target_price_up", "target_price")
down_f = _opt_float("target_price_down", "target_price")
plan_id = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float(
body.get("target_price_up")
or body.get("target_price")
or 0
),
"target_price_up": float(
body.get("target_price_up")
or body.get("target_price")
or 0
),
"target_price_down": float(
body.get("target_price_down")
or body.get("target_price")
or 0
),
"target_price": up_f,
"target_price_up": up_f,
"target_price_down": down_f,
"profit_rr": profit_rr,
"sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
@@ -817,6 +826,20 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
body = request.get_json(silent=True) or {}
plan_type = (body.get("plan_type") or "perp_options").strip().lower()
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
if is_coin_margin_mode() and not dry_run:
return jsonify(
{
"ok": False,
"msg": "当前单笔期权为币本位模式,对冲计划仅支持 USDC 期权;请将 OKX_OPTIONS_MARGIN_MODE=usdc 并重启后再开对冲",
}
), 400
except Exception as e:
return jsonify(
{"ok": False, "msg": f"期权本位校验失败,已拒绝开对冲: {e}"}
), 400
with _hedge_start_lock():
gates = _gates_dict(cfg, plan_type)
if not dry_run and not gates.get("can_start"):
@@ -1238,6 +1261,12 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
rr_raw = body.get("profit_rr")
profit_rr = None
if rr_raw not in (None, ""):
profit_rr = float(rr_raw)
if profit_rr <= 0:
raise ValueError("盈亏比须大于0")
up = body.get("target_price_up")
down = body.get("target_price_down")
legacy = body.get("target_price")
@@ -1245,13 +1274,19 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
up = legacy
if down in (None, "") and legacy not in (None, ""):
down = legacy
if up in (None, "") or down in (None, ""):
raise ValueError("请填写上破与下破目标价")
up_f = float(up)
down_f = float(down)
if up_f <= down_f:
if profit_rr is None and (up in (None, "") or down in (None, "")):
raise ValueError("请填写盈亏比")
up_f = float(up) if up not in (None, "") else None
down_f = float(down) if down not in (None, "") else None
if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f:
raise ValueError("上破目标价必须大于下破目标价")
index_px = float(body.get("index_px") or ((up_f + down_f) / 2))
index_px = body.get("index_px")
if index_px in (None, ""):
if up_f is not None and down_f is not None:
index_px = (up_f + down_f) / 2
else:
raise ValueError("缺少指数价格")
index_px = float(index_px)
leg_a = body.get("leg_a") or {}
leg_b = body.get("leg_b") or {}
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
@@ -1269,6 +1304,7 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
if money_err:
raise ValueError(money_err)
return build_options_options_preview(
profit_rr=profit_rr,
target_price_up=up_f,
target_price_down=down_f,
index_px=index_px,
@@ -213,7 +213,7 @@
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
@@ -221,8 +221,7 @@
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div>
<div class="form-row hp-target-row hp-oo-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div>
<div class="hp-oo-controls">
@@ -237,7 +236,7 @@
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后立刻买一清另一腿(无2×,失败重试)"><span class="hp-oo-check" aria-hidden="true"></span></button>
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>残值</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div>
</div>
+66 -9
View File
@@ -74,21 +74,50 @@ def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str
def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]:
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U."""
snap = options_snap if isinstance(options_snap, dict) else {}
if snap.get("enabled") is False or snap.get("ok") is False:
return None
upl = snap.get("upl_total_usdc")
if upl is not None:
def _safe(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return round(float(upl), 4)
return float(v)
except (TypeError, ValueError):
pass
# 快照偶发缺合计时,按持仓行回退汇总(与卡片展示一致)
return None
def _index_px() -> float | None:
px = _safe(snap.get("options_index_px") or snap.get("index_px"))
if px is not None and px > 0:
return px
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
px = _safe(p.get("idx_px") or p.get("idxPx"))
if px is not None and px > 0:
return px
return None
def _row_is_coin(p: dict[str, Any]) -> bool:
ccy = str(p.get("premium_ccy") or "").strip().upper()
if ccy in ("ETH", "BTC"):
return True
if str(p.get("margin_mode") or "").strip().lower() == "coin":
return True
mid = str(p.get("inst_id") or "")
return "-USD-" in mid.upper() and "_UM" not in mid.upper()
mode = str(snap.get("options_margin_mode") or snap.get("margin_mode") or "").strip().lower()
snap_coin = mode == "coin"
idx = _index_px()
try:
from lib.options.options_positions_lib import display_pnl_from_option_row
total = 0.0
total_u = 0.0
found = False
missing_fx = False
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
@@ -96,10 +125,38 @@ def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[floa
if pnl is None:
continue
found = True
total += float(pnl)
return round(total, 4) if found else None
if snap_coin or _row_is_coin(p):
px = _safe(p.get("idx_px") or p.get("idxPx"))
if px is None or px <= 0:
px = idx
if px is None or px <= 0:
missing_fx = True
continue
total_u += float(pnl) * float(px)
else:
total_u += float(pnl)
if found and not missing_fx:
return round(total_u, 4)
if found and missing_fx and abs(total_u) > 1e-12:
# 部分腿已换算成功时仍返回可得合计
return round(total_u, 4)
except Exception:
return None
pass
upl = snap.get("upl_total_usdc")
if upl is not None:
try:
raw = float(upl)
except (TypeError, ValueError):
return None
if snap_coin or any(
isinstance(p, dict) and _row_is_coin(p) for p in (snap.get("positions") or [])
):
if idx is None or idx <= 0:
return None
return round(raw * float(idx), 4)
return round(raw, 4)
return None
def options_open_position_count(options_snap: dict[str, Any] | None) -> int:
+45
View File
@@ -0,0 +1,45 @@
"""中控后台轮询等待:防止 request_refresh 连锁打满 CPU."""
from __future__ import annotations
import asyncio
import time
async def wait_poll_interval(
*,
refresh: asyncio.Event,
stop: asyncio.Event,
interval_sec: float,
started_at: float,
min_early_wake_sec: float | None = None,
) -> None:
"""距 started_at 至少间隔 interval_sec 再进入下一轮.
期间若收到 refresh:仅当已过 min_early_wake_sec 才提前结束(兼顾手动刷新与防抖).
"""
interval = max(0.05, float(interval_sec))
min_early = (
float(min_early_wake_sec)
if min_early_wake_sec is not None
else min(2.0, interval * 0.4)
)
while not stop.is_set():
left = interval - (time.monotonic() - started_at)
if left <= 0:
return
refresh.clear()
stop_task = asyncio.create_task(stop.wait())
refresh_task = asyncio.create_task(refresh.wait())
done, pending = await asyncio.wait(
{stop_task, refresh_task},
timeout=left,
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
if stop.is_set():
return
if not done:
return
if refresh_task in done and (time.monotonic() - started_at) >= min_early:
return
+51 -3
View File
@@ -121,20 +121,40 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
return default
def _format_profit_exit_mult(mult: Any) -> str:
try:
n = float(mult)
except (TypeError, ValueError):
return "1倍"
if n <= 0:
return "1倍"
if abs(n - round(n)) < 1e-9:
return f"{int(round(n))}"
return f"{n:g}"
def _format_options_target(p: dict[str, Any]) -> str:
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
if hedge:
rr = _safe_float(hedge.get("profit_rr"))
pid = hedge.get("plan_id")
if rr is not None and rr > 0:
return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}"
ot = str(hedge.get("opt_type") or opt_type).upper()
side = "Put ≤" if ot == "P" else "Call ≥"
tgt = _safe_float(hedge.get("target_index"))
pid = hedge.get("plan_id")
if tgt is not None:
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
parts: list[str] = []
tgt = _safe_float(p.get("target_index"))
if tgt is not None and tgt > 0:
side = "Put ≤" if opt_type == "P" else "Call ≥"
return f"{side} {tgt:g}"
parts.append(f"{side} {tgt:g}")
if p.get("profit_exit_enabled"):
parts.append(_format_profit_exit_mult(p.get("profit_exit_mult")))
if parts:
return " · ".join(parts)
return ""
@@ -350,11 +370,39 @@ def collect_options_items(
raw = fetch_options_positions() or []
except Exception:
return []
pe_map: dict[str, dict[str, Any]] = {}
tgt_map: dict[str, dict[str, Any]] = {}
hedge_map: dict[str, dict[str, Any]] = {}
if conn is not None:
try:
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import targets_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
pe_map = profit_exit_by_inst(conn)
tgt_map = targets_by_inst(conn)
hedge_map = active_options_targets_by_inst(conn)
except Exception:
pe_map, tgt_map, hedge_map = {}, {}, {}
out: list[dict[str, Any]] = []
for p in raw:
if not isinstance(p, dict):
continue
out.append(_format_options_item(p, conn=conn))
row = dict(p)
inst = str(row.get("inst_id") or row.get("instId") or "").strip()
mon = tgt_map.get(inst)
if mon:
row["target_index"] = mon.get("target_index")
pe = pe_map.get(inst)
if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult")
hedge = hedge_map.get(inst)
if hedge:
row["hedge_plan_target"] = hedge
if not mon:
row["target_index"] = hedge.get("target_index")
out.append(_format_options_item(row, conn=conn))
return out
+59 -2
View File
@@ -103,9 +103,11 @@ def profit_loss_ratio_from_trades(trades: list[dict[str, Any]] | None) -> float
def options_funding_label(
funding_usdc: float | None,
funding_usdt: float | None = None,
funding_eth: float | None = None,
margin_mode: str | None = None,
underly: str = "ETH",
) -> str:
"""期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略."""
_ = funding_usdt
"""期权侧顶栏文案(仅 USDC 模式使用;币本位不展示期权资金/交易两列)."""
if funding_usdc is None:
return ""
try:
@@ -114,6 +116,61 @@ def options_funding_label(
return ""
def _fmt_coin_amount(v: float | None, *, min_amt: float = 1e-6) -> str | None:
if v is None:
return None
try:
n = float(v)
except Exception:
# Jinja Undefined 等也吞掉,避免顶栏 float(Undefined) → HTTP 500
return None
if n < min_amt:
return None
txt = f"{n:.6f}".rstrip("0").rstrip(".")
return txt or None
def trading_account_label(
usdt: float | None,
eth: float | None = None,
btc: float | None = None,
*,
margin_mode: str | None = None,
) -> str:
"""交易账户顶栏文案.
币本位:USDT / ETH / BTC 多行(有余额才带上,不显示其它币种).
其它模式:xx.xxU.
"""
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
mode = normalize_options_margin_mode(margin_mode)
except Exception:
mode = str(margin_mode or "coin").strip().lower() or "coin"
if mode != "coin":
if usdt is None:
return ""
try:
return f"{float(usdt):.2f}U"
except (TypeError, ValueError):
return ""
parts: list[str] = []
if usdt is not None:
try:
parts.append(f"{float(usdt):.2f} USDT")
except (TypeError, ValueError):
pass
eth_txt = _fmt_coin_amount(eth, min_amt=1e-6)
if eth_txt is not None:
parts.append(f"{eth_txt} ETH")
btc_txt = _fmt_coin_amount(btc, min_amt=1e-7)
if btc_txt is not None:
parts.append(f"{btc_txt} BTC")
# 顶栏多行:USDT / ETH 各占一行
return "\n".join(parts) if parts else ""
def total_funds_usdt(
funding_usdt: float | None,
trading_usdt: float | None,
+9
View File
@@ -220,10 +220,19 @@ def pwa_app_name(exchange_key: str) -> str:
def embed_context_extras(exchange_key: str) -> dict:
# 顶栏共享模板调用 trading_account_label / options_funding_label;
# 须注入三所,否则 Gate/Binance 渲染会 UndefinedError → HTTP 500.
from lib.instance.instance_embed_context_lib import (
options_funding_label,
trading_account_label,
)
return {
"order_rule_tips_tpl": order_rule_tips_template(exchange_key),
"include_transfer_block": include_transfer_block(exchange_key),
"ui_open_guard_enabled": ui_open_guard_enabled(exchange_key),
"ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key),
"pwa_app_name": pwa_app_name(exchange_key),
"options_funding_label": options_funding_label,
"trading_account_label": trading_account_label,
}
@@ -106,6 +106,24 @@ def register_instance_settings_routes(
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
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
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)
+140 -20
View File
@@ -1075,7 +1075,7 @@ function refreshOrderDefaults(){
}).catch(()=>{});
}
function paintRealtimePnl(v){
function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1086,23 +1086,48 @@ function paintRealtimePnl(v){
return;
}
const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : "";
const text = `${sign}${n.toFixed(2)}U`;
let text;
let tone = n;
if (u === "ETH" || u === "BTC") {
// 顶栏实时盈亏只显示 U(按指数/现货换算),不展示币数量
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
tone = uu;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${uSign}${uAbs}U`;
} else {
text = "—";
tone = 0;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
nodes.forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", n > 0);
pnlEl.classList.toggle("pnl-neg", n < 0);
pnlEl.classList.toggle("pnl-pos", tone > 0);
pnlEl.classList.toggle("pnl-neg", tone < 0);
});
}
let lastRealtimePnl = null;
function updateRealtimePnl(v){
let lastRealtimePnlUnit = "U";
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v);
paintRealtimePnl(v);
if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return;
}
if(lastRealtimePnl != null) return;
paintRealtimePnl(v);
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
}
function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null;
@@ -1130,19 +1155,76 @@ function paintRealtimePnlFromSnapshot(data){
const perp = data.order_prices && data.order_prices.length
? sumOrdersFloatPnl(data.order_prices)
: null;
const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl);
if(combined !== null || perp !== null || data.options_unrealized_pnl != null){
paintRealtimePnl(combined);
const opt = data.options_unrealized_pnl;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) {
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0);
pnlEl.classList.toggle("pnl-neg", totalU < 0);
});
return;
}
paintRealtimePnl(opt, underly, spotPx);
return;
}
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) {
return;
}
}
const combined = combineRealtimeFloatPnl(perp, opt);
if(combined !== null || perp !== null || opt != null){
paintRealtimePnl(combined, "U");
}
}
function formatOptionsFundingLabel(usdc, usdt) {
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
if (usdc === null || usdc === undefined || usdc === "") return "—";
const n = Number(usdc);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`;
}
function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
// 缺省按 usdc(xx.xxU):Gate/Binance 快照无 options_margin_mode;OKX 会显式下发.
const mode = String(marginMode || "usdc").toLowerCase();
if (mode !== "coin") {
if (usdt === null || usdt === undefined || usdt === "") return "—";
const n = Number(usdt);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
const parts = [];
if (usdt !== null && usdt !== undefined && usdt !== "") {
const n = Number(usdt);
if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
const pushCoin = (v, ccy) => {
if (v === null || v === undefined || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
pushCoin(eth, "ETH");
pushCoin(btc, "BTC");
return parts.length ? parts.join("\n") : "—";
}
function setFundsFieldText(field, text){
if(text == null || text === "") return;
@@ -1156,6 +1238,11 @@ function applyPerpFundsVisibility(show){
el.style.display = on ? "" : "none";
});
}
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){
@@ -1175,9 +1262,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if(typeof data.show_perp_funds !== "undefined"){
applyPerpFundsVisibility(data.show_perp_funds);
applyPerpFundsVisibility(data.show_perp_funds !== false || coinMode);
} else if (coinMode) {
applyPerpFundsVisibility(true);
}
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
}
@@ -1185,18 +1276,47 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
}
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`);
setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
}
if(data.options_funding_usdc != null || data.options_funding_usdt != null){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt);
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding);
}
if(data.options_trading_usdc != null || data.options_trading_usdt != null){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt);
if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading);
}
if(typeof data.unrealized_pnl !== "undefined"){
updateRealtimePnl(data.unrealized_pnl);
if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) {
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
}
if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total));
+2 -2
View File
@@ -8,7 +8,7 @@
<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=13">
<link rel="stylesheet" href="/static/instance_theme.css?v=114">
<link rel="stylesheet" href="/static/instance_theme.css?v=117">
<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">
@@ -170,7 +170,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script>
<script src="/static/instance_settings_prefs.js?v=19"></script>
<script src="/static/instance_settings_prefs.js?v=22"></script>
<script src="/static/instance_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=31"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script>
+1 -1
View File
@@ -37,7 +37,7 @@
{% endif %}
<div class="env-form-grid">
{% for field in group.fields %}
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}">
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}" data-env-key="{{ field.key }}"{% if field.hidden %} hidden style="display:none"{% endif %}>
<label class="env-field-label" for="env-f-{{ field.key }}">
{{ field.label or field.key }}
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
+144 -22
View File
@@ -19,7 +19,7 @@
<link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=13">
<link rel="stylesheet" href="/static/instance_theme.css?v=114">
<link rel="stylesheet" href="/static/instance_theme.css?v=117">
</head>
<body
@@ -1556,7 +1556,7 @@ function refreshOrderDefaults(){
}).catch(()=>{});
}
function paintRealtimePnl(v){
function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1567,23 +1567,48 @@ function paintRealtimePnl(v){
return;
}
const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : "";
const text = `${sign}${n.toFixed(2)}U`;
let text;
let tone = n;
if (u === "ETH" || u === "BTC") {
// 顶栏实时盈亏只显示 U(按指数/现货换算),不展示币数量
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
tone = uu;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${uSign}${uAbs}U`;
} else {
text = "—";
tone = 0;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
nodes.forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", n > 0);
pnlEl.classList.toggle("pnl-neg", n < 0);
pnlEl.classList.toggle("pnl-pos", tone > 0);
pnlEl.classList.toggle("pnl-neg", tone < 0);
});
}
let lastRealtimePnl = null;
function updateRealtimePnl(v){
let lastRealtimePnlUnit = "U";
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v);
paintRealtimePnl(v);
if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return;
}
if(lastRealtimePnl != null) return;
paintRealtimePnl(v);
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
}
function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null;
@@ -1611,19 +1636,77 @@ function paintRealtimePnlFromSnapshot(data){
const perp = data.order_prices && data.order_prices.length
? sumOrdersFloatPnl(data.order_prices)
: null;
const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl);
if(combined !== null || perp !== null || data.options_unrealized_pnl != null){
paintRealtimePnl(combined);
const opt = data.options_unrealized_pnl;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) {
// 币本位期权盈亏单位为币,勿与永续 U 混加成「xxU」
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0);
pnlEl.classList.toggle("pnl-neg", totalU < 0);
});
return;
}
paintRealtimePnl(opt, underly, spotPx);
return;
}
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) {
return;
}
}
const combined = combineRealtimeFloatPnl(perp, opt);
if(combined !== null || perp !== null || opt != null){
paintRealtimePnl(combined, "U");
}
}
function formatOptionsFundingLabel(usdc, usdt) {
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
if(usdc == null || usdc === "") return "—";
const n = Number(usdc);
if(Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`;
}
function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
// 缺省按 usdc(xx.xxU):Gate/Binance 快照无 options_margin_mode;OKX 会显式下发.
const mode = String(marginMode || "usdc").toLowerCase();
if (mode !== "coin") {
if (usdt == null || usdt === "") return "—";
const n = Number(usdt);
if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)}U`;
}
const parts = [];
if (usdt != null && usdt !== "") {
const n = Number(usdt);
if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
const pushCoin = (v, ccy) => {
if (v == null || v === "") return;
const n = Number(v);
if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
pushCoin(eth, "ETH");
pushCoin(btc, "BTC");
return parts.length ? parts.join("\n") : "—";
}
function setFundsFieldText(field, text){
if(text == null || text === "") return;
@@ -1637,6 +1720,11 @@ function applyPerpFundsVisibility(show){
el.style.display = on ? "" : "none";
});
}
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){
@@ -1656,9 +1744,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if(typeof data.show_perp_funds !== "undefined"){
applyPerpFundsVisibility(data.show_perp_funds);
applyPerpFundsVisibility(data.show_perp_funds !== false || coinMode);
} else if (coinMode) {
applyPerpFundsVisibility(true);
}
applyOptionsFundsVisibility(!coinMode);
if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
}
@@ -1666,18 +1758,48 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
}
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`);
setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
}
if(data.options_funding_usdc != null || data.options_funding_usdt != null){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt);
if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding);
}
if(data.options_trading_usdc != null || data.options_trading_usdt != null){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt);
if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading);
}
if(typeof data.unrealized_pnl !== "undefined"){
updateRealtimePnl(data.unrealized_pnl);
if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) {
// 币本位:顶栏跟持仓卡同口径(期权净盈亏),勿用混加后的 unrealized_pnl(会被抹成 0.00)
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
}
if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total));
@@ -2045,6 +2167,6 @@ document.addEventListener("DOMContentLoaded", function () {
});
{% endif %}
</script>
<script src="/static/instance_settings_prefs.js?v=19"></script>
<script src="/static/instance_settings_prefs.js?v=22"></script>
</body>
</html>
@@ -38,11 +38,14 @@
{% include 'instance_header_stats.html' %}
</div>
<div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要">
<span class="inst-phone-chip"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
{% set _coin_margin = (options_enabled|default(false)) and (options_margin_mode|default('coin')) == 'coin' %}
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
{% set _trading_margin_mode = 'coin' if _coin_margin else 'usdc' %}
<span class="inst-phone-chip"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<em>交易</em>
<b data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</b>
<b data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth|default(none), options_trading_btc|default(none), margin_mode=_trading_margin_mode) }}</b>
</span>
<span class="inst-phone-chip"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
<span class="inst-phone-chip"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<em>资金</em>
<b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b>
</span>
@@ -1,4 +1,8 @@
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
{# 币本位顶栏仅 OKX 期权开启时生效;Gate/Binance 保持原 xx.xxU #}
{% set _coin_margin = (options_enabled|default(false)) and (options_margin_mode|default('coin')) == 'coin' %}
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
{% set _trading_margin_mode = 'coin' if _coin_margin else 'usdc' %}
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
<div class="stat-strip-item stat-strip-item--primary">
<div class="label">交易所</div>
@@ -24,22 +28,22 @@
<div class="label">总资金</div>
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
</div>
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
<div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<div class="label">资金账户</div>
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
</div>
<div class="stat-strip-item"{% if not (show_perp_funds|default(true)) %} style="display:none"{% endif %} data-perp-funds="1">
<div class="stat-strip-item"{% if not _show_perp %} style="display:none"{% endif %} data-perp-funds="1">
<div class="label">交易账户</div>
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div>
<div class="value" id="current-capital" data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth|default(none), options_trading_btc|default(none), margin_mode=_trading_margin_mode) }}</div>
</div>
{% if options_enabled %}
<div class="stat-strip-item">
{% if options_enabled and not _coin_margin %}
<div class="stat-strip-item" data-options-funds="1">
<div class="label">期权资金账户</div>
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc) }}</div>
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc|default(none), options_funding_usdt|default(none), options_funding_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}</div>
</div>
<div class="stat-strip-item">
<div class="stat-strip-item" data-options-funds="1">
<div class="label">期权交易账户</div>
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc) }}</div>
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc|default(none), options_trading_usdt|default(none), options_trading_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}</div>
</div>
{% endif %}
<div class="stat-strip-item stat-strip-item--pnl">
+46 -6
View File
@@ -58,7 +58,13 @@ def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None =
if strike is None:
strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
return close_ref_prices(
mark_px=mark,
opt_type=str(opt_type or ""),
strike=strike,
index_px=idx,
inst_id=inst_id,
)
def _avail_sheets(pos: dict[str, Any]) -> int:
@@ -100,7 +106,7 @@ def close_option_by_bid1(
- 限价 = 校验通过时锁定的买一价
- 永不市价
- 始终校验有效流动性(残档买一禁止)
- require_recycle_gate=True :首次还需可回收2×权利金并持续 hold ;
- require_recycle_gate=True :首次还需目标门控(权利金×倍数或净盈亏,U 口径)并持续 hold ;
一旦通过后对同仓续批只验流动性
"""
from lib.exchange.okx_options_lib import (
@@ -139,6 +145,8 @@ def close_option_by_bid1(
premium_paid = _open_premium_paid(cfg, inst_id)
if premium_paid is None:
premium_paid = _safe_float(pos.get("premium_paid"))
premium_ccy = str(q.get("premium_ccy") or pos.get("premium_ccy") or "USDC").strip().upper() or "USDC"
index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px"))
# 已有未成交卖平单:等成交,不撤不重挂
try:
@@ -188,7 +196,13 @@ def close_option_by_bid1(
)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
# 不撤他人挂单:仅拒绝本轮下单
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return {
"ok": False,
"msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
@@ -202,7 +216,13 @@ def close_option_by_bid1(
bid_px = _safe_float(q.get("bid"))
stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub or bid_px is None or bid_px <= 0:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return {
"ok": False,
"msg": stub_reason or "暂无买一,无法限价平仓",
@@ -225,7 +245,13 @@ def close_option_by_bid1(
stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub_lv:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid)
update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return {
"ok": False,
"msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
@@ -239,6 +265,8 @@ def close_option_by_bid1(
inst_id,
recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"):
return {
@@ -351,7 +379,7 @@ def close_option_by_bid1(
# 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id)
return {
out = {
"ok": True,
"mode": "bid1",
"orders": [{"order": order, "px": px, "sheets": level_sheets}],
@@ -369,6 +397,18 @@ def close_option_by_bid1(
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
),
}
if fully_closed:
try:
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out)
if spot_sell is not None:
out["spot_sell"] = spot_sell
if spot_sell.get("bridge_status") == "pending_sell_spot":
out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回"
except Exception as e:
out["spot_sell"] = {"ok": False, "msg": str(e)}
return out
# 兼容旧名
+171 -21
View File
@@ -1,4 +1,4 @@
"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓."""
"""期权按买盘平仓门控:可回收/净盈亏换算为 USDT 后校验,并持续 hold 秒才允许平仓."""
from __future__ import annotations
import os
@@ -14,10 +14,44 @@ def _env_float(key: str, default: float) -> float:
return default
# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0)
def _env_optional_float(key: str) -> float | None:
raw = os.getenv(key)
if raw is None or str(raw).strip() == "":
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
def close_net_pnl_min_u() -> float:
return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", 0.0)
def close_gate_mode() -> str:
m = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower()
return m if m in ("premium", "net_pnl") else "premium"
def resolve_close_recycle_mult(premium_ccy: str | None, min_mult: float | None = None) -> float:
if min_mult is not None:
m = float(min_mult)
return m if m > 0 else 1.05
global_mult = _env_optional_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT")
if global_mult is not None and global_mult > 0:
return global_mult
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy in ("ETH", "BTC"):
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05)
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", 2.0)
# 兼容旧引用
CLOSE_RECYCLE_MIN_MULT = resolve_close_recycle_mult("USDC")
_lock = threading.Lock()
# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float}
_gates: dict[str, dict[str, Any]] = {}
@@ -32,6 +66,39 @@ def _safe_float(v: Any) -> float | None:
return None
def _normalize_premium_ccy(premium_ccy: str | None) -> str:
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy not in ("ETH", "BTC", "USDC"):
ccy = "USDC"
return ccy
def _to_usdt(amount: float | None, ccy: str, index_px: float | None) -> float | None:
if amount is None:
return None
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return None
return float(amount) * float(idx)
return float(amount)
def _fmt_gate_amt(v: float, *, ccy: str) -> str:
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
txt = f"{float(v):.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{float(v):.4f}"
def _fmt_usdt(v: float | None) -> str:
if v is None:
return ""
return f"{float(v):.2f}"
def clear_close_gate(inst_id: str | None = None) -> None:
with _lock:
if inst_id:
@@ -41,7 +108,7 @@ def clear_close_gate(inst_id: str | None = None) -> None:
def mark_close_gate_passed(inst_id: str) -> None:
"""标记同仓已通过 2× 门控,续批平仓只验流动性."""
"""标记同仓已通过门控,续批平仓只验流动性."""
inst = (inst_id or "").strip()
if not inst:
return
@@ -68,10 +135,13 @@ def update_close_gate(
now: float | None = None,
min_mult: float | None = None,
hold_seconds: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
) -> dict[str, Any]:
"""
根据当前买盘可回收金额刷新门控.
条件不满足时重置计时;满足时从首次满足起累计持续时间.
根据当前买盘可回收金额刷新门控(比较口径均为 USDT 估值).
premium 模式:可回收(U) 权利金(U) × 倍数
net_pnl 模式:净盈亏(U) > OKX_OPTIONS_CLOSE_NET_PNL_MIN_U
"""
inst = (inst_id or "").strip()
if not inst:
@@ -82,19 +152,38 @@ def update_close_gate(
"msg": "缺少合约",
}
ts = float(now if now is not None else time.time())
mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT)
mode = close_gate_mode()
hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS)
if mult <= 0:
mult = 2.0
if hold < 0:
hold = 0.0
with _lock:
prev_ccy = (_gates.get(inst) or {}).get("premium_ccy")
ccy = _normalize_premium_ccy(premium_ccy or prev_ccy)
mult = resolve_close_recycle_mult(ccy, min_mult)
min_pnl_u = close_net_pnl_min_u()
prem = _safe_float(premium_paid)
recv = _safe_float(recycle_usdc)
need = round(prem * mult, 4) if prem is not None and prem > 0 else None
recycle_ok = bool(
prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need
)
recv_u = _to_usdt(recv, ccy, index_px)
prem_u = _to_usdt(prem, ccy, index_px)
net_u = round(recv_u - prem_u, 4) if recv_u is not None and prem_u is not None else None
need_u = round(prem_u * mult, 4) if prem_u is not None and prem_u > 0 and mode == "premium" else None
missing_index = ccy in ("ETH", "BTC") and (index_px is None or _safe_float(index_px) is None or _safe_float(index_px) <= 0)
if missing_index and prem is not None and prem > 0 and recv is not None:
recycle_ok = False
elif mode == "net_pnl":
recycle_ok = bool(net_u is not None and net_u > min_pnl_u + 1e-9)
else:
recycle_ok = bool(
prem_u is not None
and prem_u > 0
and recv_u is not None
and need_u is not None
and recv_u + 1e-9 >= need_u
)
with _lock:
prev = _gates.get(inst) or {}
@@ -112,11 +201,19 @@ def update_close_gate(
"ok_since": ok_since,
"recycle": recv,
"premium": prem,
"need": need,
"need": need_u,
"updated": ts,
"min_mult": mult,
"hold_seconds": hold,
"passed": passed,
"premium_ccy": ccy,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
}
_gates[inst] = state
@@ -125,15 +222,40 @@ def update_close_gate(
msg = "缺少权利金,无法校验平仓门控"
elif recv is None:
msg = "暂无有效买盘可回收金额"
elif missing_index:
msg = "缺少指数价,无法按 USDT 校验目标平仓门控"
elif mode == "net_pnl":
if not recycle_ok:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) ≤ {_fmt_usdt(min_pnl_u)}U,"
f"目标平仓门控未过"
)
elif not ready:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U,"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
)
else:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U"
f"且持续≥{hold:.0f}s,目标触达后可按买一平仓"
)
elif not recycle_ok:
msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),目标平仓门控未过"
msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) < 权利金×{mult:g}"
f"({_fmt_usdt(need_u)}U),目标平仓门控未过"
)
elif not ready:
msg = (
f"可回收已达×{mult:g}({recv:.4f}/{need:.4f}),"
f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}"
f"({_fmt_usdt(recv_u)}/{_fmt_usdt(need_u)}U),"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
)
else:
msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,目标触达后可按买一平仓"
msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}且持续≥{hold:.0f}s,"
f"目标触达后可按买一平仓"
)
auto_blocked = not (ready or passed)
return {
@@ -143,13 +265,21 @@ def update_close_gate(
"recycle_ok": recycle_ok,
"recycle_usdc": recv,
"premium_paid": prem,
"need_recycle_usdc": need,
"need_recycle_usdc": need_u,
"premium_ccy": ccy,
"min_mult": mult,
"hold_seconds": hold,
"held_seconds": round(held, 1) if recycle_ok else 0.0,
"remain_seconds": round(remain, 1) if remain is not None else None,
"ok_since": ok_since,
"msg": msg,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
"auto_close_blocked": auto_blocked,
"close_gate_blocked": auto_blocked,
}
@@ -160,25 +290,45 @@ def check_close_gate(
*,
recycle_usdc: float | None = None,
premium_paid: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
refresh: bool = True,
) -> dict[str, Any]:
"""检查是否允许平仓;默认先用最新回收/权利金刷新."""
inst = (inst_id or "").strip()
if refresh:
if recycle_usdc is None or premium_paid is None:
if recycle_usdc is None or premium_paid is None or premium_ccy is None or index_px is None:
with _lock:
prev = _gates.get(inst) or {}
if recycle_usdc is None:
recycle_usdc = prev.get("recycle")
if premium_paid is None:
premium_paid = prev.get("premium")
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid)
if premium_ccy is None:
premium_ccy = prev.get("premium_ccy")
if index_px is None:
index_px = prev.get("index_px")
return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
with _lock:
prev = _gates.get(inst)
if not prev:
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid)
return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return update_close_gate(
inst,
recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"),
premium_paid=premium_paid if premium_paid is not None else prev.get("premium"),
premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"),
index_px=index_px if index_px is not None else prev.get("index_px"),
)
+481
View File
@@ -0,0 +1,481 @@
"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回."""
from __future__ import annotations
import os
import time
from typing import Any
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
td_mode_for_option_buy,
wait_option_order_full_fill,
)
from lib.options.options_margin_mode_lib import (
calc_sheets_from_coin_balance,
compute_coin_budget_usdt,
is_coin_margin_mode,
margin_mode_from_inst_id,
normalize_options_margin_mode,
plan_coin_open_by_budget,
premium_ccy_for_mode,
)
from lib.options.options_spot_bridge_lib import (
BRIDGE_BOUGHT,
BRIDGE_HOLDING,
bridge_blocks_new_open_msg,
fetch_trading_coin_available,
insert_bridge,
rollback_bought_coin_to_usdt,
sell_residual_after_option_flat,
spot_market_buy_coin_with_usdt,
update_bridge,
)
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances
bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True)
trading = bal.get("trading_usdt_avail")
if trading is None:
trading = bal.get("trading_usdt")
try:
trading_f = float(trading or 0)
except (TypeError, ValueError):
trading_f = 0.0
buf = float(cfg.get("budget_buffer") or 0.95)
return compute_coin_budget_usdt(trading_f, buffer=buf)
def open_coin_option_buy_full(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
signal_note: str = "",
target_index: float | None = None,
profit_exit_enabled: bool = False,
profit_exit_mult: float = 1.0,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算)."""
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if not is_coin_margin_mode():
return {"ok": False, "msg": "当前非币本位模式"}
if margin_mode_from_inst_id(inst_id) != "coin":
return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"}
# 解析标的
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
block = bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return {"ok": False, "msg": compound_block, "can_open": False}
pos_limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id=inst_id,
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {"ok": False, "msg": pos_limit_msg, "can_open": False}
budget_info = coin_budget_preview(cfg, ex)
if not budget_info.get("ok"):
return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info}
budget_usdt = float(budget_info["budget_usdt"])
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return q
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"can_open": False,
}
ct_mult = float(q.get("ct_mult") or 0.01)
min_sz = int(q.get("min_sz") or 1)
idx = None
try:
idx = float(q.get("index_px") or q.get("idxPx") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
try:
from lib.exchange.okx_options_lib import fetch_index_price
idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0)
except Exception:
idx = 0.0
plan = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdt=budget_usdt,
index_px=float(idx),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if not plan.get("ok"):
return {
"ok": False,
"msg": plan.get("msg") or "无法规划买币张数",
"plan": plan,
"budget": budget_info,
"can_open": False,
}
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = fetch_trading_coin_available(ex, underlying) or 0.0
buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
if not buy.get("ok"):
return {
"ok": False,
"msg": f"现货买入 {underlying} 失败: {buy.get('msg')}",
"budget": budget_info,
"plan": plan,
}
bridge_id = insert_bridge(
conn,
underlying=underlying,
status=BRIDGE_BOUGHT,
budget_usdt=buy_usdt,
buy_ord_id=str(buy.get("ord_id") or ""),
inst_id=inst_id,
message="已买币,待开期权",
)
# 等余额落账
time.sleep(1.5)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
coin_after = fetch_trading_coin_available(ex, underlying)
if coin_after is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额"
)
return {
"ok": False,
"msg": "买币后读不到可用余额,已尝试卖回 USDT",
"rollback": rb,
"budget": budget_info,
"plan": plan,
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
if coin_bought <= 0:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
coin_available=float(coin_bought),
)
if not sizing.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=sizing.get("msg") or "张数不足",
coin_amount=float(coin_bought),
)
return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan}
# 实盘以买到的币为准,但不超过规划张数
sheets = min(int(sizing["sheets"]), int(plan["sheets"]))
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz)
if capped is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足"
)
return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb}
if capped < sheets:
sheets = int(capped)
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"ask_depth_capped": True,
}
else:
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
}
tick_sz = q.get("tick_sz")
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
# 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚)
if (not order.get("ok")) and sheets > 1:
msg_l = str(order.get("msg") or "").lower()
if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""):
sheets2 = max(1, sheets // 2)
if sheets2 < sheets:
order2 = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets2,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
if order2.get("ok"):
order = order2
sheets = sheets2
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"retried_half": True,
}
if not order.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败"
)
return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb}
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号"
)
return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb}
try:
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=int(sheets),
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=fill.get("msg") or "未成交",
)
return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb}
sheets = filled_n
eth_amount = round(float(sheets) * ct_mult, 8)
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
message="期权持仓中",
)
trade_id = _insert_coin_trade(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
strike=q.get("strike"),
exp_time=q.get("exp_time"),
sheets=int(sheets),
eth_amount=eth_amount,
open_quote=float(ask),
premium_paid=premium_paid,
signal_note=signal_note,
exchange_ord_id=ord_id,
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
# 目标位 / 翻倍离场 — 复用现有逻辑若存在
try:
if target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor
upsert_target_monitor(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
target_index=float(target_index),
trade_id=trade_id,
sheets=int(sheets),
)
except Exception:
pass
try:
from lib.options.options_notify_lib import notify_options_open
notify_options_open(
cfg,
conn,
trade_id=trade_id,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
sheets=int(sheets),
premium_paid=premium_paid,
open_quote=float(ask),
target_index=target_index,
signal_note=signal_note,
premium_ccy=premium_ccy,
margin_mode="coin",
)
except Exception:
pass
return {
"ok": True,
"msg": f"币本位开仓成功 {sheets}",
"margin_mode": "coin",
"budget": budget_info,
"sizing": sizing,
"sheets": sheets,
"eth_amount": eth_amount,
"premium_paid": premium_paid,
"premium_ccy": premium_ccy,
"bridge_id": bridge_id,
"trade_id": trade_id,
"order": order,
"fill": fill,
}
finally:
try:
conn.close()
except Exception:
pass
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle"
cur = conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
kwargs["underlying"],
kwargs["opt_type"],
kwargs.get("strike"),
str(kwargs.get("exp_time") or ""),
kwargs["sheets"],
kwargs["eth_amount"],
kwargs.get("open_quote"),
kwargs.get("premium_paid"),
kwargs.get("signal_note") or "",
kwargs.get("exchange_ord_id"),
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
pe,
pe_mult,
pe_state,
),
)
conn.commit()
return int(cur.lastrowid)
def maybe_sell_spot_after_close(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
if margin_mode_from_inst_id(inst_id) != "coin":
return None
# 仍有仓则不卖
try:
rows = cfg["fetch_option_positions"](ex) or []
for p in rows:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
except Exception:
pass
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
finally:
try:
conn.close()
except Exception:
pass
+15 -1
View File
@@ -98,11 +98,24 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
"ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'",
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
):
try:
conn.execute(ddl)
except Exception:
pass
try:
from lib.options.options_spot_bridge_lib import ensure_bridge_table
ensure_bridge_table(conn)
except Exception:
pass
init_options_review_tables(conn)
@@ -121,7 +134,8 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
).fetchone()
if not row or int(row["n"] or 0) < 1:
return None
return round(float(row["total"] or 0), 4)
# 币本位权利金常 <1e-4,保留 8 位避免被裁成 0
return round(float(row["total"] or 0), 8)
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
+71 -8
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from typing import Any
from lib.exchange.okx_options_lib import format_premium_amount
from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
def enrich_position_row_display(
@@ -14,14 +16,73 @@ def enrich_position_row_display(
meta_cache: dict[str, dict[str, Any] | None] | None = None,
premium_override: float | None = None,
) -> dict[str, Any]:
from lib.exchange.okx_options_lib import format_position_row, format_usdc_amount, tick_sz_and_ct_mult
from lib.exchange.okx_options_lib import format_position_row, tick_sz_and_ct_mult
inst_id = str(raw_pos.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz)
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = str(row.get("underlying") or (inst_id.split("-")[0] if inst_id else "ETH") or "ETH")
premium_ccy = premium_ccy_for_mode(row_mode, underly)
row["margin_mode"] = row_mode
row["premium_ccy"] = premium_ccy
row["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
if premium_override is not None:
row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_usdc_amount(premium_override)
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
return row
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _load_local_closed_by_inst(conn: Any) -> dict[str, dict[str, Any]]:
"""同合约取最新已平本地单,用于补交易所历史权利金/盈亏."""
out: dict[str, dict[str, Any]] = {}
try:
rows = conn.execute(
"""
SELECT inst_id, premium_paid, realized_pnl, premium_ccy, margin_mode,
open_quote, close_quote, sheets, closed_at
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
"""
).fetchall()
except Exception:
return out
for r in rows:
inst = str(r["inst_id"] or "").strip()
if not inst or inst in out:
continue
out[inst] = dict(r)
return out
def _overlay_local_closed(row: dict[str, Any], local: dict[str, Any] | None) -> dict[str, Any]:
if not local:
return row
prem = _safe_float(row.get("premium_paid"))
pnl = _safe_float(row.get("realized_pnl"))
local_prem = _safe_float(local.get("premium_paid"))
local_pnl = _safe_float(local.get("realized_pnl"))
# 交易所缺数或被两位小数抹成 0 时,用本地币本位落库值
if (prem is None or abs(prem) < 1e-10) and local_prem is not None and abs(local_prem) > 0:
row["premium_paid"] = local_prem
if (pnl is None or abs(pnl) < 1e-10) and local_pnl is not None and abs(local_pnl) > 0:
row["realized_pnl"] = local_pnl
if not row.get("premium_ccy") and local.get("premium_ccy"):
row["premium_ccy"] = local.get("premium_ccy")
if not row.get("margin_mode") and local.get("margin_mode"):
row["margin_mode"] = local.get("margin_mode")
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=ccy)
return row
@@ -48,6 +109,7 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
str(r["history_key"])
for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall()
}
local_closed = _load_local_closed_by_inst(conn)
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
@@ -66,15 +128,16 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
except (TypeError, ValueError):
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()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
row = format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)
items.append(_overlay_local_closed(row, local_closed.get(inst_id)))
finally:
conn.close()
hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult))
open_rows = [x for x in items if x.get("status") == "open"]
closed = [x for x in items if x.get("status") != "open"]
closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True)
+85
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
from typing import Any
@@ -28,18 +29,36 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn = cfg["get_db"]()
try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
{
"inst_id": pe.get("inst_id"),
"exit_mode": "profit_exit",
"profit_exit_mult": pe.get("profit_exit_mult"),
"profit_exit_enabled": True,
}
)
for p in positions:
mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon:
p["target_index"] = mon.get("target_index")
p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
if pe:
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target:
p["hedge_plan_target"] = hedge_target
@@ -78,6 +97,65 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
has_upl = True
upl_total += float(pnl)
bal = cfg["fetch_options_balances"](ex)
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
normalize_options_margin_mode,
premium_ccy_for_mode,
)
margin_mode = normalize_options_margin_mode()
for p in positions:
mid = str(p.get("inst_id") or "")
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode
p["margin_mode"] = row_mode
p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode(
row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH")
)
p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = None
try:
from lib.exchange.okx_options_lib import fetch_index_price
options_index_px = fetch_index_price(ex, underly)
except Exception:
options_index_px = None
if options_index_px is None:
for p in positions:
try:
px = float(p.get("idx_px") or p.get("idxPx") or 0)
except (TypeError, ValueError):
px = 0
if px > 0:
options_index_px = px
break
coin_budget = None
bridge_status = None
open_bridges = []
if is_coin_margin_mode():
try:
from lib.options.options_coin_open_lib import coin_budget_preview
coin_budget = coin_budget_preview(cfg, ex)
except Exception:
coin_budget = None
try:
conn_b = cfg["get_db"]()
try:
from lib.options.options_spot_bridge_lib import list_open_bridges
open_bridges = list_open_bridges(conn_b)
if open_bridges:
bridge_status = str(open_bridges[0].get("status") or "")
finally:
conn_b.close()
except Exception:
open_bridges = []
return {
"ok": True,
"enabled": True,
@@ -95,6 +173,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
"trade_budget": cfg.get("trade_budget"),
"account_label": cfg.get("account_label") or "OKX期权",
"max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": underly,
"options_index_px": options_index_px,
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
}
except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)}
+325
View File
@@ -0,0 +1,325 @@
"""OKX 单笔期权本位模式与币本位 USDT 预算."""
from __future__ import annotations
import os
from typing import Any
MODE_USDC = "usdc"
MODE_COIN = "coin"
def _env_bool(name: str, default: bool = False) -> bool:
v = (os.getenv(name) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on", "y")
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def normalize_options_margin_mode(raw: Any = None) -> str:
"""返回 usdc | coin;未配置时默认币本位."""
if raw is None:
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
v = str(raw or MODE_COIN).strip().lower()
if v in ("usdc", "usdc_margin", "usd_margin", "u本位", "u"):
return MODE_USDC
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
return MODE_COIN
# 空串或未知值:默认币本位
if not v:
return MODE_COIN
return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool:
return normalize_options_margin_mode(raw) == MODE_COIN
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
u = (underlying or "ETH").strip().upper() or "ETH"
mode = normalize_options_margin_mode(margin_mode)
if mode == MODE_COIN:
return f"{u}-USD"
return f"{u}-USD_UM"
def margin_mode_from_inst_id(inst_id: str) -> str:
inst = (inst_id or "").strip().upper()
if not inst:
return normalize_options_margin_mode()
if "_UM" in inst:
return MODE_USDC
# ETH-USD-260701-2500-C / BTC-USD-...
if "-USD-" in inst and "_UM" not in inst:
return MODE_COIN
return normalize_options_margin_mode()
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
return (underlying or "ETH").strip().upper() or "ETH"
return "USDC"
def spot_quote_inst_id(underlying: str) -> str:
"""现货市价买卖: ETH-USDT / BTC-USDT."""
u = (underlying or "ETH").strip().upper() or "ETH"
return f"{u}-USDT"
def compute_coin_budget_usdt(
trading_usdt: float,
*,
compound: bool | None = None,
buffer: float | None = None,
fixed_budget_usdt: float | None = None,
max_enabled: bool | None = None,
max_usdt: float | None = None,
) -> dict[str, Any]:
"""
币本位单笔 USDT 预算.
复利开: trading_usdt × buffer; 复利关: fixed × buffer.
上限开: min(..., max_usdt).
"""
bal = max(0.0, float(trading_usdt or 0))
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if buf <= 0:
buf = 0.95
fixed = (
float(fixed_budget_usdt)
if fixed_budget_usdt is not None
else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0)
)
if use_compound:
raw = bal * buf
source = "compound"
else:
raw = max(0.0, fixed) * buf
source = "fixed"
capped = False
max_on = (
_env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False)
if max_enabled is None
else bool(max_enabled)
)
max_n = (
float(max_usdt)
if max_usdt is not None
else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0)
)
budget = raw
if max_on and max_n > 0 and budget > max_n:
budget = max_n
capped = True
return {
"ok": budget > 0,
"budget_usdt": round(budget, 8),
"raw_usdt": round(raw, 8),
"trading_usdt": round(bal, 8),
"buffer": buf,
"compound": use_compound,
"source": source,
"max_enabled": max_on,
"max_usdt": max_n if max_on else None,
"capped_by_max": capped,
"msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算",
}
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
if v <= 0:
return 1.10
if v < 1.0:
return 1.0 + v
return v
def plan_coin_open_by_budget(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
budget_usdt: float,
index_px: float,
ask_sz: float | None = None,
spot_buy_buffer: float | None = None,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""
先按预算/卖一估最大可开张数,再按权利金 × 现货缓冲算应买现货 USDT.
不全额把预算换成币.
"""
import math
from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
budget = max(0.0, float(budget_usdt or 0))
idx = float(index_px or 0)
buf = normalize_coin_spot_buy_buffer(spot_buy_buffer)
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "buy_usdt": 0.0}
if idx <= 0:
return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "sheets": 0, "buy_usdt": 0.0}
if budget <= 0:
return {"ok": False, "msg": "USDT 预算无效", "sheets": 0, "buy_usdt": 0.0}
per_sheet_coin = ask * mult
# 每张开仓需买的币(含缓冲)及其约合 USDT
per_sheet_buy_coin = per_sheet_coin * buf
per_sheet_usdt = per_sheet_buy_coin * idx
if per_sheet_usdt <= 0:
return {"ok": False, "msg": "无法计算单张买币成本", "sheets": 0, "buy_usdt": 0.0}
max_by_budget = int(math.floor((budget / per_sheet_usdt) + 1e-12))
if target_sheets is not None:
try:
want = int(target_sheets)
except (TypeError, ValueError):
want = 0
if want < need:
return {
"ok": False,
"msg": f"指定张数无效(需≥{need})",
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
}
sheets = min(want, max_by_budget)
if sheets < want:
return {
"ok": False,
"msg": (
f"预算约可开 {max_by_budget} 张(含现货缓冲×{buf:g}),"
f"不足指定 {want}"
),
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
"spot_buy_buffer": buf,
}
else:
sheets = max_by_budget
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=need)
ask_depth_capped = False
if capped is None:
return {
"ok": False,
"msg": cap_msg or "卖一深度不足",
"sheets": 0,
"buy_usdt": 0.0,
"spot_buy_buffer": buf,
}
if int(capped) < sheets:
sheets = int(capped)
ask_depth_capped = True
if sheets < need:
return {
"ok": False,
"msg": (
f"预算不足,无法买入 {need}"
f"(单张约需 {per_sheet_usdt:.4f} USDT,含现货缓冲×{buf:g})"
),
"sheets": sheets,
"buy_usdt": 0.0,
"per_sheet_usdt": round(per_sheet_usdt, 8),
"spot_buy_buffer": buf,
"max_by_budget": max_by_budget,
}
premium_coin = sheets * per_sheet_coin
buy_coin = premium_coin * buf
buy_usdt = min(budget, buy_coin * idx)
# 再保险:向下对齐,避免浮点导致略超预算
buy_usdt = min(budget, round(buy_usdt, 8))
out = {
"ok": True,
"msg": "" if not ask_depth_capped else (cap_msg or f"已按卖一深度限制为 {sheets}"),
"sheets": sheets,
"eth_amount": round(sheets * mult, 8),
"coin_premium": round(premium_coin, 8),
"total_premium": round(premium_coin, 8),
"per_sheet_coin": per_sheet_coin,
"buy_coin": round(buy_coin, 8),
"buy_usdt": round(buy_usdt, 8),
"budget_usdt": round(budget, 8),
"spot_buy_buffer": buf,
"index_px": idx,
"max_by_budget": max_by_budget,
"ask_depth_capped": ask_depth_capped,
"est_note": (
f"按最大可开 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;"
f"不全额兑换预算"
),
}
if target_sheets is not None:
out["est_note"] = (
f"指定 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;不全额兑换"
)
out["target_sheets"] = int(target_sheets)
return out
def calc_sheets_from_coin_balance(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
coin_available: float,
) -> dict[str, Any]:
"""用可用标的币尽量开满(权利金以币计)."""
import math
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
coin = max(0.0, float(coin_available or 0))
# 留一点手续费/精度缓冲,避免算满张后下单 51008
coin_eff = coin * 0.97
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0}
per_sheet = ask * mult
if per_sheet <= 0:
return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0}
sheets = int(math.floor((coin_eff / per_sheet) + 1e-12))
if sheets < need:
return {
"ok": False,
"msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})",
"sheets": sheets,
"coin_premium": round(sheets * per_sheet, 8),
"per_sheet_coin": per_sheet,
}
prem = sheets * per_sheet
return {
"ok": True,
"msg": "",
"sheets": sheets,
"coin_premium": round(prem, 8),
"per_sheet_coin": per_sheet,
"eth_amount": round(sheets * mult, 8),
}
+27 -3
View File
@@ -30,16 +30,23 @@ def build_profit_alert_message(
upl: float,
upl_ratio: float | None,
bid: float | None,
premium_ccy: str | None = None,
) -> str:
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id, premium_ccy=premium_ccy)
d = 6 if ccy in ("ETH", "BTC") else 4
pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else ""
bid_txt = f"{bid:.4f}" if bid is not None else ""
bid_txt = f"{bid:.{d}f}" if bid is not None else ""
mode = "币本位" if ccy != "USDC" else "USDC"
return "\n".join(
[
"【OKX期权·翻倍提醒】",
f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}",
f"已付权利金:{premium_paid:.4f} USDC",
f"未实现盈亏:{upl:+.4f} USDC({pct})",
f"已付权利金:{premium_paid:.{d}f} {ccy}",
f"未实现盈亏:{upl:+.{d}f} {ccy}({pct})",
f"当前买一:{bid_txt}(可考虑限价平仓锁利)",
]
)
@@ -428,6 +435,8 @@ def options_monitor_loop(
profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
stop_event: Any = None,
) -> None:
@@ -459,6 +468,21 @@ def options_monitor_loop(
account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label},
)
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
run_options_profit_exits(
conn,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
if sync_trades_fn is not None:
sync_trades_fn(conn)
conn.commit()
+119 -7
View File
@@ -1,4 +1,4 @@
"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记)."""
"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).支持 USDC / 币本位(ETH/BTC)."""
from __future__ import annotations
import sqlite3
@@ -23,6 +23,63 @@ def _opt_type_label(opt_type: Any) -> str:
return t or ""
def resolve_options_premium_ccy(
*,
inst_id: str = "",
underlying: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
row: dict[str, Any] | None = None,
) -> str:
"""权利金计价币种:USDC 或 ETH/BTC."""
raw = premium_ccy
if (raw is None or str(raw).strip() == "") and row:
raw = row.get("premium_ccy")
ccy = str(raw or "").strip().upper()
if ccy:
return ccy
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
mid = str(inst_id or (row or {}).get("inst_id") or "").strip()
mode = margin_mode if margin_mode is not None else (row or {}).get("margin_mode")
if mode is None and mid:
mode = margin_mode_from_inst_id(mid)
u = str(
underlying
or (row or {}).get("underlying")
or (mid.split("-")[0] if mid else "ETH")
or "ETH"
).strip().upper() or "ETH"
return premium_ccy_for_mode(str(mode or "usdc"), u)
except Exception:
return "USDC"
def _amount_decimals(ccy: str) -> int:
c = (ccy or "USDC").strip().upper()
if c in ("ETH", "BTC"):
return 6
return 4
def _mode_tag(*, inst_id: str = "", premium_ccy: str = "", margin_mode: Any = None) -> str:
ccy = (premium_ccy or "").strip().upper()
if ccy and ccy != "USDC":
return "币本位"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode, margin_mode_from_inst_id
mode = margin_mode
if mode is None and inst_id:
mode = margin_mode_from_inst_id(inst_id)
if is_coin_margin_mode(mode):
return "币本位"
except Exception:
pass
return "USDC"
def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
@@ -57,10 +114,21 @@ def build_options_open_message(
target_index: Any = None,
signal_note: str = "",
trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [
"【OKX期权·开仓】",
f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
]
if trade_id is not None:
lines.append(f"本地单号:#{trade_id}")
@@ -69,8 +137,8 @@ def build_options_open_message(
f"合约:{inst_id}",
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"张数:{sheets if sheets is not None else ''}",
f"开仓报价:{_fmt(open_quote)} USDC",
f"权利金:{_fmt(premium_paid)} USDC",
f"开仓报价:{_fmt(open_quote, d)} {ccy}",
f"权利金:{_fmt(premium_paid, d)} {ccy}",
]
)
if target_index is not None and str(target_index).strip() != "":
@@ -98,10 +166,21 @@ def build_options_close_message(
target_index: Any = None,
trigger_idx: Any = None,
trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [
"【OKX期权·平仓】",
f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
]
if trade_id is not None:
lines.append(f"本地单号:#{trade_id}")
@@ -111,9 +190,9 @@ def build_options_close_message(
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"原因:{(reason or '平仓').strip()}",
f"张数:{sheets if sheets is not None else ''}",
f"平仓报价:{_fmt(close_quote)} USDC",
f"已付/收回:{_fmt(premium_paid)} / {_fmt(premium_received)} USDC",
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
f"平仓报价:{_fmt(close_quote, d)} {ccy}",
f"已付/收回:{_fmt(premium_paid, d)} / {_fmt(premium_received, d)} {ccy}",
f"实现盈亏:{_fmt(realized_pnl, d)} {ccy}",
]
)
if target_index is not None and str(target_index).strip() != "":
@@ -142,15 +221,26 @@ def notify_options_open(
open_quote: Any = None,
target_index: Any = None,
signal_note: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
) -> bool:
ensure_options_notify_columns(conn) if conn is not None else None
row_ccy = premium_ccy
row_mode = margin_mode
if conn is not None and trade_id is not None:
row = conn.execute(
"SELECT wechat_open_sent FROM options_trades WHERE id=?",
"SELECT wechat_open_sent, premium_ccy, margin_mode, underlying FROM options_trades WHERE id=?",
(int(trade_id),),
).fetchone()
if row and int(row["wechat_open_sent"] or 0):
return False
if row:
if row_ccy is None:
row_ccy = row["premium_ccy"] if "premium_ccy" in row.keys() else None
if row_mode is None:
row_mode = row["margin_mode"] if "margin_mode" in row.keys() else None
if not underlying:
underlying = str(row["underlying"] or "") if "underlying" in row.keys() else underlying
msg = build_options_open_message(
account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id,
@@ -162,6 +252,8 @@ def notify_options_open(
target_index=target_index,
signal_note=signal_note,
trade_id=trade_id,
premium_ccy=row_ccy,
margin_mode=row_mode,
)
ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None:
@@ -197,6 +289,8 @@ def notify_options_close(
close_quote: Any = None,
target_index: Any = None,
trigger_idx: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
force: bool = False,
) -> bool:
"""平仓必发.默认按 trade_id / 同合约未标记行幂等."""
@@ -230,6 +324,18 @@ def notify_options_close(
).fetchone()
if q2:
rows = [dict(q2)]
if not rows:
# 已有平仓记录且均已推送:幂等跳过,避免再走「无库行」重复推
exists = conn.execute(
"""
SELECT 1 FROM options_trades
WHERE inst_id=? AND status='closed'
LIMIT 1
""",
(inst_id,),
).fetchone()
if exists:
return False
if rows:
# 同次平仓可能多腿:合并一条推送,逐条标记
@@ -259,6 +365,8 @@ def notify_options_close(
target_index=target_index,
trigger_idx=trigger_idx,
trade_id=head.get("id") if len(rows) == 1 else None,
premium_ccy=premium_ccy or head.get("premium_ccy"),
margin_mode=margin_mode or head.get("margin_mode"),
)
ok = notify_options_send(cfg, msg)
if ok and conn is not None:
@@ -288,6 +396,8 @@ def notify_options_close(
target_index=target_index,
trigger_idx=trigger_idx,
trade_id=trade_id,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
return notify_options_send(cfg, msg)
@@ -327,4 +437,6 @@ def notify_options_close_trade_ids(
premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None),
realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None),
close_quote=first.get("close_quote"),
premium_ccy=first.get("premium_ccy"),
margin_mode=first.get("margin_mode"),
)
+23
View File
@@ -119,3 +119,26 @@ def option_position_limit_block_msg(
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
)
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
def compound_full_single_position_block_msg(
ex: Any,
*,
fetch_positions=None,
) -> Optional[str]:
"""全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔)."""
fetch = fetch_positions
if fetch is None:
from lib.exchange.okx_options_lib import fetch_option_positions
fetch = fetch_option_positions
try:
rows = fetch(ex)
except Exception:
rows = None
if rows is None:
return "无法获取期权持仓,全仓复利模式暂不可开仓"
active = count_live_option_positions(rows)
if active >= 1:
return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓"
return None
+15 -3
View File
@@ -40,6 +40,8 @@ def attach_close_preview(
row.get("opt_type") or row.get("optType"),
_safe_float(row.get("strike") or row.get("stk")),
_safe_float(row.get("idx_px") or row.get("idxPx")),
inst_id=inst_id,
margin_mode=row.get("margin_mode"),
)
# 与实盘一致:只按买一估算本轮可平
preview = estimate_close_by_bids(
@@ -51,9 +53,17 @@ def attach_close_preview(
intrinsic_px=intrinsic,
max_levels=1,
)
# 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要)
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
index_px = _safe_float(row.get("idx_px") or row.get("idxPx"))
# 残档时不累计门控;有效买一时刷新计时(仅自动平仓需要)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
gate = update_close_gate(inst_id, recycle_usdc=None, premium_paid=paid)
gate = update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
preview["close_gate"] = gate
preview["close_gate_blocked"] = True
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
@@ -64,6 +74,8 @@ def attach_close_preview(
inst_id,
recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
preview["close_gate"] = gate
@@ -138,7 +150,7 @@ def sum_options_net_pnl_usdc(
continue
found = True
total += float(pnl)
return round(total, 4) if found else (0.0 if not positions else None)
return round(total, 8) if found else (0.0 if not positions else None)
def build_display_option_positions(
+166 -14
View File
@@ -64,7 +64,46 @@ def _safe_px(v: Any) -> float | None:
return x if x > 0 else None
def intrinsic_px_per_unit(opt_type: str | None, strike: float | None, index_px: float | None) -> float | None:
def _quote_in_coin_from_context(
*,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> bool:
"""币本位(ETH-USD/BTC-USD)权利金按币报价;USDC(USD_UM)按美元点差."""
if quote_in_coin is not None:
return bool(quote_in_coin)
if inst_id:
try:
from lib.options.options_margin_mode_lib import MODE_COIN, margin_mode_from_inst_id
return margin_mode_from_inst_id(inst_id) == MODE_COIN
except Exception:
pass
if margin_mode is not None:
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
return is_coin_margin_mode(margin_mode)
except Exception:
return str(margin_mode).strip().lower() in ("coin", "coin_margin", "crypto")
return False
def intrinsic_px_per_unit(
opt_type: str | None,
strike: float | None,
index_px: float | None,
*,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> float | None:
"""
与盘口同单位的内在价值( 1 标的).
- USDC / USD_UM: 美元点差 max(0, SK) / max(0, KS)
- 币本位 ETH-USD / BTC-USD: 币报价 max(0, SK)/S / max(0, KS)/S
"""
o = (opt_type or "").strip().upper()
if strike is None or index_px is None:
return None
@@ -74,10 +113,18 @@ def intrinsic_px_per_unit(opt_type: str | None, strike: float | None, index_px:
except (TypeError, ValueError):
return None
if o == "C" and idx > k:
return idx - k
if o == "P" and idx < k:
return k - idx
return None
points = idx - k
elif o == "P" and idx < k:
points = k - idx
else:
return None
if _quote_in_coin_from_context(
quote_in_coin=quote_in_coin, inst_id=inst_id, margin_mode=margin_mode
):
if idx <= 0:
return None
return points / idx
return points
def is_stub_bid_px(
@@ -128,9 +175,19 @@ def close_ref_prices(
opt_type: str | None = None,
strike: float | None = None,
index_px: float | None = None,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> tuple[float | None, float | None]:
"""返回 (mark_px, intrinsic_px) 供残档判断."""
return _safe_px(mark_px), intrinsic_px_per_unit(opt_type, strike, index_px)
"""返回 (mark_px, intrinsic_px) 供残档判断;intrinsic 与盘口同单位."""
return _safe_px(mark_px), intrinsic_px_per_unit(
opt_type,
strike,
index_px,
quote_in_coin=quote_in_coin,
inst_id=inst_id,
margin_mode=margin_mode,
)
def filter_bids_for_close(
@@ -264,6 +321,25 @@ def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> f
return min(float(trading_usdc), float(trade_budget_usdc))
def resolve_compound_full_usdc(
trading_usdc: float,
*,
cap_enabled: bool = False,
cap_usdc: float | None = None,
) -> float:
"""全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶."""
bal = max(0.0, float(trading_usdc or 0))
if not cap_enabled:
return bal
try:
cap = float(cap_usdc) if cap_usdc is not None else 0.0
except (TypeError, ValueError):
cap = 0.0
if cap <= 0:
return bal
return min(bal, cap)
def calc_order_size(
*,
quote_per_unit: float,
@@ -362,10 +438,20 @@ def expiry_breakeven_from_ask(
strike: float | None,
ask_px: float | None,
mark_px: float | None = None,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> float | None:
"""买入前预估到期平衡:权利金按卖一;无卖一时回退标记价."""
prem = ask_px if ask_px is not None and ask_px > 0 else mark_px
return expiry_breakeven_px(opt_type=opt_type, strike=strike, avg_px=prem)
return expiry_breakeven_px(
opt_type=opt_type,
strike=strike,
avg_px=prem,
quote_in_coin=quote_in_coin,
inst_id=inst_id,
margin_mode=margin_mode,
)
def expiry_breakeven_px(
@@ -374,17 +460,39 @@ def expiry_breakeven_px(
strike: float | None,
avg_px: float | None,
be_px_api: float | None = None,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> float | None:
"""到期平衡点:持有至到期时标的指数盈亏为 0 的价格.优先 OKX bePx."""
if be_px_api is not None and be_px_api > 0:
return round(float(be_px_api), 2)
if strike is None or avg_px is None:
return None
try:
k = float(strike)
p = float(avg_px)
except (TypeError, ValueError):
return None
if p <= 0:
return None
o = (opt_type or "").upper()
coin = _quote_in_coin_from_context(
quote_in_coin=quote_in_coin, inst_id=inst_id, margin_mode=margin_mode
)
if coin:
# 币本位:权利金为币报价;到期结算 payoff 亦为币 → K/(1±p)
if o == "C":
if p >= 1:
return None
return round(k / (1 - p), 2)
if o == "P":
return round(k / (1 + p), 2)
return None
if o == "C":
return round(strike + avg_px, 2)
return round(k + p, 2)
if o == "P":
return round(strike - avg_px, 2)
return round(k - p, 2)
return None
@@ -423,6 +531,25 @@ def idx_distance_to_be(idx_px: float | None, be_px: float | None) -> float | Non
return round(float(be_px) - float(idx_px), 2)
def strike_distance_to_be(
strike: float | None,
be_px: float | None,
*,
opt_type: str | None = None,
) -> float | None:
"""行权价到到期平衡价的价差(Call:BEK, Put:KBE)."""
if strike is None or be_px is None:
return None
k = float(strike)
be = float(be_px)
o = (opt_type or "").upper()
if o == "C":
return round(be - k, 2)
if o == "P":
return round(k - be, 2)
return round(abs(be - k), 2)
def format_options_breakeven_line(
*,
expiry_be_px: float | None,
@@ -493,12 +620,25 @@ def equivalent_contract_leverage(
index_px: float | None,
eth_amount: float | None,
total_premium: float | None,
margin_mode: str | None = None,
) -> float | None:
"""名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用)."""
"""名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用).
USDC: 权利金为美元 index×eth/premium.
币本位: 权利金为币 eth/premium(=1/ask premium=ask×eth).
"""
if index_px is None or eth_amount is None or total_premium is None:
return None
if eth_amount <= 0 or total_premium <= 0:
return None
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
mode = normalize_options_margin_mode(margin_mode)
except Exception:
mode = (str(margin_mode or "usdc").strip().lower() or "usdc")
if mode == "coin":
return round(float(eth_amount) / float(total_premium), 1)
return round(float(index_px) * float(eth_amount) / float(total_premium), 1)
@@ -528,12 +668,24 @@ def straddle_premium_total(
def straddle_breakeven_band(
strike: float | None,
combined_ask_per_unit: float | None,
combined_ask_per_unit: float | None = None,
*,
call_ask: float | None = None,
put_ask: float | None = None,
quote_in_coin: bool = False,
) -> tuple[float | None, float | None]:
"""跨式到期平衡带:下平衡 ~ 上平衡(按双卖一报价和)."""
if strike is None or combined_ask_per_unit is None:
"""跨式到期平衡带:下平衡 ~ 上平衡."""
if strike is None:
return None, None
k = float(strike)
if quote_in_coin:
pc = _safe_px(call_ask)
pp = _safe_px(put_ask)
if pc is None or pp is None or pc <= 0 or pp <= 0 or pc >= 1:
return None, None
return round(k / (1 + pp), 2), round(k / (1 - pc), 2)
if combined_ask_per_unit is None:
return None, None
d = float(combined_ask_per_unit)
return round(k - d, 2), round(k + d, 2)
+411
View File
@@ -0,0 +1,411 @@
"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
1 = 盈利金额等于初始权利金 买一可回收 权利金 × (1 + 倍数).
目标位并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立.
"""
from __future__ import annotations
import sqlite3
from typing import Any, Callable
from lib.options.options_db import init_options_tables, sum_open_premium_paid
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_profit_exit_columns(conn: sqlite3.Connection) -> None:
init_options_tables(conn)
for ddl in (
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
):
try:
conn.execute(ddl)
except Exception:
pass
def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float:
try:
mult = float(raw)
except (TypeError, ValueError):
mult = float(default)
if mult <= 0:
mult = float(default)
return round(mult, 4)
def profit_exit_hit(
*,
premium_paid: float,
recycle_usdc: float,
mult: float,
) -> bool:
"""1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult)."""
prem = float(premium_paid or 0)
recv = float(recycle_usdc or 0)
m = float(mult or 0)
if prem <= 0 or m <= 0 or recv <= 0:
return False
return recv + 1e-9 >= prem * (1.0 + m)
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
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)
def set_profit_exit(
conn: sqlite3.Connection,
*,
inst_id: str,
enabled: bool,
mult: float | None = None,
) -> dict[str, Any]:
ensure_profit_exit_columns(conn)
inst = (inst_id or "").strip()
if not inst:
return {"ok": False, "msg": "缺少 inst_id"}
m = normalize_profit_exit_mult(mult if mult is not None else 1.0)
rows = conn.execute(
"""
SELECT id FROM options_trades
WHERE inst_id = ? AND status = 'open'
""",
(inst,),
).fetchall()
if not rows:
return {"ok": False, "msg": "未找到该合约的本地开仓记录"}
if enabled:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 1,
profit_exit_mult = ?,
profit_exit_state = 'active'
WHERE inst_id = ? AND status = 'open'
""",
(m, inst),
)
else:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0,
profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst,),
)
return {
"ok": True,
"inst_id": inst,
"profit_exit_enabled": bool(enabled),
"profit_exit_mult": m if enabled else None,
"updated": len(rows),
}
def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""进行中(active/closing)的翻倍出场,按合约取最新一条规则."""
ensure_profit_exit_columns(conn)
rows = conn.execute(
"""
SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state
FROM options_trades
WHERE status = 'open'
AND (
CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1
OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing')
)
ORDER BY id DESC
"""
).fetchall()
out: dict[str, dict[str, Any]] = {}
for r in rows:
inst = str(r["inst_id"] or "").strip()
if not inst or inst in out:
continue
enabled = int(r["profit_exit_enabled"] or 0) == 1
state = str(r["profit_exit_state"] or "idle")
if not enabled and state not in ("active", "closing"):
continue
mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0)
out[inst] = {
"inst_id": inst,
"profit_exit_enabled": enabled or state in ("active", "closing"),
"profit_exit_mult": mult,
"profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"),
"required_recycle": None,
}
for inst, info in out.items():
prem = sum_open_premium_paid(conn, inst)
if prem is not None:
info["premium_paid"] = prem
info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"]))
return out
def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_state = ?
WHERE inst_id = ? AND status = 'open'
""",
(state, inst_id),
)
def _commit(conn: sqlite3.Connection) -> None:
try:
conn.commit()
except Exception:
pass
def _result_fully_done(result: dict[str, Any]) -> bool:
if result.get("already_flat"):
return True
if result.get("fully_closed"):
return True
remaining = result.get("remaining_sheets")
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
return True
return False
def close_option_by_bid_profit_exit(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
from lib.options.options_close_exec_lib import close_option_by_bid1
return close_option_by_bid1(
cfg,
ex,
inst_id,
sheets=sheets,
require_recycle_gate=False,
signal_note="翻倍出场",
)
def _estimate_recycle(
cfg: dict[str, Any],
ex: Any,
pos: dict[str, Any],
premium_paid: float | None,
) -> float | None:
from lib.options.options_positions_lib import attach_close_preview
row = dict(pos)
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"):
return None
return _safe_float(preview.get("total_received"))
def _notify_profit_exit_close(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
inst_id: str,
mult: float,
premium_paid: float | None,
recycle: float | None,
result: dict[str, Any],
conn: Any = None,
) -> None:
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id)
d = 6 if ccy in ("ETH", "BTC") else 4
mode = "币本位" if ccy != "USDC" else "USDC"
reason = f"翻倍出场({mult:g}倍)"
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
notify_options_close(
cfg,
conn,
inst_id=inst_id,
reason=reason,
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
premium_ccy=ccy,
)
# 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推
return
except Exception:
pass
if not send_wechat:
return
try:
prem_txt = f"{float(premium_paid):.{d}f}" if premium_paid is not None else ""
recv_txt = f"{float(recycle):.{d}f}" if recycle is not None else ""
send_wechat(
"\n".join(
[
"【OKX期权·翻倍出场】",
f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}",
f"倍数:{mult:g}(1倍=盈利=权利金)",
f"权利金:{prem_txt} {ccy}",
f"可回收:{recv_txt} {ccy}",
f"提交张数:{result.get('submitted_sheets') or ''}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
)
except Exception:
pass
def run_options_profit_exits(
conn: sqlite3.Connection,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None,
ex: Any = None,
) -> int:
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
hedge_managed: set[str] = set()
try:
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
return 0
rules = profit_exit_by_inst(conn)
triggered = 0
for inst_id, info in list(rules.items()):
if not inst_id:
continue
if inst_id in hedge_managed:
_mark_state(conn, inst_id, "idle")
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
_commit(conn)
continue
pos = pos_by_inst.get(inst_id)
if not pos:
# 持仓已平:收尾
_mark_state(conn, inst_id, "done")
_commit(conn)
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
if state == "closing":
result = close_fn(inst_id)
if result.get("already_flat") or _result_fully_done(result):
_mark_state(conn, inst_id, "done")
_commit(conn)
_notify_profit_exit_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
mult=mult,
premium_paid=prem,
recycle=None,
result={**result, "fully_closed": True},
conn=conn,
)
else:
_mark_state(conn, inst_id, "closing")
_commit(conn)
continue
if not info.get("profit_exit_enabled"):
continue
if recycle_fn is not None:
recycle = recycle_fn(pos, prem)
elif cfg is not None and ex is not None:
recycle = _estimate_recycle(cfg, ex, pos, prem)
else:
continue
if recycle is None:
continue
if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
continue
result = close_fn(inst_id)
if result.get("already_flat"):
_mark_state(conn, inst_id, "done")
_commit(conn)
triggered += 1
_notify_profit_exit_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
mult=mult,
premium_paid=prem,
recycle=recycle,
result=result,
conn=conn,
)
continue
if not result.get("ok"):
_mark_state(conn, inst_id, "active")
_commit(conn)
continue
done = _result_fully_done(result)
_mark_state(conn, inst_id, "done" if done else "closing")
_commit(conn)
triggered += 1
_notify_profit_exit_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
mult=mult,
premium_paid=prem,
recycle=recycle,
result=result,
conn=conn,
)
return triggered
+596 -16
View File
@@ -103,6 +103,10 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"render_main_page": app_module.render_main_page,
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
"budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
"compound_full_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True),
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
"margin_mode": (os.getenv("OKX_OPTIONS_MARGIN_MODE") or "coin").strip().lower(),
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
"chain_max_dte_days": _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", 14.0),
@@ -174,6 +178,69 @@ def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
return resolve_budget_full_usdc(trading, float(cap)), ""
def _compound_full_enabled() -> bool:
return _env_bool("OKX_OPTIONS_COMPOUND_FULL_ENABLED", True)
def _budget_full_blocked_by_compound_msg() -> str | None:
if _compound_full_enabled():
return "全仓复利已开启,不可使用单笔预算/打满;请关闭全仓复利或改用全仓复利模式"
return None
def _size_mode_budget_cap(
cfg: dict[str, Any], mode: str, budget_cap: float | None
) -> float | None:
"""全仓复利开启时禁用单笔预算封顶(sheets/eth 也不再受 trade_budget 限制)."""
if mode in ("budget_full", "compound_full"):
return budget_cap
if mode in ("sheets", "eth_amount"):
if _compound_full_enabled():
return None
return budget_cap
return None
def _normalize_size_mode(mode: str) -> tuple[str, str | None]:
"""全仓复利关闭时强制离开 compound_full,避免前端残留选中导致无法开仓."""
m = (mode or "sheets").strip() or "sheets"
if m == "compound_full" and not _compound_full_enabled():
return "sheets", "全仓复利已关闭,已改用指定张数"
if m == "budget_full" and _compound_full_enabled():
return "compound_full", None
return m, None
def _compound_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
"""全仓复利 = 期权交易户可用(可选上限封顶);再由 calc_order_size × budget_buffer."""
if not _compound_full_enabled():
return None, "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)"
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
from lib.options.options_pricing_lib import resolve_compound_full_usdc
raw = fetch_options_trading_usdc(ex)
if raw is None or float(raw) <= 0:
return None, "交易账户 USDC 可用余额不足"
trading = float(raw)
# 额度热更读 env(与模板启动值无关)
cap_on = _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False)
cap_v = _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0)
if cap_on and cap_v <= 0:
return None, "全仓上限无效(OKX_OPTIONS_COMPOUND_FULL_CAP_USDC)"
return (
resolve_compound_full_usdc(
trading,
cap_enabled=cap_on,
cap_usdc=cap_v,
),
"",
)
def _is_budget_mode(mode: str) -> bool:
return mode in ("budget_full", "compound_full")
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
conn = cfg["get_db"]()
try:
@@ -355,7 +422,40 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": err})
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
bal = cfg["fetch_options_balances"](ex, force=force, scope="main")
return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]})
from lib.options.options_margin_mode_lib import is_coin_margin_mode, normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
payload = {
"ok": True,
**bal,
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", float(cfg.get("trade_budget") or 10)),
"compound_full_enabled": _compound_full_enabled(),
"compound_full_cap_enabled": _env_bool("OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", False),
"compound_full_cap_usdc": _env_float("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC", 300.0),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
}
if is_coin_margin_mode():
try:
from lib.options.options_coin_open_lib import coin_budget_preview
payload["coin_budget"] = coin_budget_preview(cfg, ex)
except Exception as e:
payload["coin_budget"] = {"ok": False, "msg": str(e)}
conn = cfg["get_db"]()
try:
init_options_tables(conn)
from lib.options.options_spot_bridge_lib import list_open_bridges
open_bridges = list_open_bridges(conn)
if open_bridges:
payload["bridge_status"] = str(open_bridges[0].get("status") or "")
payload["bridge_underlying"] = str(open_bridges[0].get("underlying") or "")
except Exception:
pass
finally:
conn.close()
return jsonify(payload)
@app.route("/api/options/chain")
@lr
@@ -367,12 +467,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:链展示天数每次读 env,保存后刷新链即可
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
chain = cfg["build_option_chain"](
ex,
u,
max_dte_days=chain_max_dte,
itm_only=False,
itm_max_dist_usd=cfg["itm_max_dist"],
margin_mode=margin_mode,
)
except Exception as e:
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
@@ -381,6 +485,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:每次读 env,保存配置后刷新链即可生效
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
coin_budget = None
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_coin_open_lib import coin_budget_preview
if is_coin_margin_mode():
coin_budget = coin_budget_preview(cfg, ex)
except Exception:
coin_budget = None
if not expiries:
return jsonify(
{
@@ -391,6 +504,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"],
"options_margin_mode": chain.get("margin_mode") or margin_mode,
"coin_budget": coin_budget,
}
)
return jsonify(
@@ -401,6 +516,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"],
"options_margin_mode": chain.get("margin_mode") or margin_mode,
"coin_budget": coin_budget,
}
)
@@ -419,7 +536,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ask = q.get("ask")
ct_mult = q.get("ct_mult") or 0.01
min_sz = q.get("min_sz") or 1
mode = (request.args.get("mode") or "budget_full").strip()
mode = (request.args.get("mode") or "sheets").strip()
sheet_count = None
try:
if request.args.get("sheets"):
@@ -429,18 +546,196 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if mode == "close_preview":
paid = _open_premium_paid(cfg, inst_id)
target = sheet_count if sheet_count is not None else 0
return jsonify(_attach_close_preview(cfg, ex, {**q, "pos": target, "premium_paid": paid}, sheets=target, premium_paid=paid))
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id)
prem_ccy = premium_ccy_for_mode(row_mode, (inst_id.split("-")[0] if inst_id else "ETH"))
preview_row = {
**q,
"pos": target,
"premium_paid": paid,
"margin_mode": row_mode,
"premium_ccy": prem_ccy,
}
out = _attach_close_preview(cfg, ex, preview_row, sheets=target, premium_paid=paid)
out["options_margin_mode"] = row_mode
out["premium_ccy"] = prem_ccy
return jsonify(out)
mode, mode_note = _normalize_size_mode(mode)
# 币本位:报价预览走 USDT 预算→估币→张数,禁止再查 USDC
try:
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
margin_mode_from_inst_id,
)
from lib.options.options_coin_open_lib import coin_budget_preview
from lib.exchange.okx_options_lib import option_buy_liquidity_ok
if is_coin_margin_mode():
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
try:
from lib.hedge_plan.okx_trade_mode_lib import block_standalone_open_by_mode_msg
mode_block = block_standalone_open_by_mode_msg()
except Exception as e:
return jsonify({"ok": False, "can_open": False, "msg": f"交易模式校验失败: {e}"})
if mode_block:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": mode_block,
"options_margin_mode": "coin",
"sizing": {"ok": False, "msg": mode_block, "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0},
}
)
if margin_mode_from_inst_id(inst_id) != "coin":
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": "当前为币本位模式,请选择 ETH-USD / BTC-USD 合约(非 USD_UM)",
"options_margin_mode": "coin",
"sizing": {
"ok": False,
"msg": "合约非币本位",
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
}
)
budget_info = coin_budget_preview(cfg, ex)
if not can_open:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": block_msg or q.get("open_block_msg") or "暂无卖一深度,无法买入",
"options_margin_mode": "coin",
"coin_budget": budget_info,
"sizing": {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
}
)
if not budget_info.get("ok"):
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": budget_info.get("msg") or "交易账户 USDT 不足",
"options_margin_mode": "coin",
"coin_budget": budget_info,
"sizing": {
"ok": False,
"msg": budget_info.get("msg") or "交易账户 USDT 不足",
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
}
)
idx = _safe_float(q.get("index_px")) or _safe_float(q.get("idxPx"))
budget_usdt = float(budget_info["budget_usdt"])
target_sheets = sheet_count if mode == "sheets" and sheet_count is not None else None
if mode == "eth" and request.args.get("eth"):
# 指定币量:按币量反推张数后再走统一规划
try:
eth_want = float(request.args.get("eth"))
except (TypeError, ValueError):
eth_want = 0.0
if eth_want > 0 and float(ct_mult) > 0:
import math
target_sheets = max(int(min_sz), int(math.floor(eth_want / float(ct_mult) + 1e-12)))
from lib.options.options_margin_mode_lib import plan_coin_open_by_budget
sizing = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=float(ct_mult),
min_sz=int(min_sz),
budget_usdt=budget_usdt,
index_px=float(idx or 0),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if sizing.get("ok"):
sizing["premium_ccy"] = (inst_id.split("-")[0] if inst_id else "ETH").upper()
sizing["est_coin"] = sizing.get("buy_coin")
q = _attach_close_preview(
cfg,
ex,
q,
sheets=int(sizing.get("sheets") or 0),
premium_paid=_open_premium_paid(cfg, inst_id),
)
return jsonify(
{
**q,
"can_open": bool(sizing.get("ok")),
"quote_per_unit": ask,
"premium_per_sheet": round(float(ask) * float(ct_mult), 8),
"sizing": sizing,
"mode": mode,
"mode_note": mode_note,
"options_margin_mode": "coin",
"coin_budget": budget_info,
"compound_full_enabled": _compound_full_enabled(),
}
)
except Exception as e:
return jsonify({"ok": False, "msg": f"币本位报价失败: {e}"})
budget = cfg["trade_budget"]
budget_cap = cfg["trade_budget"]
available_usdc = None
if mode == "budget_full":
blocked = _budget_full_blocked_by_compound_msg()
if blocked:
return jsonify(
{
"ok": False,
"msg": blocked,
"compound_full_enabled": _compound_full_enabled(),
}
)
budget, budget_err = _budget_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err})
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": _compound_full_enabled()})
budget_cap = budget
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
available_usdc = fetch_options_trading_usdc(ex)
elif mode == "compound_full":
if not _compound_full_enabled():
return jsonify(
{
"ok": False,
"msg": "全仓复利未开启(OKX_OPTIONS_COMPOUND_FULL_ENABLED)",
"compound_full_enabled": False,
}
)
budget, budget_err = _compound_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err, "compound_full_enabled": True})
budget_cap = budget
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
available_usdc = fetch_options_trading_usdc(ex)
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
budget_cap = None
eth_amount = None
try:
if request.args.get("eth_amount"):
@@ -471,6 +766,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
except Exception as e:
@@ -507,6 +803,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
except Exception as e:
@@ -531,9 +828,39 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
from lib.options.options_position_limit_lib import option_position_limit_block_msg
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if mode == "compound_full":
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return jsonify(
{
**q,
"ok": True,
"can_open": False,
"msg": compound_block,
"quote_per_unit": ask,
"premium_per_sheet": None,
"sizing": {
"ok": False,
"msg": compound_block,
"sheets": 0,
"eth_amount": 0.0,
"total_premium": 0.0,
},
"available_usdc": available_usdc,
"budget_full_usdc": None,
"compound_full_usdc": budget,
}
)
pos_limit_msg = option_position_limit_block_msg(
ex,
@@ -558,17 +885,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
},
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
}
)
sizing = calc_order_size(
quote_per_unit=float(ask),
ct_mult=float(ct_mult),
min_sz=int(min_sz),
budget_usdc=budget if mode == "budget_full" else None,
budget_usdc=budget if _is_budget_mode(mode) else None,
budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount if mode == "eth_amount" else None,
sheets=sheet_count if mode == "sheets" else None,
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
else None,
)
if sizing.get("ok"):
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(
@@ -590,7 +920,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ct_mult=float(ct_mult),
min_sz=int(min_sz),
sheets=capped,
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
else None,
)
if sizing.get("ok"):
sizing["ask_depth_capped"] = True
@@ -612,6 +944,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"sizing": sizing,
"available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
"mode": mode,
"mode_note": mode_note,
"compound_full_enabled": _compound_full_enabled(),
}
)
@@ -643,8 +979,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"})
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip()
mode = (data.get("mode") or "budget_full").strip()
mode = (data.get("mode") or "sheets").strip()
mode, mode_note = _normalize_size_mode(mode)
signal_note = (data.get("signal_note") or "").strip()
if mode_note and mode == "sheets" and (data.get("mode") or "").strip() == "compound_full":
# 前端残留全仓复利选中时,已自动改指定张数;继续开仓
pass
target_index = None
raw_target = data.get("target_index")
if raw_target is not None and str(raw_target).strip() != "":
@@ -654,8 +994,56 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"})
profit_exit_enabled = bool(data.get("profit_exit_enabled"))
profit_exit_mult = 1.0
if profit_exit_enabled:
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult
profit_exit_mult = normalize_profit_exit_mult(data.get("profit_exit_mult"), default=1.0)
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"})
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
from lib.options.options_coin_open_lib import open_coin_option_buy_full
if is_coin_margin_mode():
want_sheets = None
if mode == "sheets":
try:
want_sheets = int(data.get("sheets") or 0) or None
except (TypeError, ValueError):
want_sheets = None
elif mode == "eth":
try:
eth_want = float(data.get("eth") or 0)
except (TypeError, ValueError):
eth_want = 0.0
if eth_want > 0:
q0 = cfg["quote_option_contract"](ex, inst_id)
ct0 = float((q0 or {}).get("ct_mult") or 0.01)
min0 = int((q0 or {}).get("min_sz") or 1)
if ct0 > 0:
import math
want_sheets = max(min0, int(math.floor(eth_want / ct0 + 1e-12)))
result = open_coin_option_buy_full(
cfg,
ex,
inst_id=inst_id,
signal_note=signal_note,
target_index=target_index,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
target_sheets=want_sheets,
)
if result.get("ok"):
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
invalidate_option_positions_cache()
_mark_balances_stale(cfg)
return jsonify(result)
except Exception as e:
return jsonify({"ok": False, "msg": f"币本位开仓失败: {e}"})
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return jsonify(q)
@@ -672,7 +1060,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ref_ask": q.get("ref_ask"),
}
)
from lib.options.options_position_limit_lib import option_position_limit_block_msg
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if mode == "compound_full":
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return jsonify({"ok": False, "msg": compound_block, "can_open": False})
pos_limit_msg = option_position_limit_block_msg(
ex,
@@ -694,23 +1092,49 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
try:
sheet_count = int(data.get("sheets"))
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "张数无效"})
sheet_count = None
if sheet_count is None or int(sheet_count) < 1:
# 全仓复利关闭后前端可能仍带着旧 mode 过来,归一后缺张数则默认 1
if (data.get("mode") or "").strip() == "compound_full":
sheet_count = 1
else:
return jsonify({"ok": False, "msg": "张数无效"})
budget = cfg["trade_budget"]
budget_cap = cfg["trade_budget"]
if mode == "budget_full":
blocked = _budget_full_blocked_by_compound_msg()
if blocked:
return jsonify({"ok": False, "msg": blocked, "compound_full_enabled": _compound_full_enabled()})
budget, budget_err = _budget_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget
elif mode == "compound_full":
if not _compound_full_enabled():
return jsonify(
{
"ok": False,
"msg": "全仓复利未开启,请改用指定张数或先开启全仓复利",
"compound_full_enabled": False,
}
)
budget, budget_err = _compound_full_usdc(cfg, ex)
if budget is None:
return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget
elif mode in ("sheets", "eth_amount") and _compound_full_enabled():
budget_cap = None
sizing = calc_order_size(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdc=budget if mode == "budget_full" else None,
budget_usdc=budget if _is_budget_mode(mode) else None,
budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount,
sheets=sheet_count,
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
budget_cap=_size_mode_budget_cap(cfg, mode, budget_cap)
if mode in ("budget_full", "compound_full", "sheets", "eth_amount")
else None,
)
if not sizing.get("ok"):
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
@@ -793,6 +1217,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
open_opt_type = None
try:
init_options_tables(conn)
from lib.options.options_profit_exit_lib import ensure_profit_exit_columns
ensure_profit_exit_columns(conn)
meta = q.get("meta") or {}
u = str(meta.get("uly") or inst_id).split("-")[0]
opt_type = meta.get("optType")
@@ -802,8 +1229,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"""
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
open_quote, premium_paid, status, signal_note, exchange_ord_id,
profit_exit_enabled, profit_exit_mult, profit_exit_state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
""",
(
inst_id,
@@ -817,6 +1245,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
sizing["total_premium"],
signal_note,
ord_id,
1 if profit_exit_enabled else 0,
profit_exit_mult if profit_exit_enabled else 1.0,
"active" if profit_exit_enabled else "idle",
),
)
trade_id = int(cur.lastrowid)
@@ -832,6 +1263,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
trade_id=trade_id,
sheets=sheets,
)
if profit_exit_enabled:
pass # 列已由 init_options_tables / ensure 迁移
conn.commit()
finally:
conn.close()
@@ -950,9 +1383,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn = cfg["get_db"]()
try:
from lib.options.options_target_lib import targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
tgt_map = targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
rows = []
for p in raw:
@@ -971,6 +1406,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
row["target_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon
pe = profit_exit_map.get(inst)
if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult")
row["profit_exit_state"] = pe.get("profit_exit_state")
row["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(inst)
if hedge_target:
row["hedge_plan_target"] = hedge_target
@@ -1097,6 +1538,62 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
finally:
conn.close()
@app.route("/api/options/profit-exit", methods=["POST"])
@lr
def api_options_profit_exit_set():
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip()
if not inst_id:
return jsonify({"ok": False, "msg": "缺少 inst_id"})
try:
from lib.hedge_plan.hedge_plan_db import (
active_hedge_option_inst_ids,
init_hedge_plan_tables,
)
conn_h = cfg["get_db"]()
try:
init_hedge_plan_tables(conn_h)
if inst_id in active_hedge_option_inst_ids(conn_h):
return jsonify(
{
"ok": False,
"msg": "该合约属于进行中的对冲计划,请在对冲计划中管理,禁止在期权页设置翻倍出场",
}
)
finally:
conn_h.close()
except Exception as e:
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
enabled_raw = data.get("enabled")
if enabled_raw is None:
enabled_raw = data.get("profit_exit_enabled")
enabled = bool(enabled_raw) and str(enabled_raw).strip().lower() not in (
"0",
"false",
"off",
"no",
)
from lib.options.options_profit_exit_lib import normalize_profit_exit_mult, set_profit_exit
mult = normalize_profit_exit_mult(data.get("mult", data.get("profit_exit_mult")), default=1.0)
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
if not _find_position(raw, inst_id):
return jsonify({"ok": False, "msg": "未找到持仓"})
conn = cfg["get_db"]()
try:
out = set_profit_exit(conn, inst_id=inst_id, enabled=enabled, mult=mult)
if out.get("ok"):
conn.commit()
return jsonify(out)
finally:
conn.close()
@app.route("/api/options/close", methods=["POST"])
@lr
def api_options_close():
@@ -1171,9 +1668,46 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn2.close()
except Exception:
pass
try:
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=result)
if spot_sell is not None:
result = dict(result)
result["spot_sell"] = spot_sell
if spot_sell.get("bridge_status") == "pending_sell_spot":
result["msg"] = (
str(result.get("msg") or "平仓成功")
+ ";但卖回 USDT 失败,请点「重试卖回」"
)
except Exception as e:
result = dict(result)
result["spot_sell"] = {"ok": False, "msg": str(e)}
_mark_balances_stale(cfg)
return jsonify(result)
@app.route("/api/options/spot-bridge/retry-sell", methods=["POST"])
@lr
def api_options_spot_bridge_retry_sell():
"""币本位:重试把残留标的币市价卖回 USDT."""
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
data = request.get_json(silent=True) or {}
underlying = (data.get("underlying") or cfg.get("default_underly") or "ETH").strip().upper()
inst_id = (data.get("inst_id") or "").strip() or None
conn = cfg["get_db"]()
try:
init_options_tables(conn)
from lib.options.options_spot_bridge_lib import sell_residual_after_option_flat
out = sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
if out.get("ok"):
_mark_balances_stale(cfg)
return jsonify(out)
finally:
conn.close()
@app.route("/api/options/convert/quote", methods=["POST"])
@lr
def api_options_convert_quote():
@@ -1294,8 +1828,34 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if raw_live is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
history = load_options_history(ex, cfg)
stats = compute_options_stats_from_history(history)
index_px = None
try:
from lib.exchange.okx_options_lib import fetch_index_price
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
normalize_options_margin_mode,
)
underly = (cfg.get("default_underly") or "ETH").strip().upper() or "ETH"
if is_coin_margin_mode(normalize_options_margin_mode(cfg.get("margin_mode"))):
index_px = fetch_index_price(ex, underly)
except Exception:
index_px = None
stats = compute_options_stats_from_history(history, index_px=index_px)
open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live)
# 币本位浮盈为币数量,折算为 U 再与已平合计
if open_float is not None and str(stats.get("pnl_unit") or "") == "U":
px = index_px
if px is None or px <= 0:
for h in history:
try:
px = float(h.get("idx_px") or 0)
except (TypeError, ValueError):
px = 0
if px > 0:
break
if px and px > 0:
open_float = round(float(open_float) * float(px), 4)
net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0
total_pnl = None
if open_float is not None:
@@ -1450,6 +2010,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
pass
return result
def _profit_exit_close(inst_id: str) -> dict[str, Any]:
from lib.options.options_profit_exit_lib import close_option_by_bid_profit_exit
ex = cfg.get("exchange_options")
if ex is None:
return {"ok": False, "msg": "期权 exchange 未就绪"}
result = close_option_by_bid_profit_exit(cfg, ex, inst_id)
if result.get("ok"):
try:
_sync_options_trades(cfg, force=True)
except Exception:
pass
try:
_mark_balances_stale(cfg)
except Exception:
pass
return result
def _stale_pending() -> dict[str, Any]:
from lib.exchange.okx_options_lib import invalidate_option_positions_cache
from lib.options.options_pending_lib import cancel_stale_close_pending_orders
@@ -1500,6 +2078,8 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
"profit_ratio": cfg["profit_ratio"],
"sync_trades_fn": _sync,
"target_close_fn": _target_close,
"profit_exit_close_fn": _profit_exit_close,
"profit_exit_cfg": cfg,
"stale_pending_fn": _stale_pending,
},
daemon=True,
+4
View File
@@ -129,6 +129,10 @@ def init_options_review_tables(conn: sqlite3.Connection) -> None:
_ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
_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", "premium_ccy", "TEXT")
_ensure_column(conn, "options_review_trades", "pnl_quote_ccy", "TEXT")
_ensure_column(conn, "options_review_trades", "idx_px", "REAL")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
+165 -8
View File
@@ -99,8 +99,140 @@ def _purge_review_trade_by_key(conn: sqlite3.Connection, history_key: str) -> bo
return True
def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str:
"""幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入."""
def _is_coin_option_row(row: dict[str, Any]) -> bool:
ccy = str(row.get("premium_ccy") or "").strip().upper()
if ccy in ("ETH", "BTC"):
return True
if str(row.get("margin_mode") or "").strip().lower() == "coin":
return True
inst = str(row.get("inst_id") or "").strip().upper()
return bool(inst) and "-USD-" in inst and "_UM" not in inst
def _resolve_review_index_px(
row: dict[str, Any],
*,
ex: Any = None,
cache: dict[str, float | None] | None = None,
) -> Optional[float]:
px = _safe_float(row.get("idx_px") or row.get("index_px") or row.get("options_index_px"))
if px is not None and px > 0:
return px
underly = str(row.get("underlying") or "").strip().upper()
if not underly:
inst = str(row.get("inst_id") or "")
underly = (inst.split("-")[0] if inst else "ETH").upper() or "ETH"
if cache is not None and underly in cache:
return cache[underly]
if ex is None:
return None
try:
from lib.exchange.okx_options_lib import fetch_index_price
got = fetch_index_price(ex, underly)
px = _safe_float(got)
if cache is not None:
cache[underly] = px if px is not None and px > 0 else None
return px if px is not None and px > 0 else None
except Exception:
if cache is not None:
cache[underly] = None
return None
def convert_option_amounts_to_usdt(
row: dict[str, Any],
*,
index_px: float | None = None,
ex: Any = None,
cache: dict[str, float | None] | None = None,
) -> dict[str, Any]:
"""币本位权利金/盈亏换算为 USDT;已标记 pnl_quote_ccy=USDT 则跳过."""
out = dict(row)
quote = str(out.get("pnl_quote_ccy") or "").strip().upper()
if quote in ("USDT", "USDC", "U"):
return out
if not _is_coin_option_row(out):
out["pnl_quote_ccy"] = "USDT"
return out
px = index_px if index_px is not None and index_px > 0 else _resolve_review_index_px(
out, ex=ex, cache=cache
)
if px is None or px <= 0:
return out
for key in ("realized_pnl", "premium_paid", "realized_pnl_total"):
v = _safe_float(out.get(key))
if v is not None:
out[key] = round(float(v) * float(px), 4)
out["idx_px"] = float(px)
out["pnl_quote_ccy"] = "USDT"
out["premium_ccy"] = "USDC"
return out
def repair_coin_review_rows_to_usdt(
conn: sqlite3.Connection,
*,
ex: Any = None,
) -> int:
"""把仍按币计价落库的复盘纯期权行换算成 U(幂等)."""
init_options_review_tables(conn)
rows = conn.execute(
"""
SELECT * FROM options_review_trades
WHERE source_type = ?
AND (pnl_quote_ccy IS NULL OR TRIM(pnl_quote_ccy) = '' OR UPPER(pnl_quote_ccy) NOT IN ('USDT','USDC','U'))
ORDER BY id DESC
LIMIT 500
""",
(SOURCE_OPTION,),
).fetchall()
cache: dict[str, float | None] = {}
fixed = 0
for raw in rows:
row = dict(raw)
if not _is_coin_option_row(row):
conn.execute(
"UPDATE options_review_trades SET pnl_quote_ccy='USDT' WHERE id=?",
(int(row["id"]),),
)
continue
converted = convert_option_amounts_to_usdt(row, ex=ex, cache=cache)
if str(converted.get("pnl_quote_ccy") or "").upper() not in ("USDT", "USDC", "U"):
continue
conn.execute(
"""
UPDATE options_review_trades
SET premium_paid=?, realized_pnl=?, realized_pnl_total=?,
premium_ccy=?, pnl_quote_ccy=?, idx_px=?
WHERE id=?
""",
(
converted.get("premium_paid"),
converted.get("realized_pnl"),
converted.get("realized_pnl")
if converted.get("realized_pnl") is not None
else converted.get("realized_pnl_total"),
converted.get("premium_ccy") or "USDC",
"USDT",
converted.get("idx_px"),
int(row["id"]),
),
)
fixed += 1
return fixed
def upsert_option_history_row(
conn: sqlite3.Connection,
row: dict[str, Any],
*,
ex: Any = None,
index_cache: dict[str, float | None] | None = None,
) -> str:
"""幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入.
币本位金额在写入前换算为 USDT.
"""
history_key = str(row.get("history_key") or "").strip()
if not history_key:
return "skip"
@@ -112,6 +244,7 @@ def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) ->
):
# 若此前已导入,清掉,避免列表残留
return "purged" if _purge_review_trade_by_key(conn, history_key) else "hidden"
row = convert_option_amounts_to_usdt(row, ex=ex, cache=index_cache)
opened_at = row.get("created_at") or row.get("opened_at")
closed_at = row.get("closed_at")
pnl = _safe_float(row.get("realized_pnl"))
@@ -139,6 +272,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,
"premium_ccy": str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC",
"pnl_quote_ccy": str(row.get("pnl_quote_ccy") or "USDT").strip().upper() or "USDT",
"idx_px": _safe_float(row.get("idx_px")),
}
cols = list(fields.keys())
if existing:
@@ -271,8 +407,14 @@ def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]
def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]:
"""从本地 options_trades 已平仓记录导入复盘快照(不访问交易所)."""
def sync_options_from_local_trades(
conn: sqlite3.Connection,
*,
ex: Any = None,
) -> dict[str, Any]:
"""从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).
币本位金额按指数换算为 USDT 后入库.
"""
init_options_review_tables(conn)
from lib.options.options_db import init_options_tables
@@ -281,7 +423,8 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]:
"""
SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets,
open_quote, close_quote, premium_paid, realized_pnl,
created_at, closed_at, signal_note, status
created_at, closed_at, signal_note, status,
margin_mode, premium_ccy
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
@@ -289,6 +432,7 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]:
"""
).fetchall()
inserted = updated = skipped = 0
index_cache: dict[str, float | None] = {}
for r in rows:
trade_id = int(r["id"])
history_key = f"local_opt:{trade_id}"
@@ -313,7 +457,11 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]:
"created_at": opened_at,
"closed_at": closed_at,
"status_label": "已平",
"margin_mode": r["margin_mode"] if "margin_mode" in r.keys() else None,
"premium_ccy": r["premium_ccy"] if "premium_ccy" in r.keys() else None,
},
ex=ex,
index_cache=index_cache,
)
if action == "inserted":
inserted += 1
@@ -354,6 +502,7 @@ def sync_options_from_exchange(
fmt = format_fn or format_option_history_row
raw_rows = fetch(ex, limit=limit)
meta_cache: dict[str, dict[str, Any] | None] = {}
index_cache: dict[str, float | None] = {}
inserted = updated = skipped = 0
for raw in raw_rows:
inst_id = str(raw.get("instId") or "").strip()
@@ -363,7 +512,9 @@ def sync_options_from_exchange(
except Exception:
pass
formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult)
action = upsert_option_history_row(conn, formatted)
action = upsert_option_history_row(
conn, formatted, ex=ex, index_cache=index_cache
)
if action == "inserted":
inserted += 1
elif action == "updated":
@@ -450,6 +601,7 @@ def upsert_hedge_plan_row(
"target_price": _safe_float(plan.get("target_price")),
"target_price_up": _safe_float(plan.get("target_price_up")),
"target_price_down": _safe_float(plan.get("target_price_down")),
"profit_rr": _safe_float(plan.get("profit_rr")),
"legs_json": _legs_json_from_plan(legs),
}
existing = conn.execute(
@@ -551,7 +703,7 @@ def sync_all_review_sources(
conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn
)
else:
out["options"] = sync_options_from_local_trades(conn)
out["options"] = sync_options_from_local_trades(conn, ex=ex)
out["hedge"] = sync_hedge_plans_closed(conn)
return out
@@ -578,7 +730,12 @@ def ensure_local_review_synced(
backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
return sync_all_review_sources(conn, from_exchange=False)
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
try:
out["repaired_usdt"] = repair_coin_review_rows_to_usdt(conn, ex=ex)
except Exception:
out["repaired_usdt"] = 0
return out
def _row_to_dict(row: Any) -> dict[str, Any]:
+416
View File
@@ -0,0 +1,416 @@
"""币本位期权:USDT↔标的币现货桥与本地状态."""
from __future__ import annotations
import sqlite3
import time
from typing import Any
from lib.options.options_margin_mode_lib import spot_quote_inst_id
BRIDGE_BOUGHT = "bought_pending_open"
BRIDGE_HOLDING = "holding"
BRIDGE_PENDING_SELL = "pending_sell_spot"
BRIDGE_CLOSED = "closed"
def ensure_bridge_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_spot_bridge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
underlying TEXT NOT NULL,
status TEXT NOT NULL,
budget_usdt REAL,
buy_ord_id TEXT,
coin_bought REAL,
sell_ord_id TEXT,
coin_sold REAL,
usdt_recovered REAL,
inst_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_spot_bridge_status
ON options_spot_bridge(status)
"""
)
def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]:
ensure_bridge_table(conn)
cur = conn.execute(
"""
SELECT id, underlying, status, budget_usdt, buy_ord_id, coin_bought,
sell_ord_id, coin_sold, usdt_recovered, inst_id, message,
created_at, updated_at, closed_at
FROM options_spot_bridge
WHERE status IN (?, ?, ?)
ORDER BY id DESC
""",
(BRIDGE_BOUGHT, BRIDGE_HOLDING, BRIDGE_PENDING_SELL),
)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def has_unfinished_bridge(conn: sqlite3.Connection) -> bool:
return bool(list_open_bridges(conn))
def insert_bridge(
conn: sqlite3.Connection,
*,
underlying: str,
status: str,
budget_usdt: float | None = None,
buy_ord_id: str | None = None,
coin_bought: float | None = None,
inst_id: str | None = None,
message: str | None = None,
) -> int:
ensure_bridge_table(conn)
cur = conn.execute(
"""
INSERT INTO options_spot_bridge(
underlying, status, budget_usdt, buy_ord_id, coin_bought, inst_id, message, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(
(underlying or "ETH").upper(),
status,
budget_usdt,
buy_ord_id,
coin_bought,
inst_id,
message,
),
)
conn.commit()
return int(cur.lastrowid)
def update_bridge(
conn: sqlite3.Connection,
bridge_id: int,
*,
status: str | None = None,
buy_ord_id: str | None = None,
coin_bought: float | None = None,
sell_ord_id: str | None = None,
coin_sold: float | None = None,
usdt_recovered: float | None = None,
inst_id: str | None = None,
message: str | None = None,
close: bool = False,
) -> None:
ensure_bridge_table(conn)
fields: list[str] = ["updated_at=CURRENT_TIMESTAMP"]
vals: list[Any] = []
if status is not None:
fields.append("status=?")
vals.append(status)
if buy_ord_id is not None:
fields.append("buy_ord_id=?")
vals.append(buy_ord_id)
if coin_bought is not None:
fields.append("coin_bought=?")
vals.append(coin_bought)
if sell_ord_id is not None:
fields.append("sell_ord_id=?")
vals.append(sell_ord_id)
if coin_sold is not None:
fields.append("coin_sold=?")
vals.append(coin_sold)
if usdt_recovered is not None:
fields.append("usdt_recovered=?")
vals.append(usdt_recovered)
if inst_id is not None:
fields.append("inst_id=?")
vals.append(inst_id)
if message is not None:
fields.append("message=?")
vals.append(message)
if close or status == BRIDGE_CLOSED:
fields.append("closed_at=CURRENT_TIMESTAMP")
vals.append(int(bridge_id))
conn.execute(
f"UPDATE options_spot_bridge SET {', '.join(fields)} WHERE id=?",
vals,
)
conn.commit()
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_coin_available(ex: Any, ccy: str) -> float | None:
"""交易账户标的币可用."""
from lib.exchange.okx_options_lib import _extract_ccy_free, _safe_float as _sf
ccy_u = (ccy or "").upper()
if not ccy_u:
return None
try:
bal = ex.fetch_balance(params={"type": "trading"})
free = _extract_ccy_free(bal, ccy_u)
if free is not None:
return float(free)
# 部分账户结构只有 total
from lib.exchange.okx_options_lib import _extract_ccy_balance
tot = _extract_ccy_balance(bal, ccy_u)
return float(tot) if tot is not None else None
except Exception:
return None
def spot_market_buy_coin_with_usdt(
ex: Any,
*,
underlying: str,
usdt_amount: float,
) -> dict[str, Any]:
"""交易账户:用 USDT 市价买入标的币."""
if usdt_amount <= 0:
return {"ok": False, "msg": "USDT 数量须大于 0"}
inst_id = spot_quote_inst_id(underlying)
try:
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "buy",
"ordType": "market",
"sz": str(usdt_amount),
"tgtCcy": "quote_ccy",
}
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {
"ok": True,
"inst_id": inst_id,
"ord_id": str(data[0].get("ordId") or ""),
"data": data[0],
"raw": resp,
}
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
except Exception as e:
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(e)}
def spot_market_sell_coin_to_usdt(
ex: Any,
*,
underlying: str,
coin_amount: float | None = None,
) -> dict[str, Any]:
"""交易账户:市价卖出标的币换 USDT.
默认/推荐:coin_amount 为空 卖光交易账户全部可用币(全部卖出).
传入 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}"}
avail_f = float(avail)
# 全部卖出:以可用余额为准;若传入数量则不超过可用(开仓失败回滚用)
if coin_amount is None or float(coin_amount) <= 0:
sell_sz = avail_f
else:
sell_sz = min(float(coin_amount), avail_f)
if sell_sz <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
inst_id = spot_quote_inst_id(ccy)
try:
# 现货卖出:向下截到 8 位,避免超过可用被拒;不再 *0.999 故意留残
sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".")
if not sz or float(sz) <= 0:
return {"ok": False, "msg": f"{ccy} 可卖数量过小"}
# 二次钳制:格式化后仍不得超过可用
if float(sz) > avail_f:
sz = f"{avail_f:.8f}".rstrip("0").rstrip(".")
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "sell",
"ordType": "market",
"sz": sz,
"tgtCcy": "base_ccy",
}
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {
"ok": True,
"inst_id": inst_id,
"ord_id": str(data[0].get("ordId") or ""),
"coin_sold": float(sz),
"data": data[0],
"raw": resp,
}
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
except Exception as e:
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(e)}
def rollback_bought_coin_to_usdt(
conn: sqlite3.Connection,
ex: Any,
*,
bridge_id: int,
underlying: str,
reason: str = "",
coin_amount: float | None = None,
) -> dict[str, Any]:
"""买币后开期权失败:卖回 USDT 并关闭桥.优先卖 bridge 记录的买入量."""
amt = coin_amount
if amt is None or float(amt) <= 0:
ensure_bridge_table(conn)
row = conn.execute(
"SELECT coin_bought FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if row:
try:
amt = float(row[0] if not isinstance(row, dict) else row.get("coin_bought") or 0)
except (TypeError, ValueError, KeyError, IndexError):
amt = None
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=amt)
if not sell.get("ok"):
update_bridge(
conn,
bridge_id,
status=BRIDGE_PENDING_SELL,
message=(reason or "") + " | 回滚卖币失败: " + str(sell.get("msg") or ""),
)
return {"ok": False, "msg": sell.get("msg") or "回滚卖币失败", "bridge_status": BRIDGE_PENDING_SELL}
update_bridge(
conn,
bridge_id,
status=BRIDGE_CLOSED,
sell_ord_id=str(sell.get("ord_id") or ""),
coin_sold=_safe_float(sell.get("coin_sold")),
message=reason or "开仓失败已卖回 USDT",
close=True,
)
return {"ok": True, "sell": sell, "bridge_status": BRIDGE_CLOSED}
def sell_residual_after_option_flat(
conn: sqlite3.Connection,
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
# 平仓后全部卖出交易账户可用标的币(含权利金盈亏留下的币),不按 bridge 记账量限卖
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=None)
if target 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,
bid,
status=BRIDGE_PENDING_SELL,
inst_id=inst_id,
message=str(sell.get("msg") or "卖回 USDT 失败"),
)
return {
"ok": False,
"msg": sell.get("msg") or "卖回 USDT 失败",
"bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL,
}
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}
def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
bridges = list_open_bridges(conn)
if not bridges:
return None
st = str(bridges[0].get("status") or "")
if st == BRIDGE_PENDING_SELL:
return "存在待卖回 USDT 的币本位桥残留,请先到期权页重试卖回后再开仓"
if st == BRIDGE_BOUGHT:
return "存在已买币未完成开仓的桥流程,请等待回滚或联系处理后重试"
if st == BRIDGE_HOLDING:
return "币本位桥仍在持仓中(一次仅一笔),请先平仓并卖回 USDT"
return "存在未完成的币本位资金桥,暂不可开仓"
def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None:
"""有单笔期权仓或未完成桥时禁止切换本位."""
if has_unfinished_bridge(conn):
return "存在未完成的币本位资金桥,禁止切换期权本位模式"
if ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
rows = fetch_option_positions(ex) or []
for p in rows:
try:
pos = float(p.get("pos") or 0)
except (TypeError, ValueError):
pos = 0.0
if abs(pos) > 1e-12:
return "存在未平期权持仓,禁止切换期权本位模式"
except Exception:
pass
# 本地 open 交易记录
try:
row = conn.execute(
"SELECT COUNT(*) FROM options_trades WHERE status='open'"
).fetchone()
n = int(row[0] if not isinstance(row, dict) else row.get("COUNT(*)") or list(row.values())[0])
if n > 0:
return "本地仍有未平期权记录,禁止切换期权本位模式"
except Exception:
pass
return None
+81 -62
View File
@@ -6,6 +6,7 @@ from typing import Any
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
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
def _parse_ts(raw: Any) -> datetime | None:
@@ -33,8 +34,62 @@ def _avg_seconds(values: list[float]) -> float | None:
return round(sum(values) / len(values), 1)
def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[str, 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 _row_premium_ccy(row: dict[str, Any]) -> str:
ccy = str(row.get("premium_ccy") or "").strip().upper()
if ccy:
return ccy
inst = str(row.get("inst_id") or "").strip()
mode = str(row.get("margin_mode") or "").strip().lower()
underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "ETH") or "ETH")
if mode:
return premium_ccy_for_mode(mode, underly)
if not inst:
# 旧统计行无合约信息时按 USDC 口径,避免默认币本位把盈亏跳过
return "USDC"
return premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
def _pnl_as_usdt(row: dict[str, Any], *, fallback_index: float | None = None) -> float | None:
"""已平/浮盈统一折算为 USDT(币本位×指数;USDC 原样)."""
pnl = _safe_float(row.get("realized_pnl"))
if pnl is None:
pnl = _safe_float(row.get("upl"))
if pnl is None:
return None
ccy = _row_premium_ccy(row)
if ccy in ("ETH", "BTC"):
px = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if px is None or px <= 0:
px = fallback_index
if px is None or px <= 0:
return None
return float(pnl) * float(px)
return float(pnl)
def _history_index_px(history: list[dict[str, Any]]) -> float | None:
for row in history:
px = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if px is not None and px > 0:
return px
return None
def compute_options_stats_from_history(
history: list[dict[str, Any]],
*,
index_px: float | None = None,
) -> dict[str, Any]:
"""基于期权历史列表计算统计;币本位盈亏按指数折算为 U."""
wins: list[float] = []
losses: list[float] = []
win_holds: list[float] = []
@@ -42,8 +97,13 @@ def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[st
all_holds: list[float] = []
open_holds: list[float] = []
now = datetime.now()
fallback_idx = index_px if index_px is not None and index_px > 0 else _history_index_px(history)
coinish = False
for row in history:
ccy = _row_premium_ccy(row)
if ccy in ("ETH", "BTC"):
coinish = True
if row.get("status") == "open":
start = _parse_ts(row.get("created_at"))
if start is not None:
@@ -51,12 +111,8 @@ def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[st
if sec >= 0:
open_holds.append(sec)
continue
pnl_raw = row.get("realized_pnl")
if pnl_raw is None:
continue
try:
pnl = float(pnl_raw)
except (TypeError, ValueError):
pnl = _pnl_as_usdt(row, fallback_index=fallback_idx)
if pnl is None:
continue
hold = _hold_seconds(row.get("created_at"), row.get("closed_at"))
if hold is not None:
@@ -94,6 +150,8 @@ def compute_options_stats_from_history(history: list[dict[str, Any]]) -> dict[st
"avg_loss_hold_sec": _avg_seconds(loss_holds),
"open_count": len(open_holds),
"avg_open_hold_sec": _avg_seconds(open_holds),
"pnl_unit": "U" if coinish else "USDC",
"index_px": fallback_idx,
}
@@ -103,7 +161,7 @@ def compute_options_stats(get_db) -> dict[str, Any]:
init_options_tables(conn)
closed_rows = conn.execute(
"""
SELECT realized_pnl, created_at, closed_at
SELECT realized_pnl, created_at, closed_at, inst_id, premium_ccy, margin_mode
FROM options_trades
WHERE status = 'closed' AND realized_pnl IS NOT NULL
"""
@@ -116,58 +174,19 @@ def compute_options_stats(get_db) -> dict[str, Any]:
finally:
conn.close()
wins: list[float] = []
losses: list[float] = []
win_holds: list[float] = []
loss_holds: list[float] = []
all_holds: list[float] = []
now = datetime.now()
hist = []
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)
open_holds: list[float] = []
hist.append(
{
"status": "closed",
"realized_pnl": row["realized_pnl"],
"created_at": row["created_at"],
"closed_at": row["closed_at"],
"inst_id": row["inst_id"] if "inst_id" in row.keys() else None,
"premium_ccy": row["premium_ccy"] if "premium_ccy" in row.keys() else None,
"margin_mode": row["margin_mode"] if "margin_mode" in row.keys() else None,
}
)
for row in open_rows:
start = _parse_ts(row["created_at"])
if start is None:
continue
sec = (now - start).total_seconds()
if sec >= 0:
open_holds.append(sec)
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
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),
}
hist.append({"status": "open", "created_at": row["created_at"]})
return compute_options_stats_from_history(hist)
+53 -3
View File
@@ -33,7 +33,13 @@ def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None =
if strike is None:
strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
return close_ref_prices(
mark_px=mark,
opt_type=str(opt_type or ""),
strike=strike,
index_px=idx,
inst_id=inst_id,
)
def ensure_target_tables(conn: sqlite3.Connection) -> None:
@@ -303,6 +309,11 @@ def _notify_target_close(
conn: Any = None,
) -> None:
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id)
d = 6 if ccy in ("ETH", "BTC") else 4
mode = "币本位" if ccy != "USDC" else "USDC"
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
@@ -318,23 +329,28 @@ def _notify_target_close(
close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target,
trigger_idx=idx,
premium_ccy=ccy,
)
# 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推
return
except Exception:
pass
if not send_wechat:
return
try:
recv = result.get("premium_received")
recv_txt = f"{float(recv):.{d}f} {ccy}" if recv is not None else f"{ccy}"
send_wechat(
"\n".join(
[
"【OKX期权·目标位平仓】",
f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}",
f"目标指数:{target:g}",
f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or ''}",
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else ''} USDC",
f"预估收回:{recv_txt}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
@@ -385,7 +401,7 @@ def run_options_target_closes(
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
_commit_monitor(conn)
# 先处理已挂单等待成交的,绝不再发微信
# 先处理已挂单等待成交的;首次触发已推过「挂单中」,此处仅在全平时走幂等平仓推送
for mon in list_closing_targets(conn):
inst_id = str(mon.get("inst_id") or "")
if not inst_id:
@@ -402,6 +418,18 @@ def run_options_target_closes(
if inst_id not in pos_by_inst:
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn)
target = _safe_float(mon.get("target_index"))
idx = _safe_float(mon.get("trigger_idx"))
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=float(target) if target is not None else 0.0,
idx=float(idx) if idx is not None else 0.0,
result={"already_flat": True, "fully_closed": True, "ok": True},
conn=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"))
@@ -415,6 +443,17 @@ def run_options_target_closes(
message="目标位限价平仓完成",
)
_commit_monitor(conn)
target = _safe_float(mon.get("target_index"))
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=float(target) if target is not None else 0.0,
idx=float(idx) if idx is not None else 0.0,
result={**result, "fully_closed": True},
conn=conn,
)
continue
mark_monitor(
conn,
@@ -458,6 +497,17 @@ def run_options_target_closes(
if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn)
triggered += 1
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
idx=idx,
result=result,
conn=conn,
)
continue
if not result.get("ok"):
mark_monitor(
+85 -29
View File
@@ -2,7 +2,11 @@
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
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 %}"
data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}"
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code><code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
{% endif %}
@@ -14,16 +18,27 @@
<div class="card options-order-card"{% if options_open_allowed is defined and not options_open_allowed %} style="opacity:.72"{% endif %}>
<h2>期权下单{% if options_open_allowed is defined and not options_open_allowed %} <small class="muted">(对冲模式已禁用开仓)</small>{% endif %}</h2>
<details class="opt-close-rule opt-open-rule">
<summary>开仓规则说明</summary>
<summary>仓规则说明</summary>
<div class="opt-close-rule-body">
<p>报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
<p><strong>开仓</strong> · 报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
<ul>
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
<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>平仓仅买一限价,详见说明文档</li>
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓</li>
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code>):按交易户 USDT×缓冲买满 ETH/BTC 再开满期权;平仓后自动卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。</li>
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
</ul>
<p><strong>平仓(买一)</strong> · 平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
<ul>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
<li>币本位平仓后自动卖回 USDT;失败可点持仓区「重试卖回」按交易户全部可用量市价卖出。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
@@ -57,10 +72,10 @@
<th>类型</th>
<th>合约</th>
<th>卖一/张</th>
<th title="指数÷卖一(每1币)">杠杆</th>
<th title="USDC:指数÷卖一;币本位:1÷卖一(卖一为币报价)">杠杆</th>
<th>买一/张</th>
<th>到期平衡</th>
<th>平衡</th>
<th>平衡价差</th>
<th>操作</th>
</tr>
<tr id="opt-strike-head-t" class="hidden" hidden>
@@ -103,34 +118,50 @@
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v"></span></div>
<div><span class="k">合约杠杆</span><span id="opt-order-leverage" class="v" title="名义价值÷权利金,测算用"></span></div>
<div><span class="k">到期平衡</span><span id="opt-order-expiry-be" class="v"></span></div>
<div><span class="k">平衡</span><span id="opt-order-dist-be" class="v"></span></div>
<div><span class="k">平衡价差</span><span id="opt-order-dist-be" class="v"></span></div>
</div>
<div class="options-estimate-row">
<div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-target-idx">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓"
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span class="k">预计价值</span>
<span id="opt-est-value" class="v"></span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span>
<span class="k">盈亏比</span>
<span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金"></span>
</div>
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平;无止损,到期即止损</span>
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span>
</div>
<div class="options-estimate-row opt-profit-exit-row">
<div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
<input type="checkbox" id="opt-profit-exit-enabled">
<span>翻倍出场</span>
</label>
<label class="k" for="opt-profit-exit-mult">倍数</label>
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
</div>
<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" checked>
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
<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">
<input type="radio" name="opt-size-mode" value="budget_full">
<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 %}>
<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 %}>
<input type="radio" name="opt-size-mode" value="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
<span>全仓复利</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<span>指定币数量</span>
@@ -141,6 +172,9 @@
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
</p>
<p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
</p>
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
@@ -159,7 +193,11 @@
<div class="card options-pos-card-wrap">
<div class="options-pos-head">
<h2>持仓</h2>
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
<div class="options-pos-head-actions">
<span id="opt-bridge-sell-hint" class="muted opt-bridge-sell-hint" hidden></span>
<button type="button" class="btn-secondary opt-retry-sell-coin-btn" id="opt-retry-sell-coin" hidden title="市价卖出交易账户全部可用标的币换回 USDT">重试卖回</button>
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
</div>
</div>
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
<button type="button" class="btn-secondary opt-pos-tab active" data-opt-pos-tab="live" role="tab" aria-selected="true" id="opt-pos-tab-live">当前持仓</button>
@@ -177,19 +215,6 @@
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
<div id="opt-pos-cards"></div>
</div>
<details class="opt-close-rule">
<summary>买一平仓规则说明</summary>
<div class="opt-close-rule-body">
<p>平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
<ul>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
</details>
</div>
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
<div class="opt-pos-pending-pane">
@@ -320,8 +345,39 @@
</div>
</div>
</div>
<details class="opt-pos-transfer" id="opt-pos-transfer">
<summary class="opt-pos-transfer-head">
<span class="opt-pos-transfer-title">划转</span>
<span class="opt-pos-transfer-open-hint muted">收起</span>
<span class="opt-pos-transfer-closed-hint muted">展开</span>
</summary>
<div class="opt-pos-transfer-body">
<div class="options-settings-subtitle">账户内划转</div>
<div class="form-row opt-pos-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="">
<select id="opt-pos-xfer-ccy" aria-label="币种" autocomplete="off">
<option value="USDT" selected>USDT</option>
<option value="USDC">USDC</option>
</select>
<select id="opt-pos-xfer-from" aria-label="划出账户">
<option value="funding" selected>from: 资金</option>
<option value="trading">from: 交易</option>
</select>
<select id="opt-pos-xfer-to" aria-label="划入账户">
<option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option>
</select>
<input type="number" id="opt-pos-xfer-amount" name="cm_opt_pos_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-pos-xfer-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-pos-xfer-btn">划转</button>
</div>
<div id="opt-pos-xfer-msg" class="muted opt-pos-xfer-msg"></div>
</div>
</details>
</div>
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=54"></script>
<script src="/static/options_panel.js?v=70"></script>
@@ -4,10 +4,13 @@
{% endif %}
{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%}
{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %}
<select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %} class="trade-policy-symbol-select">
<option value="">选择币种</option>
{% for sym in trade_policy.symbol_whitelist %}
<option value="{{ sym }}" {% if value and ((value|upper) == sym or (value|upper).startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option>
{% set wl = trade_policy.symbol_whitelist %}
{% set sole_sym = wl[0] if (wl|length) == 1 else '' %}
{% set effective = value if value else sole_sym %}
<select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %} class="trade-policy-symbol-select"{% if sole_sym %} data-sole-symbol="{{ sole_sym }}"{% endif %}>
{% if not sole_sym %}<option value="">选择币种</option>{% endif %}
{% for sym in wl %}
<option value="{{ sym }}" {% if effective and ((effective|upper) == sym or (effective|upper).startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option>
{% endfor %}
</select>
{% else %}
+9 -4
View File
@@ -17,15 +17,20 @@ def trade_policy_template_context(policy: TradePolicy) -> dict:
def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str:
d = (raw_default or "BTC/USDT").strip() or "BTC/USDT"
d = (raw_default or "").strip()
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
# 白名单仅一币时直接用 env 币种,表单下拉同步默认选中
if len(policy.symbol_whitelist) == 1:
return f"{policy.symbol_whitelist[0]}/USDT"
from lib.trade.trade_policy_lib import symbol_base_coin
base = symbol_base_coin(d)
base = symbol_base_coin(d or "BTC/USDT")
if base not in policy.symbol_whitelist:
return f"{policy.symbol_whitelist[0]}/USDT"
return d
if d:
return d if "/" in d else f"{base}/USDT"
return f"{policy.symbol_whitelist[0]}/USDT"
return d or "BTC/USDT"
def check_symbol_policy(
policy: TradePolicy,
+3 -2
View File
@@ -23,8 +23,9 @@ HUB_DISABLED_IDS=
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100)
HUB_TRUST_LAN=true
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"})
# HUB_ALLOW_PUBLIC=true
# 默认 true(代码默认允许公网/反代访问中控,靠 HUB_PASSWORD 保护)
# 仅本机调试可关: HUB_ALLOW_PUBLIC=false
HUB_ALLOW_PUBLIC=true
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
HUB_USERNAME=admin
+72 -13
View File
@@ -37,6 +37,11 @@ from lib.hub.hub_position_metrics import (
parse_position_unrealized_pnl,
resolve_position_display_upnl,
)
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
normalize_api_credential,
strip_ccxt_credentials,
)
import ccxt
from fastapi import FastAPI, Header, HTTPException, Request
@@ -86,12 +91,31 @@ GATE_POS_MODE = "hedge" if _gate_pos in ("hedge", "dual", "double") else "single
app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None)
_ccxt_ex: Any = None
_markets_loaded = False
# 鉴权失败冷却:中控会轮询 /status;坏钥时若持续签名请求,Gate 易封 IP
_AUTH_FAIL_UNTIL = 0.0
_AUTH_FAIL_MSG = ""
_AUTH_COOLDOWN_SEC = 600
def _socks_proxy_url(prefix: str) -> str:
return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip()
def _raise_if_auth_cooling() -> None:
if time.time() < _AUTH_FAIL_UNTIL and _AUTH_FAIL_MSG:
raise RuntimeError(_AUTH_FAIL_MSG)
def _mark_auth_failure(exc: BaseException) -> None:
global _AUTH_FAIL_UNTIL, _AUTH_FAIL_MSG, _ccxt_ex, _markets_loaded
_AUTH_FAIL_MSG = f"交易所鉴权失败(已暂停签名请求 {_AUTH_COOLDOWN_SEC}s,避免封 IP): {exc}"
_AUTH_FAIL_UNTIL = time.time() + _AUTH_COOLDOWN_SEC
if _ccxt_ex is not None:
strip_ccxt_credentials(_ccxt_ex)
_ccxt_ex = None
_markets_loaded = False
def _http_https_proxy(prefix: str) -> dict[str, str] | None:
http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip()
https = (os.getenv(f"{prefix}_HTTPS_PROXY") or "").strip()
@@ -110,11 +134,12 @@ def _attach_proxies(ex: Any, prefix: str) -> None:
def _make_exchange() -> Any:
_raise_if_auth_cooling()
if EXCHANGE_KIND == "binance":
key = (os.getenv("BINANCE_API_KEY") or "").strip()
secret = (os.getenv("BINANCE_API_SECRET") or "").strip()
key = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
secret = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
if not key or not secret:
raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET")
raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET(请在服务器 .env 配置真密钥)")
ex = ccxt.binance(
{
"apiKey": key,
@@ -133,11 +158,11 @@ def _make_exchange() -> Any:
return ex
if EXCHANGE_KIND == "okx":
key = (os.getenv("OKX_API_KEY") or "").strip()
secret = (os.getenv("OKX_API_SECRET") or "").strip()
password = (os.getenv("OKX_API_PASSPHRASE") or "").strip()
key = normalize_api_credential(os.getenv("OKX_API_KEY"))
secret = normalize_api_credential(os.getenv("OKX_API_SECRET"))
password = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
if not key or not secret or not password:
raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE")
raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE(请在服务器 .env 配置真密钥)")
ex = ccxt.okx(
{
"apiKey": key,
@@ -154,10 +179,10 @@ def _make_exchange() -> Any:
return ex
# gate
key = (os.getenv("GATE_API_KEY") or "").strip()
secret = (os.getenv("GATE_API_SECRET") or "").strip()
key = normalize_api_credential(os.getenv("GATE_API_KEY"))
secret = normalize_api_credential(os.getenv("GATE_API_SECRET"))
if not key or not secret:
raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET")
raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET(请在服务器 .env 配置真密钥)")
from lib.exchange.gate_ccxt_lib import gate_ccxt_class
ex = gate_ccxt_class()(
@@ -177,6 +202,7 @@ def _make_exchange() -> Any:
def get_exchange() -> Any:
global _ccxt_ex
_raise_if_auth_cooling()
if _ccxt_ex is None:
_ccxt_ex = _make_exchange()
return _ccxt_ex
@@ -184,9 +210,17 @@ def get_exchange() -> Any:
def _ensure_markets() -> None:
global _markets_loaded
if not _markets_loaded:
if _markets_loaded:
return
_raise_if_auth_cooling()
try:
get_exchange().load_markets()
_markets_loaded = True
except Exception as e:
if is_exchange_auth_error(e):
_mark_auth_failure(e)
raise RuntimeError(_AUTH_FAIL_MSG) from e
raise
def _check_token(x_control_token: str | None) -> None:
@@ -572,8 +606,20 @@ def _status_inner(x_control_token: str | None) -> Any:
u = bal.get("USDT") or {}
if isinstance(u, dict) and u.get("total") is not None:
balance_usdt = _finite_or_none(u["total"])
except Exception:
pass
except Exception as e:
if is_exchange_auth_error(e):
_mark_auth_failure(e)
return JSONResponse(
{
"ok": False,
"error": _AUTH_FAIL_MSG,
"exchange": EXCHANGE_KIND,
"balance_usdt": None,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
positions_out: list[dict[str, Any]] = []
total_upnl = 0.0
@@ -587,6 +633,19 @@ def _status_inner(x_control_token: str | None) -> Any:
else:
raw = ex.fetch_positions() or []
except Exception as e:
if is_exchange_auth_error(e):
_mark_auth_failure(e)
return JSONResponse(
{
"ok": False,
"error": _AUTH_FAIL_MSG,
"exchange": EXCHANGE_KIND,
"balance_usdt": balance_usdt,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
return JSONResponse(
{
"ok": False,
+4 -5
View File
@@ -187,9 +187,9 @@ HUB_PORT = int(os.getenv("HUB_PORT", "5100"))
HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
_allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
# 默认 true:云端域名/反代可访问;仅靠 HUB_PASSWORD 保护.本地若要强制仅本机,设 HUB_ALLOW_PUBLIC=false
_allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC", "true") or "").strip().lower()
HUB_ALLOW_PUBLIC = _allow_pub_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260607-hub-archive"
_archive_sync_stop: asyncio.Event | None = None
@@ -336,8 +336,7 @@ async def _run_board_aggregate() -> dict:
await asyncio.to_thread(record_fund_snapshot_from_board, body.get("rows") or [])
except Exception:
pass
# 监控聚合完成即唤醒数据看板,持仓来源与监控 5s 同步.
dashboard_store.request_refresh()
# 看板自有轮询即可;此处再 request_refresh 会与监控锁步,聚合变慢时几乎不睡眠打满 CPU.
return {"ok": True, **body}
except asyncio.TimeoutError:
return {
+8 -1
View File
@@ -123,9 +123,16 @@ def _format_options_position_detail_line(p: dict) -> str:
if sheets is None:
sheets = "?"
parts = [f"期权 {inst} {label}", f"来源{src}", f"张数{sheets}"]
mode_lab = p.get("margin_mode_label") or ("币本位" if p.get("margin_mode") == "coin" else "")
if mode_lab:
parts.append(f"本位{mode_lab}")
paid = _safe_float(p.get("premium_paid"))
if paid is not None:
parts.append(f"权利金{paid:g}U")
ccy = p.get("premium_ccy") or ("ETH" if p.get("margin_mode") == "coin" else "USDC")
if str(ccy).upper() == "USDC":
parts.append(f"权利金{paid:g}U")
else:
parts.append(f"权利金{paid:g}{ccy}")
net: Optional[float] = None
try:
from lib.options.options_positions_lib import net_pnl_from_display_row
+9 -8
View File
@@ -5,9 +5,12 @@ from __future__ import annotations
import asyncio
import json
import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
from lib.hub.hub_poll_wait_lib import wait_poll_interval
HUB_BOARD_POLL_INTERVAL = float(os.getenv("HUB_BOARD_POLL_INTERVAL", "5"))
HUB_BOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_BOARD_SSE_HEARTBEAT_SEC", "25"))
@@ -79,18 +82,16 @@ class MonitorBoardStore:
async def _loop(self) -> None:
assert self._build_fn is not None
while not self._stop.is_set():
started = time.monotonic()
await self._aggregate_once(self._build_fn)
if self._stop.is_set():
break
self._refresh.clear()
sleep_task = asyncio.create_task(asyncio.sleep(HUB_BOARD_POLL_INTERVAL))
refresh_task = asyncio.create_task(self._refresh.wait())
done, pending = await asyncio.wait(
{sleep_task, refresh_task},
return_when=asyncio.FIRST_COMPLETED,
await wait_poll_interval(
refresh=self._refresh,
stop=self._stop,
interval_sec=HUB_BOARD_POLL_INTERVAL,
started_at=started,
)
for t in pending:
t.cancel()
async def _aggregate_once(self, build_fn: BuildFn) -> None:
async with self._lock:
+7 -8
View File
@@ -11,6 +11,7 @@ from dataclasses import dataclass
from typing import Any
from hub_board_cache import board_store
from lib.hub.hub_poll_wait_lib import wait_poll_interval
HUB_CHART_POLL_INTERVAL = float(os.getenv("HUB_CHART_POLL_INTERVAL", "5"))
HUB_CHART_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_CHART_SSE_HEARTBEAT_SEC", "25"))
@@ -161,18 +162,16 @@ class ChartPollStore:
async def _loop(self) -> None:
assert self._poll_fn is not None
while not self._stop.is_set():
started = time.monotonic()
await self._poll_once(self._poll_fn)
if self._stop.is_set():
break
self._refresh.clear()
sleep_task = asyncio.create_task(asyncio.sleep(HUB_CHART_POLL_INTERVAL))
refresh_task = asyncio.create_task(self._refresh.wait())
done, pending = await asyncio.wait(
{sleep_task, refresh_task},
return_when=asyncio.FIRST_COMPLETED,
await wait_poll_interval(
refresh=self._refresh,
stop=self._stop,
interval_sec=HUB_CHART_POLL_INTERVAL,
started_at=started,
)
for t in pending:
t.cancel()
async def _poll_once(self, poll_fn: PollFn) -> None:
async with self._lock:
+8 -8
View File
@@ -5,10 +5,12 @@ from __future__ import annotations
import asyncio
import json
import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC
from lib.hub.hub_poll_wait_lib import wait_poll_interval
HUB_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_DASHBOARD_SSE_HEARTBEAT_SEC", "25"))
@@ -81,18 +83,16 @@ class DashboardStore:
async def _loop(self) -> None:
assert self._build_fn is not None
while not self._stop.is_set():
started = time.monotonic()
await self._aggregate_once(self._build_fn)
if self._stop.is_set():
break
self._refresh.clear()
sleep_task = asyncio.create_task(asyncio.sleep(DASHBOARD_POLL_INTERVAL_SEC))
refresh_task = asyncio.create_task(self._refresh.wait())
done, pending = await asyncio.wait(
{sleep_task, refresh_task},
return_when=asyncio.FIRST_COMPLETED,
await wait_poll_interval(
refresh=self._refresh,
stop=self._stop,
interval_sec=DASHBOARD_POLL_INTERVAL_SEC,
started_at=started,
)
for t in pending:
t.cancel()
async def _aggregate_once(self, build_fn: BuildFn) -> None:
async with self._lock:
+9 -8
View File
@@ -4,9 +4,12 @@ from __future__ import annotations
import asyncio
import json
import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
from lib.hub.hub_poll_wait_lib import wait_poll_interval
SUPERVISOR_POLL_INTERVAL_SEC = float(os.getenv("SUPERVISOR_POLL_INTERVAL_SEC", "30"))
SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25"))
@@ -65,18 +68,16 @@ class SupervisorStore:
async def _loop(self) -> None:
assert self._tick_fn is not None
while not self._stop.is_set():
started = time.monotonic()
await self._tick_once(self._tick_fn)
if self._stop.is_set():
break
self._refresh.clear()
sleep_task = asyncio.create_task(asyncio.sleep(SUPERVISOR_POLL_INTERVAL_SEC))
refresh_task = asyncio.create_task(self._refresh.wait())
done, pending = await asyncio.wait(
{sleep_task, refresh_task},
return_when=asyncio.FIRST_COMPLETED,
await wait_poll_interval(
refresh=self._refresh,
stop=self._stop,
interval_sec=SUPERVISOR_POLL_INTERVAL_SEC,
started_at=started,
)
for t in pending:
t.cancel()
async def _tick_once(self, tick_fn: TickFn) -> None:
async with self._lock:
+234 -20
View File
@@ -746,6 +746,93 @@
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
}
/** 币本位权利金/盈亏:ETH/BTC 保留足量小数;USDC 两位. */
function optPremiumCcyOf(p, fallbackUnderly) {
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
if (ccy) return ccy;
const mode = String((p && p.margin_mode) || "").toLowerCase();
const inst = String((p && p.inst_id) || "");
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
return (inst.split("-")[0] || fallbackUnderly || "ETH").toUpperCase() || "ETH";
}
return "USDC";
}
function optionsPanelCcy(optMeta) {
if (!optMeta || typeof optMeta !== "object") return "USDC";
const mode = String(optMeta.options_margin_mode || optMeta.margin_mode || "").toLowerCase();
const underly = String(optMeta.options_underly || "ETH").toUpperCase() || "ETH";
if (mode === "coin") return underly;
const pos = Array.isArray(optMeta.positions) ? optMeta.positions : [];
for (let i = 0; i < pos.length; i++) {
const c = optPremiumCcyOf(pos[i], underly);
if (c && c !== "USDC") return c;
}
return "USDC";
}
function fmtOptPnlAmt(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
if (unit === "ETH" || unit === "BTC") {
const s = n.toFixed(8).replace(/\.?0+$/, "");
return s || "0";
}
return fmt(n, 2);
}
function spotPxFromOptMeta(optMeta, p) {
if (p) {
const n = Number(p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px);
if (Number.isFinite(n) && n > 0) return n;
}
if (optMeta) {
const n = Number(optMeta.options_index_px != null ? optMeta.options_index_px : optMeta.index_px);
if (Number.isFinite(n) && n > 0) return n;
const pos = Array.isArray(optMeta.positions) ? optMeta.positions : [];
for (let i = 0; i < pos.length; i++) {
const px = Number(pos[i] && (pos[i].idx_px != null ? pos[i].idx_px : pos[i].idxPx));
if (Number.isFinite(px) && px > 0) return px;
}
}
return null;
}
function fmtOptPnlText(v, ccy, spotPx) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
const sign = n > 0 ? "+" : "";
if (unit === "ETH" || unit === "BTC") {
const coin = `${sign}${fmtOptPnlAmt(Math.abs(n), unit)}`;
const signedCoin = n < 0 ? `-${fmtOptPnlAmt(Math.abs(n), unit)}` : coin;
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return `${signedCoin} ${unit}`;
const u = n * px;
const uAbs = Math.abs(u).toFixed(2);
const uTxt = u < 0 ? `-${uAbs}` : u > 0 ? `+${uAbs}` : uAbs;
return `${signedCoin} ${unit} / ${uTxt}U`;
}
return `${sign}${fmt(n, 2)}U`;
}
/** 中控期权浮盈汇总框:只显示换算后的 U(持仓表净盈亏仍用 fmtOptPnlText 双显). */
function fmtOptPnlUsdtOnly(v, ccy, spotPx) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
let uu = n;
if (unit === "ETH" || unit === "BTC") {
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return "—";
uu = n * px;
}
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
return `${uSign}${uAbs}U`;
}
/** 交易所持仓开仓价(三所子代理 entry_price) */
function positionEntryPrice(pos) {
if (!pos) return null;
@@ -3882,27 +3969,95 @@
};
}
function renderStatRow(funding, trading, upnl, kind) {
function renderStatRow(funding, trading, upnl, kind, optMeta) {
if (!showAccountPnlPref()) return "";
const isOpt = kind === "options";
const fundLabel = isOpt ? "期权资金账户" : "资金账户";
const tradeLabel = isOpt ? "期权交易账户" : "交易账户";
const pnlLabel = isOpt ? "期权浮盈" : "浮盈合计";
const rowCls = isOpt ? "stat-row stat-row-options" : "stat-row";
const coinMode = isOpt && optMeta && (optMeta.options_margin_mode === "coin" || optMeta.margin_mode === "coin");
if (coinMode) {
const bal = (optMeta && optMeta.balances) || {};
const fundUsdt = bal.funding_usdt != null ? bal.funding_usdt : optMeta.funding_usdt;
const usdt = bal.trading_usdt != null ? bal.trading_usdt : (optMeta.trading_usdt != null ? optMeta.trading_usdt : trading);
const eth = bal.trading_eth;
const btc = bal.trading_btc;
const fundTxt = fundUsdt != null && fundUsdt !== ""
? `${fmt(fundUsdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small>`
: "—";
const tradeTxt = formatCoinTradingLabel(usdt, eth, btc);
const pnlCcy = optionsPanelCcy(optMeta);
const spotPx = spotPxFromOptMeta(optMeta);
const pnlTxt = upnl == null || Number.isNaN(Number(upnl))
? "—"
: `<span class="${pnlCls(upnl)}">${fmtOptPnlUsdtOnly(upnl, pnlCcy, spotPx)}</span>`;
return `<div class="${rowCls}">
<div class="stat-box"><div class="stat-label">资金账户</div><div class="stat-value">${fundTxt}</div></div>
<div class="stat-box"><div class="stat-label">交易账户</div><div class="stat-value">${tradeTxt}</div></div>
<div class="stat-box"><div class="stat-label">${pnlLabel}</div><div class="stat-value">${pnlTxt}</div></div>
</div>`;
}
let fundTxt = `${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small>`;
let tradeTxt = `${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small>`;
return `<div class="${rowCls}">
<div class="stat-box"><div class="stat-label">${fundLabel}</div><div class="stat-value">${fmt(funding, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">${tradeLabel}</div><div class="stat-value">${fmt(trading, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
<div class="stat-box"><div class="stat-label">${fundLabel}</div><div class="stat-value">${fundTxt}</div></div>
<div class="stat-box"><div class="stat-label">${tradeLabel}</div><div class="stat-value">${tradeTxt}</div></div>
<div class="stat-box"><div class="stat-label">${pnlLabel}</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></div>
</div>`;
}
function formatCoinTradingLabel(usdt, eth, btc) {
const parts = [];
if (usdt != null && usdt !== "") {
const n = Number(usdt);
if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
const pushCoin = (v, ccy) => {
if (v == null || v === "") return;
const n = Number(v);
const minAmt = ccy === "BTC" ? 1e-7 : 1e-6;
if (Number.isNaN(n) || !(n >= minAmt)) return;
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`);
};
pushCoin(eth, "ETH");
pushCoin(btc, "BTC");
return parts.length ? parts.join(" / ") : "—";
}
function formatCoinOptFunds(opt, side, underly) {
const bal = (opt && opt.balances) || {};
const usdt = side === "funding"
? (bal.funding_usdt != null ? bal.funding_usdt : opt.funding_usdt)
: (bal.trading_usdt != null ? bal.trading_usdt : opt.trading_usdt);
let coin = side === "funding"
? (bal.funding_eth != null ? bal.funding_eth : bal.funding_btc)
: (bal.trading_eth != null ? bal.trading_eth : bal.trading_btc);
if (underly === "BTC" && side === "funding" && bal.funding_btc != null) coin = bal.funding_btc;
if (underly === "BTC" && side === "trading" && bal.trading_btc != null) coin = bal.trading_btc;
const parts = [];
if (usdt != null && usdt !== "") {
const n = Number(usdt);
if (!Number.isNaN(n)) parts.push(`${n.toFixed(2)} USDT`);
}
if (coin != null && coin !== "") {
const n = Number(coin);
if (!Number.isNaN(n)) {
const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${underly}`);
}
}
return parts.length ? parts.join(" / ") : "—";
}
function renderAccountStatRow(row, ag) {
return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl);
}
function renderOptionsAccountStatRow(opt) {
const bal = optionsBalanceFields(opt);
return renderStatRow(bal.funding, bal.trading, bal.upl, "options");
return renderStatRow(bal.funding, bal.trading, bal.upl, "options", opt || {});
}
function shortOptionsInst(instId) {
@@ -3928,14 +4083,47 @@
);
}
function renderOptionsTargetCell(target) {
if (!target) return "<td>—</td>";
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
const px = target.target_index != null ? fmt(target.target_index, 1) : "";
if (target.managed_by === "hedge_plan") {
function formatProfitExitMultLabel(mult) {
const n = Number(mult);
if (!Number.isFinite(n) || n <= 0) return "1倍";
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)) + "";
return fmt(n, 2) + "倍";
}
function renderOptionsTargetCell(target, pos) {
if (target && target.managed_by === "hedge_plan") {
const rr = target.profit_rr != null ? Number(target.profit_rr) : null;
if (rr != null && rr > 0) {
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} 盈亏比 ${esc(fmt(rr, 2))}</td>`;
}
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(side)} ${esc(px)}</td>`;
}
return `<td class="hub-opt-target-cell is-on" title="目标监控">${esc(side)} ${esc(px)}</td>`;
const parts = [];
const hasIndex =
target &&
target.exit_mode !== "profit_exit" &&
target.target_index != null &&
Number(target.target_index) > 0;
if (hasIndex) {
const side = String(target.opt_type || (pos && pos.opt_type) || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
parts.push(side + " " + fmt(target.target_index, 1));
}
const peOn =
!!(pos && pos.profit_exit_enabled) ||
!!(target && (target.exit_mode === "profit_exit" || target.profit_exit_enabled));
if (peOn) {
const mult =
pos && pos.profit_exit_mult != null
? pos.profit_exit_mult
: target && target.profit_exit_mult != null
? target.profit_exit_mult
: 1;
parts.push(formatProfitExitMultLabel(mult));
}
if (!parts.length) return "<td>—</td>";
return `<td class="hub-opt-target-cell is-on" title="目标监控">${esc(parts.join(" · "))}</td>`;
}
function renderOptionsPositionsTable(pos, targets) {
@@ -3960,13 +4148,16 @@
}
const target = findOptionsTargetForInst(targets, p.inst_id);
html += `<tr>
<td><code class="hub-options-inst" title="${esc(p.inst_id || "")}">${esc(shortOptionsInst(p.inst_id))}</code></td>
<td><code class="hub-options-inst" title="${esc(p.inst_id || "")}">${esc(shortOptionsInst(p.inst_id))}${
p.margin_mode_label || p.margin_mode === "coin" ? ` <span class="hub-opt-mode">${esc(p.margin_mode_label || "币本位")}</span>` : ""
}</code></td>
<td>${esc(optType)}</td>
<td>${esc(p.pos)}</td>
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
${renderOptionsTargetCell(target)}`;
${renderOptionsTargetCell(target, p)}`;
if (showPnl) {
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmt(net, 2)}</td>
const premCcy = optPremiumCcyOf(p);
html += `<td class="${pnlCls(net)}">${net == null ? "—" : fmtOptPnlText(net, premCcy, spotPxFromOptMeta(null, p))}</td>
<td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
}
html += "</tr>";
@@ -4023,7 +4214,14 @@
const pos = Array.isArray(opt.positions) ? opt.positions : [];
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
html += renderOptionsAccountStatRow(opt);
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓</div>`;
const modeLab = esc(opt.options_margin_mode_label || (opt.options_margin_mode === "coin" ? "币本位" : "USDC"));
const bridgeHint =
opt.bridge_status === "pending_sell_spot"
? " · 待卖回USDT"
: opt.bridge_status
? ` · 桥:${esc(opt.bridge_status)}`
: "";
html += `<div class="section-title hub-options-title">期权持仓 · ${pos.length} 仓 · ${modeLab}${bridgeHint}</div>`;
html +=
layout === "cards"
? renderOptionsPositionsCards(pos)
@@ -4444,6 +4642,8 @@
let optLine = "";
let pnlShow = upnl;
let pnlSuffix = "";
let pnlUnit = "U";
let pnlSpotPx = null;
if (hasOptCap) {
if (opt.enabled === false) {
optLine = "期权未启用";
@@ -4456,17 +4656,29 @@
const n = Number.isFinite(optCount) ? optCount : 0;
const bal = optionsBalanceFields(opt);
const optUpl = bal.upl != null ? bal.upl : null;
const optCcy = optionsPanelCcy(opt);
const parts = [n > 0 ? `期权 ${n}` : "期权 空仓"];
if (showAccountPnlPref()) {
if (bal.funding != null) parts.push(`资金 ${fmt(bal.funding, 2)}U`);
if (bal.trading != null) parts.push(`交易 ${fmt(bal.trading, 2)}U`);
if (opt.options_margin_mode === "coin") {
const coinTrade = formatCoinTradingLabel(
bal.trading != null ? bal.trading : opt.trading_usdt,
(opt.balances || {}).trading_eth,
(opt.balances || {}).trading_btc
);
if (coinTrade && coinTrade !== "—") parts.push(`交易 ${coinTrade}`);
} else if (bal.trading != null) {
parts.push(`交易 ${fmt(bal.trading, 2)}U`);
}
if (optUpl != null && Number.isFinite(Number(optUpl))) {
parts.push(`浮盈 ${fmt(optUpl, 2)}U`);
parts.push(`浮盈 ${fmtOptPnlUsdtOnly(optUpl, optCcy, spotPxFromOptMeta(opt))}`);
}
if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) {
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U
// 永续空仓时主数字优先展示期权浮盈(只显示 U)
pnlShow = optUpl;
pnlSuffix = "期权";
pnlUnit = optCcy;
pnlSpotPx = spotPxFromOptMeta(opt);
}
}
optLine = parts.join(" · ");
@@ -4475,6 +4687,10 @@
const hm = row.hub_monitor || {};
const flaskOk = row.flask_ok !== false && hm.ok !== false;
const strategyStats = renderCardStrategyStats(row, hm, flaskOk);
const tilePnlHtml =
pnlUnit === "ETH" || pnlUnit === "BTC"
? `${fmtOptPnlUsdtOnly(pnlShow, pnlUnit, pnlSpotPx)} <small>${pnlSuffix ? esc(pnlSuffix) : ""}</small>`
: `${fmt(pnlShow, 2)} <small>U${pnlSuffix ? " · " + esc(pnlSuffix) : ""}</small>`;
return `<div class="card hub-tile ${tileCls}" data-ex-id="${esc(row.id)}">
<div class="hub-tile-body card-expand-zone" title="点击进入全屏详情">
<div class="hub-tile-top">
@@ -4484,9 +4700,7 @@
</div>
${
showAccountPnlPref()
? `<div class="hub-tile-pnl ${pnlCls(pnlShow)}">${fmt(pnlShow, 2)} <small>U${
pnlSuffix ? " · " + pnlSuffix : ""
}</small></div>`
? `<div class="hub-tile-pnl ${pnlCls(pnlShow)}">${tilePnlHtml}</div>`
: ""
}
<div class="hub-tile-meta">${esc(posLine)}</div>
+38 -1
View File
@@ -40,6 +40,35 @@
return `${n > 0 ? "+" : "-"}${abs}U`;
}
function optPremiumCcyOf(p) {
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
if (ccy) return ccy;
const mode = String((p && p.margin_mode) || "").toLowerCase();
const inst = String((p && p.inst_id) || "");
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
}
return "USDC";
}
function pnlSignedOpt(v, ccy, spotPx) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const unit = String(ccy || "USDC").toUpperCase();
if (unit === "ETH" || unit === "BTC") {
const abs = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
const sign = n > 0 ? "+" : n < 0 ? "-" : "";
const coinTxt = `${sign}${abs} ${unit}`;
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
const u = n * px;
const uAbs = Math.abs(u).toFixed(2);
const uSign = u < 0 ? "-" : u > 0 ? "+" : "";
return `${coinTxt} / ${uSign}${uAbs}U`;
}
return pnlSigned(n, 2);
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
@@ -338,7 +367,15 @@
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
<td><span class="${targetCls}">${esc(target)}</span></td>`;
if (showPnl) {
html += `<td class="${pnlClass(net)}">${net != null ? pnlSigned(net, 2) : "—"}</td>
html += `<td class="${pnlClass(net)}">${
net != null
? pnlSignedOpt(
net,
optPremiumCcyOf(p),
Number(p.idx_px != null ? p.idx_px : p.idxPx) || null
)
: "—"
}</td>
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>`;
}
html += "</tr>";
+1 -1
View File
@@ -1767,6 +1767,6 @@
<script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=4"></script>
<script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260807-opt-float"></script>
<script src="/assets/app.js?v=20260812-profit-exit"></script>
</body>
</html>
+1 -1
View File
@@ -146,7 +146,7 @@
return;
}
if (r.status === 403) {
showErr("访问被拒绝(403):云端 hub 需设置 HUB_ALLOW_PUBLIC=true");
showErr("访问被拒绝(403):请确认 HUB_ALLOW_PUBLIC 未设为 false,并检查反代/登录配置");
} else {
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")");
}
+1 -1
View File
@@ -20,7 +20,7 @@
1. `hub.py` 启动后 `dashboard_store`**60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照.
2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`).
3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**.
4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步.
4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板;常规轮询二者各自按间隔跑,避免连锁打满 CPU.
5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合.
可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔).
+36
View File
@@ -0,0 +1,36 @@
import unittest
from lib.exchange.api_credentials_lib import (
credentials_configured,
is_exchange_auth_error,
normalize_api_credential,
)
class TestNormalizeApiCredential(unittest.TestCase):
def test_empty_and_placeholder(self):
self.assertEqual(normalize_api_credential(""), "")
self.assertEqual(normalize_api_credential(None), "")
self.assertEqual(normalize_api_credential(" "), "")
self.assertEqual(normalize_api_credential("REPLACE_WITH_GATE_API_KEY"), "")
self.assertEqual(normalize_api_credential("CHANGE_TO_LONG_RANDOM_SECRET"), "")
self.assertEqual(normalize_api_credential("你的密钥"), "")
def test_real_key_kept(self):
self.assertEqual(normalize_api_credential(" real-key-value "), "real-key-value")
self.assertTrue(credentials_configured("abc", "def"))
self.assertFalse(credentials_configured("REPLACE_WITH_X", "secret"))
class TestAuthErrorDetect(unittest.TestCase):
def test_binance_invalid_key(self):
class AuthenticationError(Exception):
pass
self.assertTrue(is_exchange_auth_error(AuthenticationError('binance {"code":-2008,"msg":"Invalid Api-Key ID."}')))
self.assertTrue(is_exchange_auth_error(Exception("gate INVALID_KEY")))
self.assertFalse(is_exchange_auth_error(Exception("rate limit exceeded")))
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
"""Gate 持仓指标:全仓保证金不得误用 unrealised_pnl."""
from __future__ import annotations
import unittest
class TestGatePositionMetrics(unittest.TestCase):
def test_cross_margin_not_equal_unrealised_pnl(self):
from crypto_monitor_gate.app import parse_ccxt_position_metrics
pos = {
"side": "long",
"contracts": 400,
"collateral": 21.19,
"initialMargin": None,
"notional": 3098.54,
"unrealizedPnl": 21.19,
"markPrice": 77463.5,
"leverage": 0,
"marginMode": "cross",
"symbol": "BTC/USDT:USDT",
"info": {
"value": "3098.54",
"leverage": "0",
"cross_leverage_limit": "20",
"margin": "21.19",
"unrealised_pnl": "21.19",
"mark_price": "77463.5",
},
}
out = parse_ccxt_position_metrics(pos, order_leverage=20)
self.assertIsNotNone(out)
self.assertAlmostEqual(out["unrealized_pnl"], 21.19)
self.assertGreater(out["initial_margin"], 150)
self.assertLess(out["initial_margin"], 160)
pct = out["unrealized_pnl"] / out["initial_margin"] * 100
self.assertGreater(pct, 12)
self.assertLess(pct, 16)
def test_cross_margin_trusts_api_when_sane(self):
from crypto_monitor_gate.app import parse_ccxt_position_metrics
pos = {
"side": "long",
"contracts": 1,
"collateral": 157.03,
"notional": 3098.54,
"unrealizedPnl": 21.19,
"leverage": 0,
"marginMode": "cross",
"info": {
"value": "3098.54",
"leverage": "0",
"cross_leverage_limit": "20",
"margin": "157.03",
"unrealised_pnl": "21.19",
},
}
out = parse_ccxt_position_metrics(pos, order_leverage=20)
self.assertIsNotNone(out)
self.assertAlmostEqual(out["initial_margin"], 157.03)
def test_isolated_uses_api_margin(self):
from crypto_monitor_gate.app import parse_ccxt_position_metrics
pos = {
"side": "long",
"contracts": 10,
"collateral": 88.5,
"notional": 885.0,
"unrealizedPnl": 3.2,
"leverage": 10,
"marginMode": "isolated",
"info": {"value": "885", "leverage": "10", "margin": "88.5", "unrealised_pnl": "3.2"},
}
out = parse_ccxt_position_metrics(pos, order_leverage=10)
self.assertIsNotNone(out)
self.assertAlmostEqual(out["initial_margin"], 88.5)
if __name__ == "__main__":
unittest.main()
+11 -8
View File
@@ -102,20 +102,23 @@ class TestHedgePlanCalc(unittest.TestCase):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
p = build_options_options_preview(
target_price_up=3500,
target_price_down=3000,
profit_rr=2,
index_px=3200,
leg_a=a,
leg_b=b,
)
self.assertEqual(p["summary"]["premium_paid"], 10)
self.assertTrue(p["summary"]["expiry_is_loss"])
self.assertEqual(p["summary"]["rr_risk_premium"], 10)
self.assertIsNotNone(p["summary"]["rr_at_up"])
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4)
self.assertEqual(len(p["scenarios"]), 4)
self.assertEqual(p["scenarios"][0]["id"], "target_up")
self.assertEqual(p["scenarios"][1]["id"], "target_down")
self.assertEqual(p["summary"]["profit_rr"], 2)
self.assertEqual(p["summary"]["at_rr_a_full_total"], 15) # 盈利=2*10, 亏腿-5
self.assertEqual(len(p["scenarios"]), 5)
self.assertEqual(p["scenarios"][0]["id"], "rr_leg_a_full")
self.assertEqual(p["scenarios"][1]["id"], "rr_leg_b_full")
# 到期实值反推:Call 盈利20 → 价值25 → 每币2500 → spot=3300+2500
self.assertEqual(p["scenarios"][0]["spot"], 5800.0)
# Put 盈利20 → spot=3100-2500
self.assertEqual(p["scenarios"][1]["spot"], 600.0)
self.assertEqual(p["scenarios"][2]["spot"], 5800.0) # 残值情景同腿A反推
def test_oo_legacy_single_target_still_works(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
+26
View File
@@ -155,6 +155,32 @@ class TestHedgeHistoryStats(unittest.TestCase):
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
def test_active_options_targets_profit_rr(self):
conn = _mem()
pid = insert_plan(
conn,
{
"plan_type": "options_options",
"status": "active",
"underlying": "ETH",
"profit_rr": 2,
},
)
insert_leg(
conn,
{
"plan_id": pid,
"leg_role": "option_a",
"inst_id": "ETH-USD_UM-260719-1890-C",
"opt_type": "C",
"status": "open",
},
)
targets = active_options_targets_by_inst(conn)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["profit_rr"], 2)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["exit_mode"], "profit_rr")
self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"])
if __name__ == "__main__":
unittest.main()
+1 -2
View File
@@ -104,8 +104,7 @@ class TestHedgeMoneyness(unittest.TestCase):
err = validate_start_body(
"options_options",
{
"target_price_up": 1900,
"target_price_down": 1700,
"profit_rr": 2,
"index_px": 1800,
"leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700},
"leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900},
+1 -3
View File
@@ -169,9 +169,7 @@ class TestHedgePlanOrderPath(unittest.TestCase):
"budget_buffer": 0.95,
}
body = {
"target_price": 1900,
"target_price_up": 1950,
"target_price_down": 1750,
"profit_rr": 2,
"oo_sheets_mode": "same_sheets",
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
+28 -1
View File
@@ -66,6 +66,33 @@ class TestHubMonitorTotals(unittest.TestCase):
self.assertEqual(out["options_float_pnl_u"], 1.5)
self.assertEqual(out["float_pnl_u"], 1.5)
def test_aggregate_monitor_board_totals_coin_options_to_usdt(self):
rows = [
{
"capabilities": ["options"],
"options": {
"ok": True,
"enabled": True,
"options_margin_mode": "coin",
"options_index_px": 2000.0,
"positions": [
{
"inst_id": "ETH-USD-260822-2250-C",
"margin_mode": "coin",
"premium_ccy": "ETH",
"upl": 0.002,
"idx_px": 2000.0,
}
],
"upl_total_usdc": 0.002,
},
"agent": {"positions": [], "total_unrealized_pnl": 10.0},
}
]
out = aggregate_monitor_board_totals(rows, trading_day="2026-08-20", reset_hour=8)
self.assertEqual(out["options_float_pnl_u"], 4.0)
self.assertEqual(out["float_pnl_u"], 14.0)
def test_aggregate_excludes_option_like_agent_positions(self):
"""子代理误把期权当永续上报时:不算进持仓数,浮盈只用期权 snap."""
rows = [
@@ -74,7 +101,7 @@ class TestHubMonitorTotals(unittest.TestCase):
"options": {
"ok": True,
"enabled": True,
"positions": [{"inst_id": "ETH-USD-260806-1875-C"}],
"positions": [{"inst_id": "ETH-USD_UM-260806-1875-C"}],
"upl_total_usdc": -0.4,
},
"agent": {
+22
View File
@@ -63,6 +63,28 @@ class HubOptionsFundsLibTests(TestCase):
self.assertEqual(out["options_open_position_count"], 1)
self.assertEqual(out["options_float_pnl_u"], 0.5)
def test_options_float_pnl_usdt_coin_converts_by_index(self):
from lib.hub.hub_options_funds_lib import options_float_pnl_usdt
snap = {
"ok": True,
"enabled": True,
"options_margin_mode": "coin",
"options_index_px": 2280.0,
"upl_total_usdc": 0.0016,
"positions": [
{
"inst_id": "ETH-USD-260822-2250-C",
"margin_mode": "coin",
"premium_ccy": "ETH",
"upl": 0.0016,
"idx_px": 2280.0,
}
],
}
out = options_float_pnl_usdt(snap)
self.assertAlmostEqual(out, round(0.0016 * 2280.0, 4), places=4)
def test_repair_double_counted_fund_entry(self):
raw = {
"funding_usdt": 586.82,
+51
View File
@@ -0,0 +1,51 @@
import asyncio
import time
import unittest
from lib.hub.hub_poll_wait_lib import wait_poll_interval
class TestWaitPollInterval(unittest.IsolatedAsyncioTestCase):
async def test_ignores_refresh_storm_until_interval(self):
refresh = asyncio.Event()
stop = asyncio.Event()
started = time.monotonic()
async def storm():
for _ in range(30):
refresh.set()
await asyncio.sleep(0.01)
t = asyncio.create_task(storm())
await wait_poll_interval(
refresh=refresh,
stop=stop,
interval_sec=0.25,
started_at=started,
min_early_wake_sec=0.2,
)
t.cancel()
elapsed = time.monotonic() - started
self.assertGreaterEqual(elapsed, 0.18)
async def test_stop_ends_early(self):
refresh = asyncio.Event()
stop = asyncio.Event()
started = time.monotonic()
async def stopper():
await asyncio.sleep(0.05)
stop.set()
asyncio.create_task(stopper())
await wait_poll_interval(
refresh=refresh,
stop=stop,
interval_sec=2.0,
started_at=started,
)
self.assertLess(time.monotonic() - started, 0.5)
if __name__ == "__main__":
unittest.main()
+21 -3
View File
@@ -91,6 +91,7 @@ class TestEnvSchema(unittest.TestCase):
"OKX_POS_MODE",
"POSITION_SIZING_MODE",
"TRADE_DIRECTION",
"OKX_OPTIONS_MARGIN_MODE",
):
self.assertIn(key, SELECT_OPTIONS)
@@ -101,9 +102,19 @@ class TestEnvSchema(unittest.TestCase):
self.skipTest("missing okx .env.example")
groups = build_env_ui_payload("okx", example, env_path if os.path.isfile(env_path) else example)
by_key = {f["key"]: f for g in groups for f in g["fields"]}
for key in ("OKX_TD_MODE", "OKX_POS_MODE", "POSITION_SIZING_MODE", "TRADE_DIRECTION"):
for key in (
"OKX_TD_MODE",
"OKX_POS_MODE",
"POSITION_SIZING_MODE",
"TRADE_DIRECTION",
"OKX_OPTIONS_MARGIN_MODE",
):
self.assertEqual(by_key[key]["type"], "select")
self.assertTrue(by_key[key]["options"])
self.assertEqual(
{o["value"] for o in by_key["OKX_OPTIONS_MARGIN_MODE"]["options"]},
{"usdc", "coin"},
)
self.assertIn("KEY_AUTO_ORDER_ENABLED", by_key)
self.assertEqual(by_key["KEY_AUTO_ORDER_ENABLED"]["label"], "关键位自动单")
self.assertEqual(by_key["KEY_AUTO_ORDER_ENABLED"]["type"], "bool")
@@ -114,7 +125,10 @@ class TestEnvSchema(unittest.TestCase):
self.assertTrue(by_key["OKX_SHOW_PERP_FUNDS"].get("hot_reload"))
self.assertNotIn("OKX_OPTIONS_API_KEY", by_key)
self.assertNotIn("OKX_SUB_ACCOUNT_NAME", by_key)
self.assertEqual(by_key["OKX_API_KEY"]["note"], "账户 API(永续+期权共用)")
self.assertNotIn("OKX_API_KEY", by_key)
self.assertNotIn("OKX_API_SECRET", by_key)
self.assertNotIn("OKX_API_PASSPHRASE", by_key)
self.assertIn("LIVE_TRADING_ENABLED", by_key)
groups_v = [{"title": "t", "fields": [by_key["TRADE_DIRECTION"]]}]
clean, errors = validate_env_updates(groups_v, {"TRADE_DIRECTION": "long_only"})
@@ -141,11 +155,15 @@ class TestShowPerpFunds(unittest.TestCase):
os.environ["OKX_SHOW_PERP_FUNDS"] = old
def test_options_funding_label_usdc_only(self):
from lib.instance.instance_embed_context_lib import options_funding_label
from lib.instance.instance_embed_context_lib import options_funding_label, trading_account_label
self.assertEqual(options_funding_label(12.5, 99.0), "12.50 USDC")
self.assertEqual(options_funding_label(0.0, 50.0), "0.00 USDC")
self.assertEqual(options_funding_label(None, 10.0), "")
self.assertEqual(
trading_account_label(20.0, 0.01, 0.001, margin_mode="coin"),
"20.00 USDT\n0.01 ETH\n0.001 BTC",
)
if __name__ == "__main__":
+21 -3
View File
@@ -27,18 +27,36 @@ class TestHeaderStatsLib(unittest.TestCase):
def test_total_funds_usdt(self):
self.assertEqual(total_funds_usdt(100.5, 59.27), 159.77)
self.assertIsNone(total_funds_usdt(None, 10))
self.assertEqual(total_funds_usdt(None, 10), 10.0)
self.assertIsNone(total_funds_usdt(None, None))
self.assertEqual(
total_funds_usdt(100, 50, options_trading_usdc=0.2, options_trading_usdt=10),
160.2,
)
def test_options_funding_label(self):
self.assertEqual(options_funding_label(1.5, 10), "1.50 USDC · 10.00 USDT")
self.assertEqual(options_funding_label(1.5, 10), "1.50 USDC")
self.assertEqual(options_funding_label(10.19, 0), "10.19 USDC")
self.assertEqual(options_funding_label(None, 10), "10.00 USDT")
self.assertEqual(options_funding_label(None, 10), "")
self.assertEqual(options_funding_label(None, None), "")
def test_trading_account_label_coin(self):
from lib.instance.instance_embed_context_lib import trading_account_label
self.assertEqual(trading_account_label(100, None, None, margin_mode="usdc"), "100.00U")
self.assertEqual(
trading_account_label(100, 0.2, 0.001, margin_mode="coin"),
"100.00 USDT\n0.2 ETH\n0.001 BTC",
)
self.assertEqual(
trading_account_label(0.02, 0.0, None, margin_mode="coin"),
"0.02 USDT",
)
self.assertEqual(
trading_account_label(12.5, 0.004321, None, margin_mode="coin"),
"12.50 USDT\n0.004321 ETH",
)
if __name__ == "__main__":
unittest.main()
+16 -1
View File
@@ -37,9 +37,24 @@ class TestOkxSpotSwap(unittest.TestCase):
)
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20)
self.assertFalse(result["ok"])
self.assertEqual(result["msg"], "资金账户 USDT 可用余额不足")
self.assertEqual(result["msg"], "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)")
self.assertNotIn("{", result["msg"])
def test_insufficient_usdc_message(self):
from lib.exchange.okx_options_lib import _okx_trade_error_message
msg = _okx_trade_error_message(
resp={
"data": [
{
"sCode": "51008",
"sMsg": "Order failed. Insufficient USDC balance in account.",
}
]
}
)
self.assertEqual(msg, "交易账户 USDC 可用余额不足")
if __name__ == "__main__":
unittest.main()
+92 -10
View File
@@ -1,16 +1,98 @@
"""按可用余额打满:min(余额, 单笔预算)."""
"""按可用余额打满 / 全仓复利定仓."""
from __future__ import annotations
from lib.options.options_pricing_lib import resolve_budget_full_usdc
import unittest
from lib.options.options_pricing_lib import (
resolve_budget_full_usdc,
resolve_compound_full_usdc,
)
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
count_live_option_positions,
)
def test_balance_above_budget_uses_budget():
assert resolve_budget_full_usdc(100.0, 10.0) == 10.0
class TestOptionsBudgetModes(unittest.TestCase):
def test_balance_above_budget_uses_budget(self):
self.assertEqual(resolve_budget_full_usdc(100.0, 10.0), 10.0)
def test_balance_below_budget_uses_balance(self):
self.assertEqual(resolve_budget_full_usdc(5.0, 10.0), 5.0)
def test_balance_equals_budget(self):
self.assertEqual(resolve_budget_full_usdc(10.0, 10.0), 10.0)
def test_compound_full_no_cap_uses_all(self):
self.assertEqual(
resolve_compound_full_usdc(200.0, cap_enabled=False, cap_usdc=50.0),
200.0,
)
def test_compound_full_cap_on(self):
self.assertEqual(
resolve_compound_full_usdc(200.0, cap_enabled=True, cap_usdc=50.0),
50.0,
)
self.assertEqual(
resolve_compound_full_usdc(30.0, cap_enabled=True, cap_usdc=50.0),
30.0,
)
def test_compound_full_cap_invalid_falls_back_to_balance(self):
self.assertEqual(
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=0),
80.0,
)
self.assertEqual(
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=None),
80.0,
)
def test_compound_full_blocks_when_position_open(self):
rows = [{"instId": "ETH-USD_UM-260812-1870-P", "pos": "1"}]
msg = compound_full_single_position_block_msg(
object(), fetch_positions=lambda _ex: rows
)
self.assertIsNotNone(msg)
self.assertIn("1 笔", msg or "")
def test_compound_full_allows_when_flat(self):
msg = compound_full_single_position_block_msg(
object(), fetch_positions=lambda _ex: []
)
self.assertIsNone(msg)
self.assertEqual(count_live_option_positions([]), 0)
def test_normalize_size_mode_when_compound_off(self):
import os
from unittest.mock import patch
from lib.options import options_register as reg
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "false"}):
mode, note = reg._normalize_size_mode("compound_full")
self.assertEqual(mode, "sheets")
self.assertIsNotNone(note)
mode2, note2 = reg._normalize_size_mode("budget_full")
self.assertEqual(mode2, "budget_full")
self.assertIsNone(note2)
mode3, _ = reg._normalize_size_mode("sheets")
self.assertEqual(mode3, "sheets")
def test_normalize_size_mode_when_compound_on(self):
import os
from unittest.mock import patch
from lib.options import options_register as reg
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "true"}):
mode, note = reg._normalize_size_mode("budget_full")
self.assertEqual(mode, "compound_full")
self.assertIsNone(note)
mode2, _ = reg._normalize_size_mode("compound_full")
self.assertEqual(mode2, "compound_full")
def test_balance_below_budget_uses_balance():
assert resolve_budget_full_usdc(5.0, 10.0) == 5.0
def test_balance_equals_budget():
assert resolve_budget_full_usdc(10.0, 10.0) == 10.0
if __name__ == "__main__":
unittest.main()
+142 -15
View File
@@ -1,6 +1,7 @@
"""期权平仓门控:可回收≥2×权利金且持续持有."""
"""期权平仓门控:USDT 口径(权利金×倍数 / 净盈亏阈值)且持续持有."""
from __future__ import annotations
import os
import unittest
from lib.options.options_close_gate_lib import (
@@ -14,43 +15,169 @@ from lib.options.options_close_gate_lib import (
class OptionsCloseGateTests(unittest.TestCase):
def setUp(self):
clear_close_gate()
self._env_backup = {
k: os.environ.get(k)
for k in (
"OKX_OPTIONS_CLOSE_GATE_MODE",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U",
)
}
for k in self._env_backup:
os.environ.pop(k, None)
def tearDown(self):
clear_close_gate()
for k, v in self._env_backup.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_below_2x_not_ready(self):
g = update_close_gate("ETH-X", recycle_usdc=15.0, premium_paid=10.0, now=1000.0)
def test_usdc_below_2x_not_ready(self):
g = update_close_gate(
"ETH-X",
recycle_usdc=15.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1000.0,
)
self.assertFalse(g["recycle_ok"])
self.assertFalse(g["ready"])
self.assertIn("U(估)", g["msg"])
def test_meets_2x_needs_hold(self):
g1 = update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0)
def test_usdc_meets_2x_needs_hold(self):
g1 = update_close_gate(
"ETH-X",
recycle_usdc=20.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1000.0,
)
self.assertTrue(g1["recycle_ok"])
self.assertFalse(g1["ready"])
self.assertAlmostEqual(g1["remain_seconds"], 120.0)
g2 = update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1120.0)
g2 = update_close_gate(
"ETH-X",
recycle_usdc=21.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1120.0,
)
self.assertTrue(g2["ready"])
self.assertGreaterEqual(g2["held_seconds"], 120.0)
def test_coin_premium_gate_uses_usdt_and_default_105(self):
# 0.0384 ETH * 2500 = 96U; ×1.05 = 100.8U
g = update_close_gate(
"ETH-P",
recycle_usdc=0.036,
premium_paid=0.0384,
premium_ccy="ETH",
index_px=2500.0,
now=1000.0,
)
self.assertFalse(g["recycle_ok"])
self.assertAlmostEqual(g["premium_usdt"], 96.0)
self.assertAlmostEqual(g["need_recycle_usdt"], 100.8)
self.assertIn("U(估)", g["msg"])
g_ok = update_close_gate(
"ETH-P",
recycle_usdc=0.041,
premium_paid=0.0384,
premium_ccy="ETH",
index_px=2500.0,
now=1000.0,
)
self.assertTrue(g_ok["recycle_ok"])
self.assertAlmostEqual(g_ok["recycle_usdt"], 102.5)
def test_net_pnl_gate_mode(self):
os.environ["OKX_OPTIONS_CLOSE_GATE_MODE"] = "net_pnl"
os.environ["OKX_OPTIONS_CLOSE_NET_PNL_MIN_U"] = "1"
g = update_close_gate(
"ETH-N",
recycle_usdc=0.039,
premium_paid=0.0384,
premium_ccy="ETH",
index_px=2500.0,
now=1000.0,
)
self.assertTrue(g["recycle_ok"])
self.assertAlmostEqual(g["net_pnl_usdt"], 1.5)
g2 = update_close_gate(
"ETH-N2",
recycle_usdc=0.0385,
premium_paid=0.0384,
premium_ccy="ETH",
index_px=2500.0,
now=1000.0,
)
self.assertFalse(g2["recycle_ok"])
def test_break_resets_timer(self):
update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0)
update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1100.0)
g_break = update_close_gate("ETH-X", recycle_usdc=12.0, premium_paid=10.0, now=1110.0)
update_close_gate(
"ETH-X",
recycle_usdc=20.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1000.0,
)
update_close_gate(
"ETH-X",
recycle_usdc=21.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1100.0,
)
g_break = update_close_gate(
"ETH-X",
recycle_usdc=12.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1110.0,
)
self.assertFalse(g_break["recycle_ok"])
g_again = update_close_gate("ETH-X", recycle_usdc=22.0, premium_paid=10.0, now=1111.0)
g_again = update_close_gate(
"ETH-X",
recycle_usdc=22.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1111.0,
)
self.assertTrue(g_again["recycle_ok"])
self.assertFalse(g_again["ready"])
self.assertAlmostEqual(g_again["held_seconds"], 0.0)
def test_passed_latches_after_ready(self):
update_close_gate("ETH-Y", recycle_usdc=20.0, premium_paid=10.0, now=1000.0)
g_ready = update_close_gate("ETH-Y", recycle_usdc=21.0, premium_paid=10.0, now=1120.0)
update_close_gate(
"ETH-Y",
recycle_usdc=20.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1000.0,
)
g_ready = update_close_gate(
"ETH-Y",
recycle_usdc=21.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1120.0,
)
self.assertTrue(g_ready["ready"])
self.assertTrue(g_ready["passed"])
self.assertTrue(is_close_gate_passed("ETH-Y"))
# 后续回收跌破 2×:计时重置,但 passed 仍保留供续批只验流动性
g_drop = update_close_gate("ETH-Y", recycle_usdc=5.0, premium_paid=10.0, now=1130.0)
g_drop = update_close_gate(
"ETH-Y",
recycle_usdc=5.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1130.0,
)
self.assertFalse(g_drop["recycle_ok"])
self.assertTrue(g_drop["passed"])
self.assertFalse(g_drop["auto_close_blocked"])
+50
View File
@@ -0,0 +1,50 @@
"""期权历史/复盘币本位金额."""
from __future__ import annotations
import unittest
from lib.exchange.okx_options_lib import format_option_history_row
from lib.options.options_review_lib import convert_option_amounts_to_usdt
class TestOptionsHistoryCoin(unittest.TestCase):
def test_format_option_history_row_coin_premium_fmt(self) -> None:
raw = {
"instId": "ETH-USD-260822-2250-C",
"openAvgPx": "0.02",
"closeAvgPx": "0.03",
"closeTotalPos": "2",
"realizedPnl": "0.002",
"pnlRatio": "0.5",
"type": "2",
"uTime": "1724146497000",
"cTime": "1724126855000",
"posId": "123",
"uly": "ETH-USD",
}
row = format_option_history_row(raw, tick_sz="0.0001", ct_mult=0.1)
self.assertEqual(row["margin_mode"], "coin")
self.assertEqual(row["premium_ccy"], "ETH")
self.assertAlmostEqual(float(row["premium_paid"]), 0.004, places=6)
self.assertNotEqual(row["premium_paid_fmt"], "0.00")
self.assertIn("0.004", str(row["premium_paid_fmt"]))
def test_convert_option_amounts_to_usdt(self) -> None:
out = convert_option_amounts_to_usdt(
{
"inst_id": "ETH-USD-260822-2250-C",
"premium_ccy": "ETH",
"margin_mode": "coin",
"premium_paid": 0.004,
"realized_pnl": 0.002,
},
index_px=2000.0,
)
self.assertEqual(out["pnl_quote_ccy"], "USDT")
self.assertEqual(out["premium_paid"], 8.0)
self.assertEqual(out["realized_pnl"], 4.0)
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
"""币本位模式预算与合约族单测."""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
class TestOptionsMarginMode(unittest.TestCase):
def test_normalize_mode(self):
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
self.assertEqual(normalize_options_margin_mode("usdc"), "usdc")
self.assertEqual(normalize_options_margin_mode("coin"), "coin")
self.assertEqual(normalize_options_margin_mode("币本位"), "coin")
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("OKX_OPTIONS_MARGIN_MODE", None)
self.assertEqual(normalize_options_margin_mode(None), "coin")
self.assertEqual(normalize_options_margin_mode(""), "coin")
def test_inst_family(self):
from lib.options.options_margin_mode_lib import inst_family_for_underlying
self.assertEqual(inst_family_for_underlying("ETH", margin_mode="usdc"), "ETH-USD_UM")
self.assertEqual(inst_family_for_underlying("ETH", margin_mode="coin"), "ETH-USD")
def test_margin_mode_from_inst_id(self):
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
self.assertEqual(margin_mode_from_inst_id("ETH-USD_UM-260701-2500-C"), "usdc")
self.assertEqual(margin_mode_from_inst_id("ETH-USD-260701-2500-C"), "coin")
def test_coin_budget_compound(self):
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
with patch.dict(os.environ, {}, clear=False):
r = compute_coin_budget_usdt(
20.0,
compound=True,
buffer=0.95,
max_enabled=False,
)
self.assertTrue(r["ok"])
self.assertAlmostEqual(r["budget_usdt"], 19.0, places=6)
def test_coin_budget_max_cap(self):
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
r = compute_coin_budget_usdt(
100.0,
compound=True,
buffer=0.95,
max_enabled=True,
max_usdt=50.0,
)
self.assertTrue(r["ok"])
self.assertAlmostEqual(r["budget_usdt"], 50.0, places=6)
self.assertTrue(r["capped_by_max"])
def test_coin_budget_fixed(self):
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
r = compute_coin_budget_usdt(
100.0,
compound=False,
buffer=0.95,
fixed_budget_usdt=10.0,
max_enabled=False,
)
self.assertAlmostEqual(r["budget_usdt"], 9.5, places=6)
def test_sheets_from_coin(self):
from lib.options.options_margin_mode_lib import calc_sheets_from_coin_balance
# ask 0.01 ETH per 1 ETH, ctMult 0.01 → 每张 0.0001 ETH; 0.01 ETH×0.97 缓冲可开 97 张
r = calc_sheets_from_coin_balance(
quote_per_unit=0.01,
ct_mult=0.01,
min_sz=1,
coin_available=0.01,
)
self.assertTrue(r["ok"])
self.assertEqual(r["sheets"], 97)
def test_spot_buy_buffer_normalize(self):
from lib.options.options_margin_mode_lib import normalize_coin_spot_buy_buffer
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.10), 1.10)
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(0.10), 1.10)
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.25), 1.25)
def test_plan_coin_open_by_budget(self):
from lib.options.options_margin_mode_lib import plan_coin_open_by_budget
# ask 0.01, ct 0.1 → 单张权利金 0.001 ETH;×1.1=0.0011;×指数 2000 → 2.2 USDT/张
r = plan_coin_open_by_budget(
quote_per_unit=0.01,
ct_mult=0.1,
min_sz=1,
budget_usdt=10.0,
index_px=2000.0,
ask_sz=100,
spot_buy_buffer=1.10,
)
self.assertTrue(r["ok"], r.get("msg"))
self.assertEqual(r["sheets"], 4) # floor(10/2.2)=4
self.assertAlmostEqual(r["buy_usdt"], 4 * 0.01 * 0.1 * 1.10 * 2000, places=4)
self.assertLess(r["buy_usdt"], 10.0)
one = plan_coin_open_by_budget(
quote_per_unit=0.01,
ct_mult=0.1,
min_sz=1,
budget_usdt=10.0,
index_px=2000.0,
ask_sz=100,
spot_buy_buffer=1.10,
target_sheets=1,
)
self.assertTrue(one["ok"], one.get("msg"))
self.assertEqual(one["sheets"], 1)
self.assertAlmostEqual(one["buy_usdt"], 0.01 * 0.1 * 1.10 * 2000, places=4)
if __name__ == "__main__":
unittest.main()
+42 -4
View File
@@ -11,10 +11,10 @@ from lib.options.options_notify_lib import (
class TestOptionsNotify(unittest.TestCase):
def test_open_close_messages(self) -> None:
def test_open_close_messages_usdc(self) -> None:
open_msg = build_options_open_message(
account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C",
inst_id="ETH-USD_UM-250725-3200-C",
underlying="ETH",
opt_type="C",
sheets=2,
@@ -23,14 +23,17 @@ class TestOptionsNotify(unittest.TestCase):
target_index=3400,
signal_note="假突破",
trade_id=12,
premium_ccy="USDC",
margin_mode="usdc",
)
self.assertIn("【OKX期权·开仓】", open_msg)
self.assertIn("ETH-USD-250725-3200-C", open_msg)
self.assertIn("ETH-USD_UM-250725-3200-C", open_msg)
self.assertIn("目标指数:3400", open_msg)
self.assertIn("USDC", open_msg)
close_msg = build_options_close_message(
account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C",
inst_id="ETH-USD_UM-250725-3200-C",
reason="手动平仓",
underlying="ETH",
opt_type="C",
@@ -38,10 +41,45 @@ class TestOptionsNotify(unittest.TestCase):
premium_paid=8.5,
premium_received=12.0,
realized_pnl=3.5,
premium_ccy="USDC",
)
self.assertIn("【OKX期权·平仓】", close_msg)
self.assertIn("手动平仓", close_msg)
self.assertIn("3.5000", close_msg)
self.assertIn("USDC", close_msg)
def test_open_close_messages_coin(self) -> None:
open_msg = build_options_open_message(
account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C",
underlying="ETH",
opt_type="C",
sheets=1,
premium_paid=0.001234,
open_quote=0.01234,
trade_id=99,
premium_ccy="ETH",
margin_mode="coin",
)
self.assertIn("本位:币本位", open_msg)
self.assertIn("ETH", open_msg)
self.assertNotIn("USDC", open_msg)
close_msg = build_options_close_message(
account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C",
reason="翻倍出场(1倍)",
underlying="ETH",
sheets=1,
premium_paid=0.001234,
premium_received=0.0025,
realized_pnl=0.001266,
premium_ccy="ETH",
margin_mode="coin",
)
self.assertIn("本位:币本位", close_msg)
self.assertIn("翻倍出场", close_msg)
self.assertIn("ETH", close_msg)
if __name__ == "__main__":
+68 -1
View File
@@ -267,6 +267,35 @@ def test_stub_bid_blocks_auto_close_estimate():
assert good["covered_sheets"] == 10
def test_intrinsic_px_coin_vs_usdc_units():
from lib.options.options_pricing_lib import intrinsic_px_per_unit, is_stub_bid_px
# USDC / 默认:美元点差
assert intrinsic_px_per_unit("C", 2250, 2274) == 24.0
assert intrinsic_px_per_unit("C", 2250, 2274, margin_mode="usdc") == 24.0
# 币本位:与盘口同单位的币报价 (S−K)/S
coin_iv = intrinsic_px_per_unit("C", 2250, 2274, quote_in_coin=True)
assert coin_iv is not None
assert abs(coin_iv - 24.0 / 2274.0) < 1e-12
assert abs(
intrinsic_px_per_unit("C", 2250, 2274, inst_id="ETH-USD-260822-2250-C") - 24.0 / 2274.0
) < 1e-12
# USD_UM 仍为点差
assert intrinsic_px_per_unit("C", 2250, 2274, inst_id="ETH-USD_UM-260822-2250-C") == 24.0
# 复现线上误杀:把点差当内在价值会把正常买一判残档
wrong_stub, _ = is_stub_bid_px(0.023, mark_px=0.0241, intrinsic_px=23.58)
assert wrong_stub is True
# 币报价内在价值后,买一贴近标记价应有效
ok_stub, _ = is_stub_bid_px(0.023, mark_px=0.0241, intrinsic_px=coin_iv)
assert ok_stub is False
put_iv = intrinsic_px_per_unit("P", 2300, 2274, quote_in_coin=True)
assert put_iv is not None
assert abs(put_iv - 26.0 / 2274.0) < 1e-12
def test_expiry_breakeven_from_ask():
from lib.options.options_pricing_lib import expiry_breakeven_from_ask
@@ -302,6 +331,44 @@ def test_expiry_breakeven_call_put():
assert expiry_breakeven_px(opt_type="P", strike=3500, avg_px=15.6) == 3484.4
def test_expiry_breakeven_coin_margin():
from lib.options.options_pricing_lib import expiry_breakeven_from_ask, expiry_breakeven_px
# ETH-USD 币本位:卖一 0.0165 → 到期平衡 K/(1-p),非 K+p
assert expiry_breakeven_px(
opt_type="C", strike=2390, avg_px=0.0165, margin_mode="coin"
) == round(2390 / (1 - 0.0165), 2)
assert expiry_breakeven_px(
opt_type="P", strike=2450, avg_px=0.0161, margin_mode="coin"
) == round(2450 / (1 + 0.0161), 2)
assert expiry_breakeven_from_ask(
opt_type="C",
strike=2425,
ask_px=0.0187,
inst_id="ETH-USD-260823-2425-C",
) == round(2425 / (1 - 0.0187), 2)
def test_strike_distance_to_be():
from lib.options.options_pricing_lib import strike_distance_to_be
assert strike_distance_to_be(2390, 2430, opt_type="C") == 40.0
assert strike_distance_to_be(2450, 2411, opt_type="P") == 39.0
def test_straddle_breakeven_band_coin():
from lib.options.options_pricing_lib import straddle_breakeven_band
lo, hi = straddle_breakeven_band(
2425,
quote_in_coin=True,
call_ask=0.0187,
put_ask=0.0253,
)
assert lo == round(2425 / (1 + 0.0253), 2)
assert hi == round(2425 / (1 - 0.0187), 2)
def test_close_breakeven_at_mark_equals_avg():
from lib.options.options_pricing_lib import close_breakeven_idx
@@ -394,4 +461,4 @@ def test_format_position_row_breakeven():
assert row["expiry_be_px"] == 3515.6
assert row["idx_px"] == 3480.0
assert row["close_be_px"] is not None
assert row["dist_expiry_be"] == 35.6
assert row["dist_expiry_be"] == 15.6
+65
View File
@@ -0,0 +1,65 @@
"""单独期权翻倍出场命中条件."""
from __future__ import annotations
import sqlite3
import tempfile
import unittest
from pathlib import Path
from lib.options.options_db import init_options_tables
from lib.options.options_profit_exit_lib import (
normalize_profit_exit_mult,
profit_exit_by_inst,
profit_exit_hit,
required_recycle_usdc,
set_profit_exit,
)
class TestOptionsProfitExit(unittest.TestCase):
def test_hit_one_x_means_profit_equals_premium(self):
# 1倍:盈利=权利金 ⇒ 回收≥2×权利金
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=20.0, mult=1.0))
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=19.9, mult=1.0))
self.assertEqual(required_recycle_usdc(10.0, 1.0), 20.0)
def test_hit_two_x(self):
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=30.0, mult=2.0))
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=29.9, mult=2.0))
def test_normalize_mult(self):
self.assertEqual(normalize_profit_exit_mult(None), 1.0)
self.assertEqual(normalize_profit_exit_mult(0), 1.0)
self.assertEqual(normalize_profit_exit_mult("1.5"), 1.5)
def test_set_and_clear(self):
with tempfile.TemporaryDirectory() as td:
db = Path(td) / "t.db"
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
init_options_tables(conn)
conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
VALUES ('ETH-X', 'ETH', 'C', 1, 0.01, 10.0, 'open')
"""
)
conn.commit()
out = set_profit_exit(conn, inst_id="ETH-X", enabled=True, mult=1.5)
self.assertTrue(out["ok"])
conn.commit()
m = profit_exit_by_inst(conn)
self.assertTrue(m["ETH-X"]["profit_exit_enabled"])
self.assertEqual(m["ETH-X"]["profit_exit_mult"], 1.5)
self.assertEqual(m["ETH-X"]["required_recycle"], 25.0)
out2 = set_profit_exit(conn, inst_id="ETH-X", enabled=False, mult=1.5)
self.assertTrue(out2["ok"])
conn.commit()
m2 = profit_exit_by_inst(conn)
self.assertNotIn("ETH-X", m2)
conn.close()
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -84,3 +84,21 @@ class OptionsStatsLibTests(TestCase):
self.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2)
self.assertEqual(out["open_count"], 1)
self.assertAlmostEqual(out["net_realized_pnl"], round(0.87 - 3.99 - 1.33, 4), places=4)
def test_compute_options_stats_coin_to_usdt(self):
history = [
{
"status": "closed",
"realized_pnl": 0.00078,
"premium_ccy": "ETH",
"margin_mode": "coin",
"inst_id": "ETH-USD-260822-2250-C",
"idx_px": 2280,
"created_at": "2026-08-20 08:00:00",
"closed_at": "2026-08-20 13:32:00",
}
]
out = compute_options_stats_from_history(history)
self.assertEqual(out["pnl_unit"], "U")
self.assertEqual(out["total_closed"], 1)
self.assertAlmostEqual(out["net_realized_pnl"], 0.00078 * 2280, places=4)
+26
View File
@@ -88,3 +88,29 @@ def test_badge_parts():
}
)
assert trade_policy_badge_parts(p) == ("仅多", "BTC/ETH")
def test_default_symbol_when_whitelist_sole():
from lib.trade.trade_policy_app_lib import default_symbol_for_policy
p = load_trade_policy(
{
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
"TRADE_SYMBOL_WHITELIST": "BTC",
}
)
assert default_symbol_for_policy(p, "") == "BTC/USDT"
assert default_symbol_for_policy(p, "ETH/USDT") == "BTC/USDT"
def test_default_symbol_when_whitelist_multi():
from lib.trade.trade_policy_app_lib import default_symbol_for_policy
p = load_trade_policy(
{
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
}
)
assert default_symbol_for_policy(p, "ETH") == "ETH/USDT"
assert default_symbol_for_policy(p, "SOL/USDT") == "BTC/USDT"