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=真实下单) # 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true LIVE_TRADING_ENABLED=true
# Binance API Key(需开通合约,万向划转等权限) # Binance API(仅服务器 .env 配置;新机保持为空,填真钥后 pm2 restart --update-env;勿用占位符以免鉴权狂打)
BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY BINANCE_API_KEY=
# Binance API Secret BINANCE_API_SECRET=
BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET
# 保证金模式:cross=全仓,isolated=逐仓 # 保证金模式:cross=全仓,isolated=逐仓
BINANCE_MARGIN_MODE=cross BINANCE_MARGIN_MODE=cross
# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向 # 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向
+39 -4
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path: if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT) sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import ( from lib.ai.ai_review_lib import (
build_journal_ai_chart_path, build_journal_ai_chart_path,
@@ -347,8 +352,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz() APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip() BINANCE_API_KEY = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip() BINANCE_API_SECRET = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower() BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
# hedge=双向持仓(需 positionSide);oneway / single=单向持仓 # hedge=双向持仓(需 positionSide);oneway / single=单向持仓
_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower() _raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
@@ -490,6 +495,8 @@ if BINANCE_API_KEY and BINANCE_API_SECRET:
exchange.apiKey = BINANCE_API_KEY exchange.apiKey = BINANCE_API_KEY
exchange.secret = BINANCE_API_SECRET exchange.secret = BINANCE_API_SECRET
MARKETS_LOADED = False MARKETS_LOADED = False
# 鉴权失败后停止私有 API(资金/持仓),避免坏钥反复请求;尤其 Gate 易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = { ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0, "updated_at": 0.0,
"funding_usdt": None, "funding_usdt": None,
@@ -2859,6 +2866,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready(): def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED: if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (BINANCE_API_KEY and BINANCE_API_SECRET): if not (BINANCE_API_KEY and BINANCE_API_SECRET):
@@ -2893,9 +2902,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured(): def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(BINANCE_API_KEY and BINANCE_API_SECRET) return bool(BINANCE_API_KEY and BINANCE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, BINANCE_API_KEY, BINANCE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
BINANCE_API_KEY = ""
BINANCE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _float_balance_field(val): def _float_balance_field(val):
if val is None or val == "": if val is None or val == "":
return None return None
@@ -3178,11 +3201,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try: try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt() ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt()
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try: try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total() ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total()
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -3585,7 +3612,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False): def ensure_markets_loaded(force=False):
global MARKETS_LOADED global MARKETS_LOADED
if force or not MARKETS_LOADED: if force or not MARKETS_LOADED:
try:
exchange.load_markets(reload=force) exchange.load_markets(reload=force)
except Exception as e:
# 坏钥时立刻去掉签名再拉公开 markets,避免反复鉴权(尤其勿拖累同机 Gate)
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True MARKETS_LOADED = True
+3 -4
View File
@@ -74,10 +74,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) # 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true LIVE_TRADING_ENABLED=true
# Gate API Key(实盘) # Gate API(仅服务器 .env 配置;新机保持为空,填真钥后重启;错误密钥反复请求易导致 Gate 封 IP)
GATE_API_KEY=REPLACE_WITH_GATE_API_KEY GATE_API_KEY=
# Gate API Secret(实盘) GATE_API_SECRET=
GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET
# 保证金模式:cross=全仓,isolated=逐仓 # 保证金模式:cross=全仓,isolated=逐仓
GATE_TD_MODE=cross GATE_TD_MODE=cross
# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤 # 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤
+114 -26
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path: if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT) sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import ( from lib.ai.ai_review_lib import (
build_journal_ai_chart_path, build_journal_ai_chart_path,
@@ -346,8 +351,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz() APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip() GATE_API_KEY = normalize_api_credential(os.getenv("GATE_API_KEY"))
GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip() GATE_API_SECRET = normalize_api_credential(os.getenv("GATE_API_SECRET"))
GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower() GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平) # 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平)
@@ -478,6 +483,8 @@ if GATE_API_KEY and GATE_API_SECRET:
exchange.apiKey = GATE_API_KEY exchange.apiKey = GATE_API_KEY
exchange.secret = GATE_API_SECRET exchange.secret = GATE_API_SECRET
MARKETS_LOADED = False MARKETS_LOADED = False
# 鉴权失败后停止私有 API,避免坏钥反复签名;Gate 尤其易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = { ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0, "updated_at": 0.0,
"funding_usdt": None, "funding_usdt": None,
@@ -2547,6 +2554,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready(): def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED: if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (GATE_API_KEY and GATE_API_SECRET): if not (GATE_API_KEY and GATE_API_SECRET):
@@ -2581,9 +2590,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured(): def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等.""" """仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(GATE_API_KEY and GATE_API_SECRET) return bool(GATE_API_KEY and GATE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, GATE_API_KEY, GATE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
GATE_API_KEY = ""
GATE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启;勿反复试错以免 Gate 封 IP): {exc}"
)
def _extract_usdt_total(balance): def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {} total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2841,11 +2864,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try: try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt()
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try: try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"]) ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"])
except Exception: except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」 # 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -3345,7 +3372,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False): def ensure_markets_loaded(force=False):
global MARKETS_LOADED global MARKETS_LOADED
if force or not MARKETS_LOADED: if force or not MARKETS_LOADED:
try:
exchange.load_markets(reload=force) exchange.load_markets(reload=force)
except Exception as e:
# Gate 对无效签名/坏钥敏感,失败后立即改公开 markets,勿反复带钥请求
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True MARKETS_LOADED = True
@@ -3761,46 +3796,99 @@ def _coerce_float(*values):
return None return None
def _gate_is_cross_margin(position, info):
mode = str(position.get("marginMode") or info.get("pos_margin_mode") or "").lower()
if "cross" in mode:
return True
lev = _coerce_float(info.get("leverage"), position.get("leverage"))
return lev is not None and lev == 0
def _gate_effective_leverage(position, info, order_leverage=None):
lev = _coerce_float(position.get("leverage"), info.get("leverage"))
if lev is not None and lev > 0:
return lev
cross_lev = _coerce_float(info.get("cross_leverage_limit"))
if cross_lev is not None and cross_lev > 0:
return cross_lev
if order_leverage is not None:
try:
ol = float(order_leverage)
if ol > 0:
return ol
except (TypeError, ValueError):
pass
return None
def _gate_estimated_initial_margin(notional, leverage):
"""Gate App 口径:仓位价值/杠杆 + 预估平仓 taker 费(0.075%)."""
if notional is None or notional <= 0 or leverage is None or leverage <= 0:
return None
return notional / float(leverage) + notional * 0.00075
def _gate_margin_matches_unrealized(margin, unrealized):
if margin is None or unrealized is None:
return False
return abs(float(margin) - float(unrealized)) <= max(0.02, abs(float(unrealized)) * 0.05)
def _gate_resolve_initial_margin(position, info, *, notional, unrealized, order_leverage=None):
"""全仓下 API margin 偶发等于 unrealised_pnl;优先 value/杠杆,逐仓仍信 API."""
api_margin = _coerce_float(
info.get("initial_margin"),
position.get("initialMargin"),
position.get("collateral"),
position.get("margin"),
info.get("margin"),
info.get("iso_margin"),
info.get("position_margin"),
info.get("initialMargin"),
)
eff_lev = _gate_effective_leverage(position, info, order_leverage)
estimated = _gate_estimated_initial_margin(notional, eff_lev) if eff_lev else None
if _gate_is_cross_margin(position, info):
if estimated and estimated > 0:
if (
api_margin is None
or api_margin <= 0
or _gate_margin_matches_unrealized(api_margin, unrealized)
or api_margin < estimated * 0.6
):
return estimated
if api_margin is not None and api_margin > 0 and not _gate_margin_matches_unrealized(
api_margin, unrealized
):
return api_margin
return estimated
if api_margin is not None and api_margin > 0:
return api_margin
return estimated
def parse_ccxt_position_metrics(position, order_leverage=None): def parse_ccxt_position_metrics(position, order_leverage=None):
""" """
ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底). ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底).
App仓位保证金对齐时优先用 initialMargin;缺失时再尝试 info 内字段. 全仓优先 value/cross_leverage_limit(+平仓费);API margin unrealised_pnl 则弃用.
""" """
if not position: if not position:
return None return None
p = position p = position
info = p.get("info", {}) or {} info = p.get("info", {}) or {}
# Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
if initial is None or initial <= 0:
initial = _coerce_float(
info.get("margin"),
info.get("cross_margin"),
info.get("iso_margin"),
info.get("initial_margin"),
info.get("position_margin"),
info.get("initialMargin"),
)
notional = _coerce_float(p.get("notional"), p.get("notionalValue")) notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
if notional is None or notional <= 0: if notional is None or notional <= 0:
notional = _coerce_float(info.get("value")) notional = _coerce_float(info.get("value"))
if notional is not None: if notional is not None:
notional = abs(notional) notional = abs(notional)
# 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近)
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
try:
lev = float(order_leverage)
if lev > 0:
approx = notional / lev
if approx > 0:
initial = approx
except (TypeError, ValueError):
pass
unrealized = _coerce_float( unrealized = _coerce_float(
p.get("unrealizedPnl"), p.get("unrealizedPnl"),
info.get("unrealised_pnl"), info.get("unrealised_pnl"),
info.get("unrealized_pnl"), info.get("unrealized_pnl"),
) )
initial = _gate_resolve_initial_margin(
p, info, notional=notional, unrealized=unrealized, order_leverage=order_leverage
)
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice")) mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice"))
out = {} out = {}
if initial is not None and initial > 0: if initial is not None and initial > 0:
+26 -4
View File
@@ -80,12 +80,13 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) # 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true LIVE_TRADING_ENABLED=true
# ============================================================================= # =============================================================================
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2) # OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示)
# 新机保持为空;填真钥后 pm2 restart --update-env(含子代理)
# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填 # 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填
# ============================================================================= # =============================================================================
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY OKX_API_KEY=
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET OKX_API_SECRET=
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE OKX_API_PASSPHRASE=
# 保证金模式:cross=全仓,isolated=逐仓 # 保证金模式:cross=全仓,isolated=逐仓
OKX_TD_MODE=cross OKX_TD_MODE=cross
# 持仓模式:hedge=双向持仓,net=单向净持仓 # 持仓模式:hedge=双向持仓,net=单向净持仓
@@ -113,8 +114,21 @@ OKX_OPTIONS_ENABLED=false
# OKX_OPTIONS_API_SECRET= # OKX_OPTIONS_API_SECRET=
# OKX_OPTIONS_API_PASSPHRASE= # OKX_OPTIONS_API_PASSPHRASE=
OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 OKX_OPTIONS_ACCOUNT_LABEL=账户·期权
# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC)
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_TRADE_BUDGET_USDC=10 OKX_OPTIONS_TRADE_BUDGET_USDC=10
OKX_OPTIONS_BUDGET_BUFFER=0.95 OKX_OPTIONS_BUDGET_BUFFER=0.95
# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走)
OKX_OPTIONS_COIN_COMPOUND=true
OKX_OPTIONS_COIN_BUDGET_USDT=10
OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false
OKX_OPTIONS_COIN_MAX_USDT=50
# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换
OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10
# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算
OKX_OPTIONS_COMPOUND_FULL_ENABLED=true
OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false
OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300
# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲 # 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲
# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」 # 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」
OKX_TRADE_MODE=options OKX_TRADE_MODE=options
@@ -130,6 +144,14 @@ OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
OKX_OPTIONS_POLL_SECONDS=15 OKX_OPTIONS_POLL_SECONDS=15
OKX_OPTIONS_TD_MODE=isolated OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值)
OKX_OPTIONS_CLOSE_GATE_MODE=premium
# 全局倍数可选;留空则按本位用下方 COIN/USDC
OKX_OPTIONS_CLOSE_RECYCLE_MULT=
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2
OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 # 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
+123 -18
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path: if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT) sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import ( from lib.ai.ai_review_lib import (
build_journal_ai_chart_path, build_journal_ai_chart_path,
@@ -360,9 +365,9 @@ def _promote_legacy_options_api_keys() -> None:
_promote_legacy_options_api_keys() _promote_legacy_options_api_keys()
OKX_API_KEY = os.getenv("OKX_API_KEY", "") OKX_API_KEY = normalize_api_credential(os.getenv("OKX_API_KEY"))
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "") OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET"))
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "") OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on") OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on")
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10")) OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
@@ -506,6 +511,7 @@ if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE:
exchange_options.password = OKX_API_PASSPHRASE exchange_options.password = OKX_API_PASSPHRASE
MARKETS_LOADED = False MARKETS_LOADED = False
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = { ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0, "updated_at": 0.0,
"funding_usdt": None, "funding_usdt": None,
@@ -2485,6 +2491,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_okx_live_ready(): def ensure_okx_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED: if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
@@ -2517,6 +2525,23 @@ def order_row_key_signal_type(row):
return None return None
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
try:
strip_ccxt_credentials(exchange_options)
except Exception:
pass
OKX_API_KEY = ""
OKX_API_SECRET = ""
OKX_API_PASSPHRASE = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _extract_usdt_total(balance): def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {} total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2637,8 +2662,9 @@ def get_exchange_capitals(force=False):
ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding
ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
except Exception: except Exception as e:
pass if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
@@ -2869,7 +2895,14 @@ def build_okx_order_params(direction, reduce_only=False):
def ensure_markets_loaded(force=False): def ensure_markets_loaded(force=False):
global MARKETS_LOADED global MARKETS_LOADED
if force or not MARKETS_LOADED: if force or not MARKETS_LOADED:
try:
exchange.load_markets(reload=force) exchange.load_markets(reload=force)
except Exception as e:
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True MARKETS_LOADED = True
@@ -2985,6 +3018,8 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_
def exchange_private_api_configured(): def exchange_private_api_configured():
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
@@ -6608,7 +6643,6 @@ def render_main_page(page="trade", embed_mode=None):
from lib.instance.instance_embed_context_lib import ( from lib.instance.instance_embed_context_lib import (
embed_render_plan, embed_render_plan,
minimal_stats_bundle, minimal_stats_bundle,
options_funding_label,
profit_loss_ratio_from_trades, profit_loss_ratio_from_trades,
show_perp_funds_enabled, show_perp_funds_enabled,
total_funds_usdt, total_funds_usdt,
@@ -6626,22 +6660,39 @@ def render_main_page(page="trade", embed_mode=None):
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
if ( if (
OKX_OPTIONS_ENABLED OKX_OPTIONS_ENABLED
and exchange_options.apiKey and exchange_options.apiKey
and embed_mode != "fragment" and embed_mode != "fragment"
): ):
try: try:
from lib.exchange.okx_options_lib import options_header_balances from lib.exchange.okx_options_lib import options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( _op = options_header_balance_pack(exchange_options)
exchange_options options_trading_usdc = _op.get("trading_usdc")
) options_funding_usdc = _op.get("funding_usdc")
options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
except Exception: except Exception:
options_trading_usdc = None options_trading_usdc = None
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
recommended_capital = get_recommended_capital(current_capital) recommended_capital = get_recommended_capital(current_capital)
key_list = ( key_list = (
conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else []
@@ -6793,6 +6844,11 @@ def render_main_page(page="trade", embed_mode=None):
options_funding_usdt=options_funding_usdt, options_funding_usdt=options_funding_usdt,
options_trading_usdc=options_trading_usdc, options_trading_usdc=options_trading_usdc,
options_trading_usdt=options_trading_usdt, options_trading_usdt=options_trading_usdt,
options_funding_eth=options_funding_eth,
options_trading_eth=options_trading_eth,
options_trading_btc=options_trading_btc,
options_margin_mode=options_margin_mode,
options_underly=options_underly,
trading_day=trading_day, trading_day=trading_day,
daily_start_capital=DAILY_START_CAPITAL, daily_start_capital=DAILY_START_CAPITAL,
current_capital=current_capital, current_capital=current_capital,
@@ -6857,10 +6913,10 @@ def render_main_page(page="trade", embed_mode=None):
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
key_rule_ctx=key_rule_ctx, key_rule_ctx=key_rule_ctx,
funds_fmt=format_funds_u, funds_fmt=format_funds_u,
options_funding_label=options_funding_label, # options_funding_label / trading_account_label 由 embed_context_extras 注入,勿重复写进 dict
exchange_display=EXCHANGE_DISPLAY_NAME, exchange_display=EXCHANGE_DISPLAY_NAME,
options_enabled=OKX_OPTIONS_ENABLED, options_enabled=OKX_OPTIONS_ENABLED,
show_perp_funds=_show_perp_funds, show_perp_funds=_show_perp_funds or (options_margin_mode == "coin"),
options_nav_visible=True, options_nav_visible=True,
okx_trade_mode=_okx_trade_mode, okx_trade_mode=_okx_trade_mode,
options_open_allowed=_okx_trade_mode == "options", 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"), hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"),
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"),
options_compound_full_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_enabled=os.getenv(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"
).lower()
in ("1", "true", "yes", "on"),
options_compound_full_cap_usdc=float(
os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300"
),
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
options_chain_ask_liq_filter=os.getenv( options_chain_ask_liq_filter=os.getenv(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true"
@@ -7039,19 +7106,38 @@ def api_account_snapshot():
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
options_index_px = None
if OKX_OPTIONS_ENABLED and exchange_options.apiKey: if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
try: try:
from lib.exchange.okx_options_lib import options_header_balances from lib.exchange.okx_options_lib import fetch_index_price, options_header_balance_pack
options_trading_usdc, options_funding_usdc, options_funding_usdt, options_trading_usdt = options_header_balances( _op = options_header_balance_pack(exchange_options, force=force_refresh)
exchange_options, options_trading_usdc = _op.get("trading_usdc")
force=force_refresh, options_funding_usdc = _op.get("funding_usdc")
) options_funding_usdt = _op.get("funding_usdt")
options_trading_usdt = _op.get("trading_usdt")
options_funding_eth = _op.get("funding_eth")
options_trading_eth = _op.get("trading_eth")
options_trading_btc = _op.get("trading_btc")
options_margin_mode = _op.get("options_margin_mode") or "coin"
options_underly = _op.get("options_underly") or "ETH"
options_index_px = fetch_index_price(exchange_options, options_underly)
except Exception: except Exception:
options_trading_usdc = None options_trading_usdc = None
options_funding_usdc = None options_funding_usdc = None
options_funding_usdt = None options_funding_usdt = None
options_trading_usdt = None options_trading_usdt = None
options_funding_eth = None
options_trading_eth = None
options_trading_btc = None
options_margin_mode = "coin"
options_underly = "ETH"
options_index_px = None
recommended_capital = get_recommended_capital(current_capital) recommended_capital = get_recommended_capital(current_capital)
from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors
@@ -7122,10 +7208,12 @@ def api_account_snapshot():
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
# 币本位期权盈亏单位为币,禁止与永续 U 混加(且 merge 只保留 2 位会把 0.0019 抹成 0)
if options_margin_mode != "coin":
unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl)
except Exception: except Exception:
options_unrealized_pnl = None options_unrealized_pnl = None
_show_perp_funds = show_perp_funds_enabled(exchange_key="okx") _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin")
return jsonify({ return jsonify({
"funding_usdt": funding_usdt, "funding_usdt": funding_usdt,
"current_capital": current_capital, "current_capital": current_capital,
@@ -7134,6 +7222,12 @@ def api_account_snapshot():
"options_funding_usdt": options_funding_usdt, "options_funding_usdt": options_funding_usdt,
"options_trading_usdc": options_trading_usdc, "options_trading_usdc": options_trading_usdc,
"options_trading_usdt": options_trading_usdt, "options_trading_usdt": options_trading_usdt,
"options_funding_eth": options_funding_eth,
"options_trading_eth": options_trading_eth,
"options_trading_btc": options_trading_btc,
"options_margin_mode": options_margin_mode,
"options_underly": options_underly,
"options_index_px": options_index_px,
"total_funds": total_funds_usdt( "total_funds": total_funds_usdt(
funding_usdt if _show_perp_funds else None, funding_usdt if _show_perp_funds else None,
current_capital 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_unrealized_pnl = None
options_index_px = None
options_margin_mode = None
options_underly = None
if OKX_OPTIONS_ENABLED and exchange_options.apiKey: if OKX_OPTIONS_ENABLED and exchange_options.apiKey:
try: try:
from lib.options.options_positions_lib import sum_options_net_pnl_usdc from lib.options.options_positions_lib import sum_options_net_pnl_usdc
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
from lib.exchange.okx_options_lib import fetch_index_price
opt_cfg = app.extensions.get("options_cfg") opt_cfg = app.extensions.get("options_cfg")
if opt_cfg: if opt_cfg:
@@ -7544,6 +7643,9 @@ def api_price_snapshot():
from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc
options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options)
options_margin_mode = normalize_options_margin_mode()
options_underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = fetch_index_price(exchange_options, options_underly)
except Exception: except Exception:
options_unrealized_pnl = None options_unrealized_pnl = None
@@ -7554,6 +7656,9 @@ def api_price_snapshot():
"position_marks": position_marks, "position_marks": position_marks,
"positions_raw_count": len(all_swap_positions), "positions_raw_count": len(all_swap_positions),
"options_unrealized_pnl": options_unrealized_pnl, "options_unrealized_pnl": options_unrealized_pnl,
"options_index_px": options_index_px,
"options_margin_mode": options_margin_mode,
"options_underly": options_underly,
**force_close_template_context( **force_close_template_context(
FORCE_CLOSE_ENABLED, FORCE_CLOSE_ENABLED,
FORCE_CLOSE_BJ_HOUR, FORCE_CLOSE_BJ_HOUR,
+2 -2
View File
@@ -40,9 +40,9 @@ bash /opt/crypto_monitor/deploy/manage.sh
- 登录账号: **admin** - 登录账号: **admin**
- 登录密码: **admin123** - 登录密码: **admin123**
- 浏览器配置: 各所 **env 配置**(API,风控) + 中控 **系统设置** - 浏览器配置: 各所 **env 配置**(风控) + 中控 **系统设置**;**交易所 API 仅服务器 `.env` 手改**(新机默认为空)
**无需 SSH 编辑 `.env` 填 API**;密钥由 `bootstrap_deploy_secrets.py` 自动生成. **无需 SSH 编辑 `.env` 填通信/登录类密钥**;交易所 API 须在服务器写入各所 `.env`(新机为空).
| 地址 | 端口 | | 地址 | 端口 |
|------|------| |------|------|
@@ -0,0 +1,279 @@
# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案
> 状态:**已实现首版**(按本文落地;改需求先改本文).
> 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**.
> **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为).
> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md)
---
## 1. 背景与动机
当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平.
实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量.
币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大.
---
## 2. 目标(首版)
1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥).
2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量).
3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT.
4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**.
5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`.
6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**.
7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持.
8. **中控不代下期权单**,但监控/快照/持仓卡片等 **只读字段须能识别币本位**(见 §7.5).
9. **不涉及 Gate** 任何业务改动.
---
## 3. 不做(首版外)
- 对冲计划(永期/期期)币本位腿或双模式混开
- 盘中按单笔切换本位(必须 env + 重启/无仓校验)
- 按权利金精确计算后再买现货(明确不做;见 §5)
- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转)
- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则)
- 多笔并行单笔期权仓(维持「一次一仓」)
- **中控代下 / 中控内嵌开平仓按钮**触发币本位或 USDC 期权下单(开平仍只在 OKX 实例页)
- **任何 Gate 相关改动**(含为「顺便统一」去动 Gate 模板或共享路径上的 Gate 分支)
---
## 4. 模式开关与互斥
### 4.1 env(草案)
| 变量 | 含义 | 默认 |
|------|------|------|
| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `coin` |
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` |
| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` |
| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) |
| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` |
| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) |
| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) |
| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 现货买入相对权利金倍数(也可写 `0.10`=+10%) | `1.10` |
开仓买币:**先按预算估最大可开张数 → 买币 USDT ≈ 张数×卖一权利金×现货缓冲**,不全额把预算换成币。
说明:
- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`.
- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`.
- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致).
### 4.2 切换门禁
| 条件 | 行为 |
|------|------|
| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc``coin` |
| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 |
| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC |
| 无仓且无半成品 | 允许改 env 并重启后生效 |
启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价.
---
## 5. 币本位资金桥与开平流水
### 5.1 开仓(先买满,再开满)
```
1. 读取交易账户 USDT 可用
2. 计算 budget_usdt(§4.1)
3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致)
4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权
- 受:最小张数、卖一深度、单笔一仓规则约束
- 不要求「币数量精确等于权利金」;允许开满后仍残留部分币
5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding
```
### 5.2 平仓(先平期权,再卖回 USDT)
```
1. 按现有纪律买一限价卖出期权(可分批深度)
2. 期权仓清零(或本轮目标完成)后:
现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT
3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利
```
### 5.3 失败回滚(必须)
| 失败点 | 处理 |
|--------|------|
| 现货买入失败 | 不开期权;报错 |
| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 |
| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 |
| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 |
---
## 6. 复利与「人工转走」
### 6.1 口径
- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算).
- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」.
- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`):
- **默认关闭** → 纯靠人工转走控规模.
- **开启** → `min(账户×0.95, N)`,防止单笔过大.
### 6.2 与永续「全仓」的关系
思想同类(吃可用 × 缓冲),但资产不同:
- 永续全仓:USDT 保证金 × 杠杆 → 合约名义
- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金
**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合.
### 6.3 一次一仓
复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品.
---
## 7. 产品与 UI
### 7.1 模式可见性
- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`.
- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态.
- USDC 模式保持现有 USDC 余额与预算展示.
### 7.2 开仓按钮文案(示例)
- 币本位:`买币并开仓(预算 ≈ xx USDT)`
- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT.
### 7.3 对冲
- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算.
- 不在此模式自动把对冲预算改成 USDT 桥.
### 7.4 复盘字段(建议)
单笔 round-trip 尽量可拆:
- 期权腿盈亏(币或折合 USDT)
- 桥兑换盈亏(买币成本 vs 卖币回收)
- 合计 USDT 变化(对复利最有意义)
首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**.
### 7.5 中控只读(要做)与不下单(不做)
中控保持现有分工:**监控只读 + 点「期权」进 OKX 实例操作**;本方案**不**在中控增加开平仓/买币桥按钮.
只读侧须能区分并展示币本位,避免仍按「一律 USDC 权利金」误读.实例上报快照/期权字段建议至少包含:
| 字段(名可调) | 含义 |
|--------------|------|
| `options_margin_mode` | `usdc` \| `coin` |
| 持仓行可辨本位 | 合约族/结算币/标签,卡片上能看出「币本位」或「USDC」 |
| 币本位时预算口径 | 可选:交易户 USDT、本轮 `×0.95` 预估预算、是否触达 N 上限 |
| 桥状态(若有半成品) | 如 `holding` / `pending_sell_spot`(待卖回 USDT),中控只展示与告警,不代执行 |
展示落点(与现网对齐即可,不新开中控交易页):
- OKX 账户监控里的期权区块 / 期权持仓卡片
- 推给教练等用的监控快照文案(若已注入期权行,须带本位标记,避免 AI/人工当成 USDC)
**不做:**中控代下单、中控触发买 ETH/卖 ETH、中控改 env 切模式.
---
## 8. 技术要点
### 8.1 合约与报价
- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径.
- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设).
- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套.
### 8.2 模块建议
| 块 | 职责 |
|----|------|
| 模式读取 + 门禁 | env、有仓拒切、启动一致性 |
| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 |
| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 |
| 定价/张数 | 币本位分支 |
| UI/API | 预算预览、确认、半成品提示 |
| 中控只读 | 消费实例快照中的 `options_margin_mode` 等字段;卡片/文案可识别币本位;**无下单 API** |
| Gate | **不纳入**;禁止为本次需求修改 Gate 树 |
现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同.
共享 `lib/options*` / 快照序列化若调整:仅扩展 OKX 期权载荷;Binance/Gate 账户快照路径保持原样.
### 8.3 权限与账户
- API 需具备:交易账户现货市价、期权开平.
- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划).
### 8.4 测试
- 预算计算:复利开/关、上限开/关、余额边界.
- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功.
- 门禁:有仓切换拒绝;一次一仓.
- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC.
- 中控只读:快照含本位字段时卡片/文案可区分 `usdc`/`coin`.
- Gate:本次 diff **不应出现** `crypto_monitor_gate/` 业务文件变更.
---
## 9. 验收标准
1. `usdc` 模式:单笔期权行为与现网一致.
2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警).
3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时).
4. 上限开关默认关;开启后预算不超过 N.
5. 有持仓或半成品时切换模式被拒绝.
6. 币本位下对冲不能误开币本位腿.
7. 开仓失败自动卖回 USDT,不留下无主现货.
8. 中控:**无**期权下单入口新增;监控/快照/持仓只读能看出当前为币本位或 USDC.
9. Gate:无相关代码改动;Gate 实例行为与改前一致.
---
## 10. 实现顺序建议
1. 模式 env + 有仓/半成品门禁 + OKX 实例 UI 展示当前模式
2. 现货桥(买/卖/回滚/待卖回) + 单测
3. 币本位合约适配 + 卖一开/买一平接入编排
4. 复利预算预览与开仓确认
5. 上限开关
6. 快照字段上报 + **中控只读识别币本位**(卡片/文案;不下单)
7. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔
---
## 11. 决策摘要(已拍板)
| 决策 | 结论 |
|------|------|
| 对冲 | 暂不接币本位 |
| 单笔模式 | env:`usdc``coin` |
| 有持仓切换 | **拒绝** |
| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) |
| 复利 | 交易账户 USDT × 0.95;人工转走控规模 |
| 单笔不超过 N U | **独立开关,默认关闭** |
| 中控 | **不下单**;只读字段/快照**能识别币本位** |
| Gate | **本次不改** |
| 动机 | 币本位流动性往往优于 USDC,利于成交 |
---
## 12. 风险与说明
- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显.
- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待.
- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准.
- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族.
+5 -4
View File
@@ -14,6 +14,7 @@
| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | | **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 |
| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | | **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 |
| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 | | **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 |
| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` |
| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | | **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 |
| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | | **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 |
@@ -61,15 +62,14 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本
| 中文名 | 说明 | 重启 | | 中文名 | 说明 | 重启 |
|--------|------|------| |--------|------|------|
| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | | 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 |
| API Key | 账户 API Key(永续+期权共用) | 需重启 |
| API Secret | 账户 API Secret | 需重启 |
| API Passphrase | 仅 OKX 显示 | 需重启 |
| 保证金模式 | 全仓 / 逐仓 | 需重启 | | 保证金模式 | 全仓 / 逐仓 | 需重启 |
| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | | 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 |
| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | | 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 |
| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | | 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 |
| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 | | 显示永续资金 | 仅 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` - 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG`
- 数据:`DB_PATH`,`UPLOAD_DIR` - 数据:`DB_PATH`,`UPLOAD_DIR`
- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空)
- 关键位门控:全部 `KEY_*`,`KLINE_*` - 关键位门控:全部 `KEY_*`,`KLINE_*`
- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*` - 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*`
- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY` - 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY`
@@ -212,7 +213,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 能力 | env 配置 | 系统设置 | 中控系统设置 | | 能力 | env 配置 | 系统设置 | 中控系统设置 |
|------|----------|----------|--------------| |------|----------|----------|--------------|
| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | | 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 |
| 交易所 API | ✅(各所自配) | ❌ | ❌ | | 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ |
| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | | AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) |
| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | | 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 |
| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | | 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ |
+1 -1
View File
@@ -75,7 +75,7 @@ python3 scripts/bootstrap_deploy_secrets.py
## 4. 实例 env 配置页变更 ## 4. 实例 env 配置页变更
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配. 三所 **env 配置** 页已 **移除「AI 复盘」卡片**.企业微信交易执行等仍各所自配;**交易所 API 仅服务器 `.env`**,前端不再展示.
实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入. 实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入.
@@ -0,0 +1,233 @@
# 实盘下单 · 盘口深度预览 — 开发方案
> 状态:**方案待实现**(按本文落地;改需求先改本文).
> 范围:**三所实例**实盘下单监控(Binance / OKX / Gate);中控嵌入同一表单时一并带上.
> 相关:[manual-order-rr-preview.md](./manual-order-rr-preview.md) · [position-sizing-mode.md](./position-sizing-mode.md) · 期权侧已有「卖一开 / 买一平」深度硬约束(本方案**不照搬硬挡**,首版以预览为主).
---
## 1. 背景与问题
实盘下单表单目前只展示 **标的现价/标记价**,再按止损与计仓模式算出预估风险 / 预估 RR.
- **资金小**:名义仓位通常远小于盘口前几档,市价成交贴近买卖一,现价参考够用.
- **资金大**(尤其 `POSITION_SIZING_MODE=full_margin`):名义 = 可用保证金 × 缓冲 × 杠杆,容易到数十万 U. 市价单会沿对手盘穿档,入场均价偏离「现价」后,止损距离与有效盈亏比都会偏.
典型例子:
| 条件 | 含义 |
|------|------|
| 可用约 1 万 U,20 倍杠杆,全仓 | 计划名义约 **20 万 U** |
| **市价做空** | 立刻卖出 ≈ 20 万 U 名义 → 吃 **买单(bid)** |
| **市价做多** | 立刻买入 ≈ 20 万 U 名义 → 吃 **卖单(ask)** |
用户需要的不是整本订单簿娱乐墙,而是回答:
> 当前计划名义下,对手盘前几档**能不能接住**,接住后的**预估均价 / 滑点**大概多少?
---
## 2. 目标(首版)
在「实盘下单监控」开仓区增加 **计划名义 vs 对手盘深度** 的只读预览:
1. 按当前表单算出的 **计划名义(USDT)****方向**,取对应一侧盘口.
2. 从最优档往外累加,直到累计名义 ≥ 计划名义(或盘口耗尽).
3. 展示:吃到第几档、累计可吸收名义、预估成交均价(VWAP)、相对参考价的滑点(bps 或 %).
4. **不拦截下单**(首版);可选标黄提示,见 §6.
与现有「预估风险 / 预估盈利 / 预估盈亏比」并列,作为下单前参考,不替代服务端风控与交易所真实成交.
---
## 3. 不做(首版外)
- 完整 20/50 档盘口图、深度图动画、WebSocket 持续推送盘口(首版 REST 轮询即可)
- 按深度 **自动缩仓****禁止开仓**(期权硬约束那套;列为二期,见 §10)
- 限价挂单的「挂单价到盘口距离」专项(可后加;首版聚焦市价吃单路径)
- 平仓/止损单穿档预估(开仓侧先做;平仓可二期)
- 改开仓逻辑、改计仓公式、改交易所下单路径
- 中控独立深度页或跨所聚合盘口
---
## 4. 产品规则
### 4.1 对手盘方向
| 用户方向 | 市价开仓动作 | 累加侧 |
|----------|--------------|--------|
| 做多(long) | 买入 | **卖盘 asks**(卖一 → 卖 N) |
| 做空(short) | 卖出 | **买盘 bids**(买一 → 买 N) |
### 4.2 计划名义从哪来
与现有开仓计仓一致,优先复用服务端已有 sizing 口径(避免前后端各算一套):
| 计仓模式 | 计划名义 |
|----------|----------|
| `full_margin` | `notional_value` ≈ 可用 × 缓冲 × 杠杆(与 `compute_full_margin_sizing` 一致) |
| `risk`(以损定仓) | 由风险金额与止损距离反推的仓位名义(与现开仓 `add_order` 路径一致) |
表单未填齐止损/方向/币种、或无法取可用保证金时:深度预览显示「—」,不报错打断填写.
### 4.3 参考价与滑点
- **参考价**:优先与表单现价条同一口径(标记价/最新价,跟现有 `symbol_live_price` / `order_defaults` 一致).
- **预估均价(VWAP)**:按所吃各档 `价格 × 该档名义` 加权.
- **滑点**:
- 做多: `(vwap - ref) / ref`(越正越差)
- 做空: `(ref - vwap) / ref`(越正越差)
- 展示可用 **bps**(1 bps = 0.01%)或 `%`,UI 统一一种即可(建议 bps,大单更直观).
### 4.4 盘口档数
- 请求深度建议 **520 档**(实现时三所取各自 API 稳妥上限,默认 20).
- 累加只展示「覆盖计划名义所需」的档位摘要,不必把未吃到的远档全部渲染.
- 若累加后仍 `< 计划名义`:明确写 **深度不足 / 缺口约 X U**,不要伪装成已完全覆盖.
### 4.5 文案示例(空单 20 万 U)
```
对手盘(买):买一~买4 累计约 23.1 万 U · 预估均价 63480(相对现价约 5 bps)
```
深度不足时:
```
对手盘(买):前 20 档累计约 12.4 万 U · 缺口约 7.6 万 U · 预估均价按已有档估算(仅供参考)
```
---
## 5. 界面位置
放在实盘下单开仓区、现有预览条附近,避免抢主按钮视觉:
| 区域 | 建议 |
|------|------|
| 现价条旁或下方 | 一行摘要即可(§4.5) |
| `#order-plan-preview` | 可增一项「盘口深度」或独立 `#order-depth-preview` |
| 详细档位 | 首版可不展开;若展开,仅列出已累加到的那几档(价/量/累计名义) |
小资金且滑点低于阈值时,可用灰色弱提示「前 N 档已覆盖,滑点可忽略」,避免噪音.
---
## 6. 提示阈值(软提示,不挡单)
建议可配置(`.env`,有默认值),仅影响颜色/文案:
| 变量(草案) | 含义 | 默认建议 |
|------------|------|----------|
| `MANUAL_DEPTH_WARN_BPS` | 预估滑点 ≥ 此值标黄 | `5` |
| `MANUAL_DEPTH_ALERT_BPS` | 预估滑点 ≥ 此值标红/强调 | `15` |
| `MANUAL_DEPTH_SHORTFALL_WARN` | 累计名义 < 计划名义时强调 | 开 |
首版:**不**因此 `disabled` 开仓按钮;与期权「无卖一禁止开仓」区分开.
---
## 7. 技术设计
### 7.1 API(三所各暴露,或抽到 `lib/` 共用 handler)
建议新增(名称可微调):
`GET /api/order_depth_preview`
| 参数 | 说明 |
|------|------|
| `symbol` | 与开仓表单一致 |
| `direction` | `long` / `short` |
| `sl` / `sl_pct` / `fixed_rr` / `sltp_mode` 等 | 以损定仓算名义时需要;全仓模式可只传 symbol+direction |
| 或直接传 `notional_usdt` | 若前端已从其它 preview API 拿到名义,可减少重复计算(**二选一,实现时定一种主路径**) |
响应草案:
```json
{
"ok": true,
"side": "bid",
"ref_px": 63512.3,
"plan_notional_usdt": 200000,
"covered_notional_usdt": 231000,
"shortfall_usdt": 0,
"levels_used": 4,
"vwap": 63480.0,
"slippage_bps": 5.1,
"levels": [
{"px": 63510, "sz": "...", "notional_usdt": 50000, "cum_notional_usdt": 50000}
],
"msg": ""
}
```
失败(拉盘口失败、币种无效):`ok=false` + 简短 `msg`;前端显示「深度暂不可用」,不影响开仓。
### 7.2 交易所盘口
| 所 | 合约盘口 | 注意 |
|----|----------|------|
| Binance | USD-M 深度 | 数量单位换算成 USDT 名义 |
| OKX | swap books | 同左;与期权 `fetch_option_book_depth` **分开**,勿混用期权接口 |
| Gate | futures order book | 同左 |
公共逻辑建议落在 `lib/trade/`(例如 `manual_order_depth_preview_lib.py`):输入档位列表 + 计划名义 + 方向 → 输出 VWAP / 缺口 / levels_used.
各所只负责 **拉 book + 单位换算成 USDT 名义**.
### 7.3 前端
- 共享脚本(建议):`lib/common/static/manual_order_depth_preview.js`
-`manual_order_rr_preview.js` 同样在币种/方向/止损/模式变更时 debounce 刷新
- 轮询间隔建议 3~5s(仅表单可见且字段有效时);切页或无焦点可停
- 三所 `index` / 嵌入 fragment 引入同一脚本
### 7.4 测试
- 纯函数:给定假盘口 + 名义,断言 `levels_used` / `vwap` / `shortfall`
- 方向: long 只吃 ask, short 只吃 bid
- 深度不足与刚好覆盖边界
- 不要求联调真盘口也能合入(真盘口可手工验一次 BTC/山寨对比)
---
## 8. 验收标准
1. 全仓 + 已知杠杆下,预览「计划名义」与开仓实际计仓名义同量级(允许四舍五入误差).
2. 市价空只反映买盘累加;市价多只反映卖盘累加.
3. BTC 厚盘:小名义常显示「前 1~2 档已覆盖、滑点很低」.
4. 人为放大名义或选薄流动性标的:能看到多档累加或「深度不足」.
5. 拉盘口失败时不阻断开仓按钮.
6. 中控嵌入实盘下单同样可见(与实例页同源表单).
---
## 9. 实现顺序建议
1. `lib/trade` 累加/VWAP 纯函数 + 单测
2. 一所(建议 OKX 或当前主力所)拉 book + API + 前端一行预览
3. 抽换算差异,补 Binance / Gate
4. 接入软提示阈值与文案打磨
5. 文档验收记录补进本文或 `docs/更新文档.md`
---
## 10. 二期(明确不做进首版)
| 项 | 说明 |
|----|------|
| 深度不够自动缩名义 | 类似期权 `cap_by_ask_depth` |
| 滑点超阈值二次确认 / 禁止市价 | 产品确认后再做硬门禁 |
| 平仓与止损穿档预估 | 持仓卡或平仓按钮旁 |
| WS 盘口 | 降低 REST 压力、更即时 |
| 限价开仓:挂单价相对盘口位置 | 另一套提示 |
---
## 11. 决策摘要(已拍板)
- **要做**:按计划名义展示「覆盖该名义所需」的对手盘摘要 + 预估均价/滑点.
- **做空看买单,做多看卖单**.
- **首版只展示 + 软提示,不挡单**.
- **不为小资金做整屏盘口墙**;大名义时深度预览才有关键决策价值.
+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-2` | `05864d7` | 2026-07-28 午后:振幅统计改为波动点数→振幅占比、两日振幅(例25日16:00→27日16:00);去掉买跨/永期对照 |
| `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 | | `snapshot/20260728` | `c73e363` | 2026-07-28:中控永期对冲计算器(由波动推仓位 / 由比例推点数)、说明文档 |
| `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 | | `snapshot/20260727` | `f53f281` | 2026-07-27:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
+30
View File
@@ -4,6 +4,36 @@
--- ---
## 2026-08-20 · OKX 单笔期权币本位 + USDT 桥 + 复利
### 修改原因
币本位期权流动性往往好于 USDC;操作者仍用 USDT 思考本金。需 env 切换本位、自动 USDT↔币桥、交易户 USDT×0.95 复利;对冲仍仅 USDC;中控只读识别本位;不改 Gate。
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `lib/options/options_margin_mode_lib.py` | 本位/合约族/USDT 预算/按币算张数 |
| `lib/options/options_spot_bridge_lib.py` | 买币/卖回/桥状态表/回滚 |
| `lib/options/options_coin_open_lib.py` | 买满→开满编排;平后卖回 |
| `options_register` / `okx_options_lib` / close_exec | 链族切换、开平接入、retry-sell |
| `options_hub_lib` + 中控 `app.js` / AI context | 只读字段识别币本位 |
| `hedge_plan_register` | 币本位禁止开对冲 |
| `env_*` / `.env.example` | 新 env;MARGIN_MODE 需重启;有仓拒切 |
| `docs/OKX单笔期权-币本位与USDT桥-开发方案.md` | 方案(已有) |
### 交付之后的验收
1. `OKX_OPTIONS_MARGIN_MODE=usdc` 行为与现网一致.
2. `=coin` 时链为 `ETH-USD`(非 `_UM`);开仓走买币再开期权;失败回滚卖币.
3. 平仓清空后卖回本桥币量;失败可 `POST /api/options/spot-bridge/retry-sell`.
4. 预算默认交易户 USDT×0.95;上限开关默认关.
5. 中控期权卡显示本位标签;无下单.
6. Gate 无改动.
---
## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复 ## 2026-07-19 · 期权复盘详情改为对话框 + 截图显示修复
### 修改原因 ### 修改原因
+16 -7
View File
@@ -47,6 +47,13 @@
- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时. - 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时.
- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂. - 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂.
### 2.4 翻倍出场(可选)
- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金).
- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件).
- 可随时关闭;与目标位监控并行,谁先达标谁平.
- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单.
--- ---
## 3. 监控逻辑 ## 3. 监控逻辑
@@ -58,19 +65,21 @@
| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 | | 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 |
| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) | | 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) |
| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | | 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) |
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次 | | 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 |
| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) |
| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | | 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) |
--- ---
## 4. 平仓校验(门控) ## 4. 平仓校验(门控)
| 门控 | 手动买一平 | 目标自动平 | 说明 | | 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 |
|------|------------|------------|------| |------|------------|------------|----------|------|
| 有效流动性 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 | | 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 |
| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | 通过后同仓续批只验流动性 | | 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 |
| 锁定买一价 | | ✅ | 下单价 = 通过校验时的买一 | | 回收 ≥ 权利金×(1+倍数) | ❌ | | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 |
| 市价兜底 | | | 永不市价 | | 锁定买一价 | | | ✅ | 下单价 = 通过校验时的买一 |
| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 |
--- ---
+1 -1
View File
@@ -66,7 +66,7 @@
| 网页登录密码 | ✅ | 本区块 | | 网页登录密码 | ✅ | 本区块 |
| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 | | 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 |
| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 | | 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 |
| 交易所 API | ❌ | **env 配置** 页(各所自配) | | 交易所 API | ❌ | **仅服务器** 各所 `.env`(`*_API_KEY` 等;前端 env 页已移除) |
| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) | | AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) |
### 操作流程 ### 操作流程
+39 -35
View File
@@ -1166,12 +1166,9 @@
renderListStrikes(); renderListStrikes();
renderTStrikes(); renderTStrikes();
if (d.index_px) { if (d.index_px) {
const idx = Number(d.index_px); // 盈亏比默认2,不随指数自动改写
if ($("hp-target-up") && !$("hp-target-up").value) { if ($("hp-profit-rr") && !$("hp-profit-rr").value) {
$("hp-target-up").value = String(Math.round(idx * 1.03)); $("hp-profit-rr").value = "2";
}
if ($("hp-target-down") && !$("hp-target-down").value) {
$("hp-target-down").value = String(Math.round(idx * 0.97));
} }
} }
} }
@@ -1581,8 +1578,7 @@
if ($("hp-contracts")) $("hp-contracts").value = ""; if ($("hp-contracts")) $("hp-contracts").value = "";
if ($("hp-tp")) $("hp-tp").value = ""; if ($("hp-tp")) $("hp-tp").value = "";
if ($("hp-sl")) $("hp-sl").value = ""; if ($("hp-sl")) $("hp-sl").value = "";
if ($("hp-target-up")) $("hp-target-up").value = ""; if ($("hp-profit-rr")) $("hp-profit-rr").value = "2";
if ($("hp-target-down")) $("hp-target-down").value = "";
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
if ($("hp-oo-sheets-a")) { if ($("hp-oo-sheets-a")) {
@@ -1618,16 +1614,12 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值"); throw new Error("期期两腿须为平值或虚值,不可选实值");
} }
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
body = { body = {
plan_type: "options_options", plan_type: "options_options",
target_price_up: up, profit_rr: rr,
target_price_down: down, index_px: indexPx() || 0,
target_price: up,
index_px: indexPx() || (up + down) / 2,
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
}; };
@@ -1719,22 +1711,28 @@
fmt(s.premium_paid) + fmt(s.premium_paid) +
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
} else { } else {
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total; const rrTarget = s.profit_rr != null ? s.profit_rr : null;
const dnTot = s.at_target_down_total;
let rrLine = ""; 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 = rrLine =
" · 盈亏比 上破 " + " · 盈亏比 上破 " +
fmtRr(s.rr_at_up) + 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">(亏=全额保费 ' + '<span class="muted">(亏=全额保费 ' +
fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) +
"</span>"; "</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 = summary.innerHTML =
"上破 " + (rrTarget != null ? "腿A达标 " : "上破 ") +
fmtPnlHtml(upTot) + fmtPnlHtml(aTot) +
(dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") + (bTot != null ? (rrTarget != null ? " · 腿B达标 " : " · 下破 ") + fmtPnlHtml(bTot) : "") +
" · 到期现价 " + " · 到期现价 " +
fmtPnlHtml(s.expiry_flat_total) + fmtPnlHtml(s.expiry_flat_total) +
" · 保费 " + " · 保费 " +
@@ -2057,8 +2055,7 @@
"hp-tp", "hp-tp",
"hp-sl", "hp-sl",
"hp-sheets", "hp-sheets",
"hp-target-up", "hp-profit-rr",
"hp-target-down",
]); ]);
if ($("hp-preview-btn")) if ($("hp-preview-btn"))
$("hp-preview-btn").addEventListener("click", function () { $("hp-preview-btn").addEventListener("click", function () {
@@ -2171,6 +2168,9 @@
if (p.plan_type === "perp_options") { if (p.plan_type === "perp_options") {
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); 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); 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_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿", target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中", profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
oo_rest_closed: "期期全平·两腿已平", oo_rest_closing: "期期残值平·清亏损腿中",
oo_rest_closed: "期期残值平·两腿已平",
orphaned_after_tp: "止盈后持有至到期", orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期", orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期", hold_to_expiry: "持有至到期",
@@ -2404,6 +2405,12 @@
"x · 张数 " + "x · 张数 " +
fmt(p.perp_size, 4) + fmt(p.perp_size, 4) +
"</div>"; "</div>";
} else {
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 { } else {
html += html +=
"<div><span class=\"muted\">目标价</span> 上破 " + "<div><span class=\"muted\">目标价</span> 上破 " +
@@ -2412,6 +2419,7 @@
fmt(p.target_price_down || p.target_price) + fmt(p.target_price_down || p.target_price) +
"</div>"; "</div>";
} }
}
html += html +=
"<div><span class=\"muted\">权利金合计</span> " + "<div><span class=\"muted\">权利金合计</span> " +
fmt(p.premium_total, 4) + fmt(p.premium_total, 4) +
@@ -2626,17 +2634,13 @@
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值"); throw new Error("期期两腿须为平值或虚值,不可选实值");
} }
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
body = { body = {
plan_type: "options_options", plan_type: "options_options",
underlying: state.underlying, underlying: state.underlying,
target_price_up: up, profit_rr: rr,
target_price_down: down, index_px: indexPx() || 0,
target_price: up,
index_px: indexPx() || (up + down) / 2,
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
oo_sheets_mode: state.ooSheetsMode || "same_sheets", oo_sheets_mode: state.ooSheetsMode || "same_sheets",
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
+69 -1
View File
@@ -202,6 +202,7 @@
function renderEnvFieldRow(field) { function renderEnvFieldRow(field) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : ""); row.className = "env-field-row" + (field.restart_required ? " env-field-row--restart" : "");
row.dataset.envKey = field.key;
const label = document.createElement("label"); const label = document.createElement("label");
label.className = "env-field-label"; label.className = "env-field-label";
label.htmlFor = "env-f-" + field.key; label.htmlFor = "env-f-" + field.key;
@@ -294,6 +295,10 @@
input.dataset.envKey = field.key; input.dataset.envKey = field.key;
input.className = "env-field-input"; input.className = "env-field-input";
row.appendChild(input); row.appendChild(input);
if (field.hidden) {
row.hidden = true;
row.style.display = "none";
}
return row; return row;
} }
@@ -345,9 +350,69 @@
body.appendChild(panelsWrap); body.appendChild(panelsWrap);
body.dataset.envModeSectionIdx = String(modeSectionIdx); body.dataset.envModeSectionIdx = String(modeSectionIdx);
bindTradeModeAutoRefresh(body); bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body);
bindMarginModeGateVisibility(body);
return body; return body;
} }
function envFieldRowByKey(body, key) {
if (!body || !key) return null;
const byRow = body.querySelector('.env-field-row[data-env-key="' + key + '"]');
if (byRow) return byRow;
const input = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
return input ? input.closest(".env-field-row") : null;
}
function setEnvRowHidden(row, hidden) {
if (!row) return;
row.hidden = !!hidden;
row.style.display = hidden ? "none" : "";
}
function syncCompoundBudgetVisibility(body) {
if (!body) return;
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC");
if (!budgetRow) return;
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true";
setEnvRowHidden(budgetRow, compoundOn);
}
function syncMarginModeGateVisibility(body) {
if (!body) return;
const modeSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
);
const mode = String((modeSel && modeSel.value) || "coin").toLowerCase();
const coinMode = mode !== "usdc";
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC"), coinMode);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), !coinMode);
}
function bindCompoundBudgetVisibility(body) {
if (!body) return;
syncCompoundBudgetVisibility(body);
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
compoundSel.dataset.compoundBudgetBound = "1";
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
}
function bindMarginModeGateVisibility(body) {
if (!body) return;
syncMarginModeGateVisibility(body);
const modeSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_MARGIN_MODE"]'
);
if (!modeSel || modeSel.dataset.marginGateBound === "1") return;
modeSel.dataset.marginGateBound = "1";
modeSel.addEventListener("change", () => syncMarginModeGateVisibility(body));
}
function bindTradeModeAutoRefresh(body) { function bindTradeModeAutoRefresh(body) {
const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]'); const modeSel = body.querySelector('.env-field-input[data-env-key="OKX_TRADE_MODE"]');
if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return; if (!modeSel || modeSel.dataset.modeRefreshBound === "1") return;
@@ -542,7 +607,10 @@
loadEnvConfig(false); loadEnvConfig(false);
const root = envConfigRoot(); const root = envConfigRoot();
const body = root && root.querySelector("#env-config-body"); 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__); 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; white-space: nowrap;
} }
#current-capital,
.stat-strip-item [data-funds-field="current-capital"],
.inst-phone-chip [data-funds-field="current-capital"] {
white-space: pre-line;
line-height: 1.2;
font-size: 0.82rem;
}
.stat-strip-item--primary .label { .stat-strip-item--primary .label {
font-size: 0.76rem; font-size: 0.76rem;
} }
@@ -2494,6 +2502,11 @@ html[data-theme="light"] .journal-detail-img-thumb {
min-width: 0; min-width: 0;
} }
/* display:flex 会盖掉 UA [hidden];全仓复利开时隐藏单笔预算等依赖此规则 */
.env-field-row[hidden] {
display: none !important;
}
.env-field-row--restart .env-field-label { .env-field-row--restart .env-field-label {
color: #d4c4a0; color: #d4c4a0;
} }
@@ -4419,6 +4432,9 @@ html[data-theme="light"] .opt-pending-item {
.opt-size-mode-chip { .opt-size-mode-chip {
position: relative; position: relative;
} }
.opt-size-mode-chip[hidden] {
display: none !important;
}
.opt-size-mode-chip input[type="radio"] { .opt-size-mode-chip input[type="radio"] {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
@@ -4442,6 +4458,22 @@ html[data-theme="light"] .opt-pending-item {
min-height: 32px; min-height: 32px;
box-sizing: border-box; box-sizing: border-box;
} }
.options-estimate-row .opt-profit-exit-mult,
.options-page-wrap .opt-pos-profit-exit-mult {
width: 4.5rem;
min-width: 0;
font-size: 0.8rem;
padding: 6px 8px;
min-height: 32px;
box-sizing: border-box;
}
.options-page-wrap .opt-profit-exit-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
white-space: nowrap;
}
.options-estimate-row .k { .options-estimate-row .k {
color: #8892b0; color: #8892b0;
} }
@@ -5165,36 +5197,43 @@ html[data-theme="light"] .opt-source-badge--oo {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
font-size: 0.72rem;
} }
.opt-history-table th:nth-child(1), .opt-history-table th:nth-child(1),
.opt-history-table td:nth-child(1) { .opt-history-table td:nth-child(1) {
width: 34%; width: 22%;
} }
.opt-history-table th:nth-child(2), .opt-history-table th:nth-child(2),
.opt-history-table td:nth-child(2) { .opt-history-table td:nth-child(2) {
width: 7%; width: 6%;
} }
.opt-history-table th:nth-child(3), .opt-history-table th:nth-child(3),
.opt-history-table td:nth-child(3) { .opt-history-table td:nth-child(3) {
width: 11%; width: 14%;
white-space: nowrap;
} }
.opt-history-table th:nth-child(4), .opt-history-table th:nth-child(4),
.opt-history-table td:nth-child(4) { .opt-history-table td:nth-child(4) {
width: 9%; width: 8%;
} }
.opt-history-table th:nth-child(5), .opt-history-table th:nth-child(5),
.opt-history-table td:nth-child(5) { .opt-history-table td:nth-child(5) {
width: 11%; width: 16%;
white-space: nowrap;
} }
.opt-history-table th:nth-child(6), .opt-history-table th:nth-child(6),
.opt-history-table td:nth-child(6) { .opt-history-table td:nth-child(6) {
width: 20%; width: 22%;
} }
.opt-history-table th:nth-child(7), .opt-history-table th:nth-child(7),
.opt-history-table td:nth-child(7) { .opt-history-table td:nth-child(7) {
width: 8%; width: 12%;
text-align: center; text-align: center;
} }
.opt-history-table .opt-hist-pnl {
white-space: nowrap;
}
.opt-hist-time { .opt-hist-time {
font-size: 0.64rem; font-size: 0.64rem;
white-space: nowrap; white-space: nowrap;
@@ -5240,6 +5279,86 @@ html[data-theme="light"] .opt-source-badge--oo {
.options-pos-head h2 { .options-pos-head h2 {
margin: 0; margin: 0;
} }
.options-pos-head-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
}
.opt-bridge-sell-hint {
font-size: 0.78rem;
line-height: 1.35;
max-width: 280px;
text-align: right;
}
.opt-retry-sell-coin-btn--pending {
border-color: #ffb347;
color: #ffb347;
}
.options-page-wrap .opt-pos-transfer {
margin-top: 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.options-page-wrap .opt-pos-transfer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
list-style: none;
font-weight: 600;
font-size: 0.88rem;
margin: 0 0 8px;
}
.options-page-wrap .opt-pos-transfer-head::-webkit-details-marker {
display: none;
}
.options-page-wrap .opt-pos-transfer[open] .opt-pos-transfer-closed-hint {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-open-hint {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-body {
display: none;
}
.options-page-wrap .opt-pos-transfer:not([open]) .opt-pos-transfer-head {
margin-bottom: 0;
}
.options-page-wrap .opt-pos-transfer-body .options-settings-subtitle {
margin-bottom: 6px;
}
.options-page-wrap .opt-pos-transfer-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0;
}
.options-page-wrap .opt-pos-transfer-form select,
.options-page-wrap .opt-pos-transfer-form input[type="number"] {
font-size: 0.74rem;
}
.options-page-wrap .opt-pos-transfer-form input[type="number"] {
width: 96px;
max-width: 30vw;
}
.options-page-wrap .opt-pos-xfer-msg {
margin-top: 6px;
font-size: 0.76rem;
min-height: 1.1em;
}
.options-page-wrap .opt-pos-xfer-msg.opt-error {
color: #ff7b72;
}
.options-page-wrap .opt-pos-xfer-msg.opt-success {
color: #3dd68c;
}
.opt-pos-card { .opt-pos-card {
margin-bottom: 10px; margin-bottom: 10px;
} }
File diff suppressed because it is too large Load Diff
+79 -11
View File
@@ -34,6 +34,61 @@
return Number(v).toFixed(2); return Number(v).toFixed(2);
} }
function posPremiumCcy(p) {
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
if (ccy) return ccy;
const mode = String((p && p.margin_mode) || "").toLowerCase();
const inst = String((p && p.inst_id) || "");
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
}
return "USDC";
}
function fmtPremiumAmt(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const unit = String(ccy || "USDC").toUpperCase();
if (unit === "ETH" || unit === "BTC") {
let s = n.toFixed(8).replace(/\.?0+$/, "");
return s || "0";
}
return fmtUsdc(n);
}
function spotPxOf(p) {
const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px));
return Number.isFinite(n) && n > 0 ? n : null;
}
function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) {
if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—";
const n = Number(coinAmt);
const unit = String(ccy || "ETH").toUpperCase();
if (unit !== "ETH" && unit !== "BTC") {
const sign = signed && n > 0 ? "+" : "";
return sign + fmtUsdc(n) + "U";
}
const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : "";
const coinTxt = coinSign + absCoin + " " + unit;
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
const u = n * px;
const absU = Math.abs(u).toFixed(2);
const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : "";
return coinTxt + " / " + uSign + absU + "U";
}
function fmtNetPnlDual(net, p) {
const ccy = posPremiumCcy(p);
if (ccy === "USDC") {
if (net == null || Number.isNaN(Number(net))) return "—";
return fmtUsdc(Number(net)) + "U";
}
return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true);
}
function optTypeLabel(t) { function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
} }
@@ -96,7 +151,7 @@
} }
const gate = preview.close_gate || {}; const gate = preview.close_gate || {};
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); return "目标门控: " + (preview.close_gate_msg || gate.msg || "门控未过(见 env 目标平仓门控)");
} }
return ""; return "";
} }
@@ -136,9 +191,10 @@
return (net / prem) * 100; return (net / prem) * 100;
} }
function fmtClosePreview(preview, premiumPaid, hub) { function fmtClosePreview(preview, premiumPaid, hub, p) {
if (!preview || preview.total_received == null) return "—"; if (!preview || preview.total_received == null) return "—";
const recvTxt = fmtUsdc(preview.total_received); const ccy = posPremiumCcy(p);
const recvTxt = fmtPremiumAmt(preview.total_received, ccy);
let cls = ""; let cls = "";
const prem = Number(premiumPaid); const prem = Number(premiumPaid);
const recv = Number(preview.total_received); const recv = Number(preview.total_received);
@@ -146,7 +202,7 @@
if (recv > prem) cls = " " + pnlCls(1, hub); if (recv > prem) cls = " " + pnlCls(1, hub);
else if (recv < prem) cls = " " + pnlCls(-1, hub); else if (recv < prem) cls = " " + pnlCls(-1, hub);
} }
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>"; return '<span class="opt-close-value' + cls + '">' + recvTxt + " " + ccy + "</span>";
} }
function expiryCdHtml(expMs) { function expiryCdHtml(expMs) {
@@ -168,7 +224,11 @@
const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
const closePreview = p.close_preview || {}; const closePreview = p.close_preview || {};
const tickSz = p.tick_sz; const tickSz = p.tick_sz;
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); const premCcy = posPremiumCcy(p);
const premTxt = fmtDisplay(
p.premium_paid_fmt,
p.premium_paid != null ? fmtPremiumAmt(p.premium_paid, premCcy) : null
);
const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt);
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
let headActions = ""; let headActions = "";
@@ -182,7 +242,7 @@
const pnlCells = hidePnl const pnlCells = hidePnl
? "" ? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' + : '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(net == null ? "—" : fmt(net, 2)) + "</span></div>" + (net == null ? "—" : fmtNetPnlDual(net, p)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' + '<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>"; (roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return ( return (
@@ -202,7 +262,7 @@
: "") + : "") +
"</div>" + "</div>" +
'<div class="pos-grid">' + '<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" + '<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " " + premCcy + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" + '<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" + '<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
@@ -213,7 +273,7 @@
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' + '<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
(closePreview.bid_invalid (closePreview.bid_invalid
? '<span class="muted">暂无有效买盘</span>' ? '<span class="muted">暂无有效买盘</span>'
: fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub)) + "</span></div>" + : fmtClosePreview(closePreview, hidePnl ? null : p.premium_paid, hub, p)) + "</span></div>" +
"</div>" + "</div>" +
(function () { (function () {
const hint = closeGateHint(closePreview); const hint = closeGateHint(closePreview);
@@ -226,6 +286,7 @@
const strike = Number(p.strike); const strike = Number(p.strike);
const tgt = Number(p.target_index); const tgt = Number(p.target_index);
const prem = Number(p.premium_paid); const prem = Number(p.premium_paid);
const idx = Number(p.idx_px);
let profit = null; let profit = null;
let value = null; let value = null;
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
@@ -233,10 +294,17 @@
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
if (intrinsic != null) { if (intrinsic != null) {
value = Math.round(intrinsic * eth * 100) / 100; value = Math.round(intrinsic * eth * 100) / 100;
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100; if (!hidePnl && Number.isFinite(prem)) {
let premUsd = prem;
if (premCcy !== "USDC" && Number.isFinite(idx) && idx > 0) premUsd = prem * idx;
profit = Math.round((value - premUsd) * 100) / 100;
} }
} }
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); }
const valueUnit = premCcy !== "USDC" ? " U(估)" : " USDC";
const profitTxt = profit == null
? "—"
: ((profit > 0 ? "+" : "") + fmtUsdc(profit) + (premCcy !== "USDC" ? " U(估)" : " USDC"));
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
const hedgeTarget = p.hedge_plan_target || null; const hedgeTarget = p.hedge_plan_target || null;
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
@@ -247,7 +315,7 @@
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' + '<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" + '<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" + '<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" + '<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + valueUnit) + "</span>" +
profitSpan + profitSpan +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + (managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
+4 -3
View File
@@ -49,7 +49,7 @@
if (v == null || v === "") return "—"; if (v == null || v === "") return "—";
var n = Number(v); var n = Number(v);
if (Number.isNaN(n)) return "—"; if (Number.isNaN(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2); return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
} }
function fmtHold(sec) { function fmtHold(sec) {
@@ -76,8 +76,9 @@
target_win_leg: "期期平盈利腿", target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿", target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中", profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
oo_rest_closed: "期期全平·两腿已平", oo_rest_closing: "期期残值平·清亏损腿中",
oo_rest_closed: "期期残值平·两腿已平",
orphaned_after_tp: "止盈后持有至到期", orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期", orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期", hold_to_expiry: "持有至到期",
+18
View File
@@ -95,6 +95,16 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", "OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS", "OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER",
"OKX_OPTIONS_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", "OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS", "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_LIVE_ORDER",
@@ -157,6 +167,14 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("perp_options", "永期对冲"), ("perp_options", "永期对冲"),
("options_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": ( "HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"), ("true", "以期权为主"),
("false", "保险模式"), ("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]]] = { _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [ "okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_TD_MODE", "保证金模式", ""), ("OKX_TD_MODE", "保证金模式", ""),
("OKX_POS_MODE", "持仓模式", ""), ("OKX_POS_MODE", "持仓模式", ""),
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"), ("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
@@ -34,17 +31,13 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
), ),
], ],
"binance": [ "binance": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("BINANCE_API_KEY", "API Key", "永续子账户"),
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
("BINANCE_MARGIN_MODE", "保证金模式", ""), ("BINANCE_MARGIN_MODE", "保证金模式", ""),
("BINANCE_POSITION_MODE", "持仓模式", ""), ("BINANCE_POSITION_MODE", "持仓模式", ""),
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
], ],
"gate": [ "gate": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"), ("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("GATE_API_KEY", "API Key", "永续子账户"),
("GATE_API_SECRET", "API Secret", "永续子账户"),
("GATE_TD_MODE", "保证金模式", ""), ("GATE_TD_MODE", "保证金模式", ""),
("GATE_POS_MODE", "持仓模式", ""), ("GATE_POS_MODE", "持仓模式", ""),
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"), ("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
@@ -143,8 +136,57 @@ _OPTIONS_SECTION: dict[str, Any] = {
"fields": [ "fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"), ("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""), ("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC)", ""), (
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"), "OKX_OPTIONS_MARGIN_MODE",
"单笔期权本位",
"usdc=USDⓈ权利金;coin=币本位+USDT买币桥(默认)。有持仓/半成品桥时勿切换;改后需重启",
),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅 USDC 模式且全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;USDC 打满/全仓复利与币本位复利共用"),
(
"OKX_OPTIONS_COIN_COMPOUND",
"币本位按交易户USDT复利",
"默认 true;预算=交易账户USDT×缓冲;关闭则用下方固定 USDT 预算×缓冲",
),
(
"OKX_OPTIONS_COIN_BUDGET_USDT",
"币本位固定预算(USDT)",
"仅币本位且复利关闭时生效",
),
(
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"币本位单笔上限开关",
"默认 false=靠人工转走控规模;true 时预算不超过下方 N U",
),
(
"OKX_OPTIONS_COIN_MAX_USDT",
"币本位单笔上限(USDT)",
"仅上限开关开启时生效",
),
(
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"币本位现货买入缓冲",
"相对权利金倍数,默认 1.10(=多买10%);也可写 0.10 表示+10%。按最大可开张数×卖一权利金×本缓冲买币,不全额兑换",
),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;仅 USDC 模式。开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
),
( (
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)", "期权持仓上限(笔)",
@@ -166,6 +208,36 @@ _OPTIONS_SECTION: dict[str, Any] = {
"链上仅显示有卖一", "链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
), ),
(
"OKX_OPTIONS_CLOSE_GATE_MODE",
"目标平仓门控模式",
"premium=可回收(U)≥权利金(U)×倍数;net_pnl=净盈亏(U)大于阈值。币本位按指数换算为U",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"门控权利金倍数(全局)",
"可选;填写则覆盖下方分本位默认值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"门控权利金倍数(币本位)",
"默认 1.05;premium 模式下 recyclable(U)≥premium(U)×本值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC",
"门控权利金倍数(USDC)",
"默认 2;premium 模式下 recyclable(U)≥premium(U)×本值",
),
(
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U",
"门控净盈亏下限(U)",
"net_pnl 模式;净盈亏(估)须大于本值,如 0 或 1",
),
(
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"门控持续秒数",
"达标后须持续本秒数才通过,默认 120",
),
], ],
} }
@@ -283,6 +355,11 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"HEDGE_PLAN_OPTION_PRIMARY": "true", "HEDGE_PLAN_OPTION_PRIMARY": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true", "HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true", "HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC": "2",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U": "0",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
} }
@@ -453,6 +530,7 @@ def build_env_ui_payload(
_build_field(key, label, note, schema, values) _build_field(key, label, note, schema, values)
for key, label, note in sec["fields"] for key, label, note in sec["fields"]
] ]
fields = _mark_options_env_field_visibility(fields)
groups.append({ groups.append({
"title": sec["title"], "title": sec["title"],
"fields": fields, "fields": fields,
@@ -461,6 +539,40 @@ def build_env_ui_payload(
return groups return groups
def _mark_options_env_field_visibility(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按本位/全仓复利隐藏无关项(供 SSR/前端;切换开关仍可再显示)."""
compound_on = True
margin_mode = "coin"
for f in fields:
key = f.get("key")
cur = str(f.get("current") or f.get("default") or "").strip()
if key == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
compound_on = _env_truthy(cur or "true")
elif key == "OKX_OPTIONS_MARGIN_MODE":
margin_mode = (cur or "coin").lower()
if margin_mode not in ("coin", "usdc"):
margin_mode = "coin"
out: list[dict[str, Any]] = []
for f in fields:
item = dict(f)
key = item.get("key")
hide = False
if key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
hide = True
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC" and margin_mode == "coin":
hide = True
if key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and margin_mode == "usdc":
hide = True
if hide:
item["hidden"] = True
out.append(item)
return out
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""兼容旧调用名;实际走 _mark_options_env_field_visibility."""
return _mark_options_env_field_visibility(fields)
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]: def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
allowed = ui_allowed_keys(exchange_key) allowed = ui_allowed_keys(exchange_key)
return {k: v for k, v in (updates or {}).items() if k in allowed} return {k: v for k, v in (updates or {}).items() if k in allowed}
+94
View File
@@ -0,0 +1,94 @@
"""交易所 API 凭证规范化.
新机 .env 密钥应为空;示例占位符不得注入 ccxt,否则鉴权失败且
(尤其 Gate)反复签名请求易触发 IP 封禁.
"""
from __future__ import annotations
from typing import Any, Optional
_PLACEHOLDER_EXACT = frozenset(
{
"你的密钥",
"your-api-key",
"your_api_key",
"your-api-secret",
"your_api_secret",
"todo",
"xxx",
"changeme",
}
)
def normalize_api_credential(value: Optional[str]) -> str:
"""去空白;空/占位符一律视为未配置."""
s = (value or "").strip().strip('"').strip("'")
if not s:
return ""
upper = s.upper()
if upper.startswith("REPLACE_WITH"):
return ""
if upper.startswith("CHANGE_TO"):
return ""
if s.lower() in _PLACEHOLDER_EXACT:
return ""
return s
def credentials_configured(*parts: Optional[str]) -> bool:
return all(bool(normalize_api_credential(p)) for p in parts)
def is_exchange_auth_error(exc: BaseException) -> bool:
"""鉴权/无效 Key 类错误(用于停掉后续签名请求,避免 Gate 封 IP)."""
name = type(exc).__name__
if name in ("AuthenticationError", "PermissionDenied", "InvalidNonce"):
return True
msg = str(exc)
markers = (
"Invalid Api-Key",
"Invalid API-key",
"Invalid API Key",
"INVALID_KEY",
"Invalid key",
"API key is invalid",
"api key not found",
"Signature",
"INVALID_SIGNATURE",
"401",
"-2008",
"-2014",
"-2015",
"10003", # Gate: invalid key often
"INVALID_KEY",
)
low = msg.lower()
if "api" in low and ("key" in low or "sign" in low) and (
"invalid" in low or "incorrect" in low or "not found" in low
):
return True
return any(m in msg for m in markers)
def strip_ccxt_credentials(exchange: Any) -> None:
"""内存中清空密钥,后续只走公开接口,避免继续带坏钥签名."""
try:
exchange.apiKey = ""
except Exception:
pass
try:
exchange.secret = ""
except Exception:
pass
try:
exchange.password = ""
except Exception:
pass
def load_markets_public_fallback(exchange: Any, *, reload: bool = False) -> None:
"""鉴权失败后去掉密钥再拉公开 markets(最多再请求一次)."""
strip_ccxt_credentials(exchange)
exchange.load_markets(reload=reload)
+244 -60
View File
@@ -13,24 +13,19 @@ import ccxt
from lib.options.options_pricing_lib import ( from lib.options.options_pricing_lib import (
expiry_breakeven_from_ask, expiry_breakeven_from_ask,
idx_distance_to_be, idx_distance_to_be,
intrinsic_px_per_unit,
is_shallow_itm, is_shallow_itm,
option_moneyness, option_moneyness,
option_moneyness_label, option_moneyness_label,
strike_distance_to_be,
) )
_OKX_OPTION_ERR_ZH: dict[str, str] = { _OKX_OPTION_ERR_ZH: dict[str, str] = {
"51008": "资金账户 USDT 可用余额不足", "51008": "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够;USDC 模式请确认 USDC 足够)",
"51018": "期权账户不能持有净空头头寸", "51018": "期权账户不能持有净空头头寸",
"51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)", "51019": "期权买入须使用逐仓模式(全仓模式下不能持有多头净头寸)",
} }
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
row: dict[str, Any] | None = None row: dict[str, Any] | None = None
@@ -51,10 +46,25 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
pass pass
if row: if row:
code = str(row.get("sCode") or "") code = str(row.get("sCode") or "")
msg = str(row.get("sMsg") or "").strip()
low = msg.lower()
if code == "51008":
# 勿写死「资金账户 USDT」:USDC 模式常因交易户 USDC 不足;币本位则是标的币不足
if "usdc" in low:
return "交易账户 USDC 可用余额不足"
if "usdt" in low:
return "USDT 可用余额不足"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
if is_coin_margin_mode():
return "可用余额或保证金不足(币本位请确认交易账户 ETH/BTC 足够,或减少张数)"
except Exception:
pass
return _OKX_OPTION_ERR_ZH["51008"]
zh = _OKX_OPTION_ERR_ZH.get(code) zh = _OKX_OPTION_ERR_ZH.get(code)
if zh: if zh:
return zh return zh
msg = str(row.get("sMsg") or "").strip()
if msg: if msg:
return msg return msg
if exc is not None: if exc is not None:
@@ -65,6 +75,28 @@ def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None)
return "下单失败" return "下单失败"
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
# public/instruments 全族缓存:合约列表变化慢,限频时用旧数据保活
_OPTION_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
_OPTION_INSTRUMENTS_CACHE_LOCK = threading.Lock()
_OPTION_INSTRUMENTS_CACHE_TTL = 90.0
_OPTION_INSTRUMENTS_STALE_MAX = 600.0
def invalidate_options_balance_cache() -> None:
_OPTIONS_BALANCE_CACHE["updated_at"] = 0.0
_OPTIONS_BALANCE_CACHE["data"] = None
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
with _OPTION_INSTRUMENTS_CACHE_LOCK:
if inst_family:
_OPTION_INSTRUMENTS_CACHE.pop(str(inst_family), None)
else:
_OPTION_INSTRUMENTS_CACHE.clear()
def td_mode_for_option_buy(configured: str | None = None) -> str: def td_mode_for_option_buy(configured: str | None = None) -> str:
"""OKX 买入期权(多头)必须使用逐仓.""" """OKX 买入期权(多头)必须使用逐仓."""
mode = (configured or "isolated").strip().lower() mode = (configured or "isolated").strip().lower()
@@ -141,6 +173,21 @@ def format_usdc_amount(v: float | None) -> str | None:
return f"{float(v):.2f}" return f"{float(v):.2f}"
def format_premium_amount(v: float | None, *, ccy: str | None = "USDC") -> str | None:
"""权利金/回收金额文案:USDC 2 位;币本位 ETH/BTC 最多 8 位去尾零."""
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
unit = (ccy or "USDC").strip().upper() or "USDC"
if unit in ("ETH", "BTC"):
txt = f"{n:.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{n:.2f}"
def is_option_full_close_history(raw: dict[str, Any]) -> bool: def is_option_full_close_history(raw: dict[str, Any]) -> bool:
"""仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓.""" """仅保留 OKX 历史仓位中的「全部平仓/强平/ADL 全平」记录,排除部分平仓."""
close_type = str(raw.get("type") or "").strip() close_type = str(raw.get("type") or "").strip()
@@ -206,15 +253,6 @@ def tick_sz_and_ct_mult(
return tick_sz, ct_mult or 0.01 return tick_sz, ct_mult or 0.01
def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None:
o = (opt_type or "").upper()
if o == "C" and index_px > strike:
return float(index_px) - float(strike)
if o == "P" and index_px < strike:
return float(strike) - float(index_px)
return None
def _resolve_chain_quote( def _resolve_chain_quote(
*, *,
ticker: dict[str, Any], ticker: dict[str, Any],
@@ -222,6 +260,7 @@ def _resolve_chain_quote(
opt_type: str, opt_type: str,
strike: float, strike: float,
index_px: float, index_px: float,
inst_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一).""" """链列表报价:卖一缺失时用标记价/内在价值估算(深度实值常见无卖一)."""
tick_sz = meta.get("tickSz") tick_sz = meta.get("tickSz")
@@ -231,12 +270,13 @@ def _resolve_chain_quote(
ask_sz = _safe_float(ticker.get("askSz")) ask_sz = _safe_float(ticker.get("askSz"))
bid_sz = _safe_float(ticker.get("bidSz")) bid_sz = _safe_float(ticker.get("bidSz"))
ask_estimated = False ask_estimated = False
iid = (inst_id or str(meta.get("instId") or "")).strip()
if ask is None and mark is not None and mark > 0: if ask is None and mark is not None and mark > 0:
ask = round_option_px(mark, tick_sz, "buy") ask = round_option_px(mark, tick_sz, "buy")
ask_estimated = True ask_estimated = True
if ask is None: if ask is None:
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
if intrinsic is not None and intrinsic > 0: if intrinsic is not None and intrinsic > 0:
ask = round_option_px(intrinsic, tick_sz, "buy") ask = round_option_px(intrinsic, tick_sz, "buy")
ask_estimated = True ask_estimated = True
@@ -244,7 +284,7 @@ def _resolve_chain_quote(
if bid is None and mark is not None and mark > 0: if bid is None and mark is not None and mark > 0:
bid = round_option_px(mark, tick_sz, "sell") bid = round_option_px(mark, tick_sz, "sell")
if bid is None: if bid is None:
intrinsic = _intrinsic_px_per_unit(opt_type, strike, index_px) intrinsic = intrinsic_px_per_unit(opt_type, strike, index_px, inst_id=iid or None)
if intrinsic is not None and intrinsic > 0: if intrinsic is not None and intrinsic > 0:
bid = round_option_px(intrinsic, tick_sz, "sell") bid = round_option_px(intrinsic, tick_sz, "sell")
@@ -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) family = inst_family_from_inst_id(inst_id)
if not family: if not family:
return None return None
# 优先从全族缓存取,避免每选一腿再打 instruments
try:
cached_rows = fetch_option_instruments(ex, family, allow_stale=True)
for r in cached_rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r
except Exception:
pass
last_err: BaseException | None = None last_err: BaseException | None = None
for attempt in range(3): for attempt in range(2):
try: try:
rows = ex.public_get_public_instruments( rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family, "instId": inst_id} {"instType": "OPTION", "instFamily": family, "instId": inst_id}
).get("data") or [] ).get("data") or []
if rows and isinstance(rows[0], dict): if rows and isinstance(rows[0], dict):
return rows[0] return rows[0]
rows = ex.public_get_public_instruments( rows = fetch_option_instruments(ex, family, allow_stale=True)
{"instType": "OPTION", "instFamily": family}
).get("data") or []
for r in rows: for r in rows:
if isinstance(r, dict) and str(r.get("instId")) == inst_id: if isinstance(r, dict) and str(r.get("instId")) == inst_id:
return r return r
return None return None
except Exception as e: except Exception as e:
last_err = e last_err = e
if _is_okx_rate_limit(e) and attempt < 2: if _is_okx_rate_limit(e) and attempt < 1:
time.sleep(0.45 * (attempt + 1)) time.sleep(1.2)
continue continue
break break
if last_err is not None and _is_okx_rate_limit(last_err): if last_err is not None and _is_okx_rate_limit(last_err):
@@ -480,8 +526,8 @@ def fetch_account_balances_by_type(
ex: ccxt.okx, ex: ccxt.okx,
account_type: str, account_type: str,
) -> tuple[dict[str, float | None], dict[str, float | None]]: ) -> tuple[dict[str, float | None], dict[str, float | None]]:
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try: try:
bal = ex.fetch_balance(params={"type": account_type}) bal = ex.fetch_balance(params={"type": account_type})
for c in out: for c in out:
@@ -496,8 +542,8 @@ def fetch_funding_balances_via_asset_api(
ex: ccxt.okx, ex: ccxt.okx,
) -> tuple[dict[str, float | None], dict[str, float | None]]: ) -> tuple[dict[str, float | None], dict[str, float | None]]:
"""OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确.""" """OKX 资金账户余额(GET /api/v5/asset/balances),比 ccxt fetch_balance 更准确."""
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None} avail: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None, "ETH": None, "BTC": None}
try: try:
resp = ex.private_get_asset_balances({}) resp = ex.private_get_asset_balances({})
for row in (resp or {}).get("data") or []: for row in (resp or {}).get("data") or []:
@@ -580,24 +626,34 @@ def fetch_options_balances(
funding = _merge_balance_maps(funding, asset_funding) funding = _merge_balance_maps(funding, asset_funding)
funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail) funding_avail = _merge_balance_maps(funding_avail, asset_funding_avail)
trading, trading_avail = fetch_account_balances_by_type(ex, "trading") trading, trading_avail = fetch_account_balances_by_type(ex, "trading")
if trading.get("USDC") is None: # OKX 统一账户:option 客户端拉 type=trading 常缺 USDT/币;用 swap 补齐缺失项
if any(trading.get(c) is None for c in ("USDT", "USDC", "ETH", "BTC")):
swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap") swap_bal, swap_avail = fetch_account_balances_by_type(ex, "swap")
if swap_bal.get("USDC") is not None: for ccy in ("USDT", "USDC", "USDG", "ETH", "BTC"):
trading["USDC"] = swap_bal["USDC"] if trading.get(ccy) is None and swap_bal.get(ccy) is not None:
if trading_avail.get("USDC") is None and swap_avail.get("USDC") is not None: trading[ccy] = swap_bal[ccy]
trading_avail["USDC"] = swap_avail["USDC"] if trading_avail.get(ccy) is None and swap_avail.get(ccy) is not None:
trading_avail[ccy] = swap_avail[ccy]
result = { result = {
"scope": "main", "scope": "main",
"funding_usdt": funding.get("USDT"), "funding_usdt": funding.get("USDT"),
"funding_usdc": funding.get("USDC"), "funding_usdc": funding.get("USDC"),
"funding_usdg": funding.get("USDG"), "funding_usdg": funding.get("USDG"),
"funding_eth": funding.get("ETH"),
"funding_btc": funding.get("BTC"),
"funding_usdt_avail": funding_avail.get("USDT"), "funding_usdt_avail": funding_avail.get("USDT"),
"funding_usdc_avail": funding_avail.get("USDC"), "funding_usdc_avail": funding_avail.get("USDC"),
"funding_eth_avail": funding_avail.get("ETH"),
"funding_btc_avail": funding_avail.get("BTC"),
"trading_usdt": trading.get("USDT"), "trading_usdt": trading.get("USDT"),
"trading_usdc": trading.get("USDC"), "trading_usdc": trading.get("USDC"),
"trading_usdg": trading.get("USDG"), "trading_usdg": trading.get("USDG"),
"trading_eth": trading.get("ETH"),
"trading_btc": trading.get("BTC"),
"trading_usdt_avail": trading_avail.get("USDT"), "trading_usdt_avail": trading_avail.get("USDT"),
"trading_usdc_avail": trading_avail.get("USDC"), "trading_usdc_avail": trading_avail.get("USDC"),
"trading_eth_avail": trading_avail.get("ETH"),
"trading_btc_avail": trading_avail.get("BTC"),
} }
_OPTIONS_BALANCE_CACHE["updated_at"] = now _OPTIONS_BALANCE_CACHE["updated_at"] = now
_OPTIONS_BALANCE_CACHE["data"] = result _OPTIONS_BALANCE_CACHE["data"] = result
@@ -613,22 +669,63 @@ def options_header_balances(
返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt) 返回:(trading_usdc, funding_usdc, funding_usdt, trading_usdt)
""" """
pack = options_header_balance_pack(ex, force=force)
return (
pack.get("trading_usdc"),
pack.get("funding_usdc"),
pack.get("funding_usdt"),
pack.get("trading_usdt"),
)
def options_header_balance_pack(
ex: ccxt.okx,
*,
force: bool = False,
) -> dict[str, Any]:
"""顶栏/快照用期权资金包(含币本位 ETH/BTC)."""
import os
bal = fetch_options_balances(ex, force=force) bal = fetch_options_balances(ex, force=force)
def _round(v: Any) -> float | None: def _round(v: Any, nd: int = 2) -> float | None:
if v is None: if v is None:
return None return None
try: try:
return round(float(v), 2) return round(float(v), nd)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return ( def _round_coin(v: Any) -> float | None:
_round(bal.get("trading_usdc")), if v is None:
_round(bal.get("funding_usdc")), return None
_round(bal.get("funding_usdt")), try:
_round(bal.get("trading_usdt")), return round(float(v), 8)
) except (TypeError, ValueError):
return None
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
except Exception:
margin_mode = "usdc"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
coin_key = "btc" if underly == "BTC" else "eth"
return {
"trading_usdc": _round(bal.get("trading_usdc")),
"funding_usdc": _round(bal.get("funding_usdc")),
"funding_usdt": _round(bal.get("funding_usdt")),
"trading_usdt": _round(bal.get("trading_usdt")),
"funding_eth": _round_coin(bal.get("funding_eth")),
"trading_eth": _round_coin(bal.get("trading_eth")),
"funding_btc": _round_coin(bal.get("funding_btc")),
"trading_btc": _round_coin(bal.get("trading_btc")),
"options_margin_mode": margin_mode,
"options_underly": underly,
"funding_coin": _round_coin(bal.get(f"funding_{coin_key}")),
"trading_coin": _round_coin(bal.get(f"trading_{coin_key}")),
}
def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
@@ -645,11 +742,42 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
def fetch_option_instruments( def fetch_option_instruments(
ex: ccxt.okx, ex: ccxt.okx,
inst_family: str, inst_family: str,
*,
force: bool = False,
allow_stale: bool = True,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""拉取 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( rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": inst_family} {"instType": "OPTION", "instFamily": family}
).get("data") or [] ).get("data") or []
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] live = [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
with _OPTION_INSTRUMENTS_CACHE_LOCK:
_OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live}
return list(live)
except Exception as e:
if allow_stale:
with _OPTION_INSTRUMENTS_CACHE_LOCK:
entry = _OPTION_INSTRUMENTS_CACHE.get(family)
if entry is not None and entry.get("rows") is not None:
age = now - float(entry.get("updated_at") or 0)
if age <= _OPTION_INSTRUMENTS_STALE_MAX:
return list(entry["rows"])
raise
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
@@ -674,31 +802,46 @@ def build_option_chain(
itm_only: bool = True, itm_only: bool = True,
itm_max_dist_usd: float = 30.0, itm_max_dist_usd: float = 30.0,
index_px: float | None = None, index_px: float | None = None,
margin_mode: str | None = None,
inst_family: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
u = (underlying or "ETH").upper() u = (underlying or "ETH").upper()
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" family = f"{u}-USD_UM"
uly = f"{u}-USD" uly = f"{u}-USD"
idx = index_px if index_px is not None else fetch_index_price(ex, uly) idx = index_px if index_px is not None else fetch_index_price(ex, uly)
chain_margin = "usdc" if "_UM" in family.upper() else "coin"
now_ms = time.time() * 1000 now_ms = time.time() * 1000
max_ms = now_ms + max_dte_days * 86400 * 1000 max_ms = now_ms + max_dte_days * 86400 * 1000
instruments_err = "" instruments_err = ""
instruments: list[dict[str, Any]] = [] instruments: list[dict[str, Any]] = []
for attempt in range(2):
try: try:
instruments = fetch_option_instruments(ex, family) instruments = fetch_option_instruments(ex, family)
instruments_err = "" if not instruments:
if instruments: # 空列表可能是瞬时空;短退避后强制再拉一次(非 50011)
break time.sleep(0.5)
instruments = fetch_option_instruments(ex, family, force=True)
if not instruments:
instruments_err = "期权合约列表为空" instruments_err = "期权合约列表为空"
except Exception as e: except Exception as e:
instruments = [] instruments = []
instruments_err = str(e) or e.__class__.__name__ instruments_err = str(e) or e.__class__.__name__
if attempt == 0: # 限频:再等一下用 stale/缓存,不要连打
time.sleep(0.35) if _is_okx_rate_limit(e):
continue time.sleep(1.5)
break try:
if attempt == 0 and not instruments: instruments = fetch_option_instruments(ex, family, allow_stale=True)
time.sleep(0.35) if instruments:
instruments_err = ""
except Exception as e2:
instruments_err = str(e2) or e2.__class__.__name__
tickers = fetch_option_tickers(ex, family) tickers = fetch_option_tickers(ex, family)
expiries: dict[str, list[dict[str, Any]]] = {} expiries: dict[str, list[dict[str, Any]]] = {}
skipped_no_index = 0 skipped_no_index = 0
@@ -731,6 +874,7 @@ def build_option_chain(
opt_type=opt_type, opt_type=opt_type,
strike=strike, strike=strike,
index_px=idx, index_px=idx,
inst_id=inst_id,
) )
ask = q["ask"] ask = q["ask"]
bid = q["bid"] bid = q["bid"]
@@ -742,6 +886,8 @@ def build_option_chain(
strike=strike, strike=strike,
ask_px=ask, ask_px=ask,
mark_px=mark, mark_px=mark,
inst_id=inst_id,
margin_mode=chain_margin,
) )
mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx) mny = option_moneyness(opt_type=opt_type, strike=strike, index_px=idx)
exp_key = str(exp_ms) exp_key = str(exp_ms)
@@ -758,7 +904,7 @@ def build_option_chain(
"mark_px": mark, "mark_px": mark,
"ask_estimated": q["ask_estimated"], "ask_estimated": q["ask_estimated"],
"expiry_be_px": expiry_be, "expiry_be_px": expiry_be,
"dist_expiry_be": idx_distance_to_be(idx, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=opt_type),
"moneyness": mny, "moneyness": mny,
"moneyness_label": option_moneyness_label(mny), "moneyness_label": option_moneyness_label(mny),
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
@@ -774,6 +920,8 @@ def build_option_chain(
"underlying": u, "underlying": u,
"index_px": idx, "index_px": idx,
"inst_family": family, "inst_family": family,
"margin_mode": "usdc" if "_UM" in family.upper() else "coin",
"premium_ccy": "USDC" if "_UM" in family.upper() else u,
"expiries": exp_list, "expiries": exp_list,
"instruments_count": len(instruments), "instruments_count": len(instruments),
} }
@@ -884,6 +1032,7 @@ def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
strike=strike, strike=strike,
ask_px=book_ask if can_open else None, ask_px=book_ask if can_open else None,
mark_px=mark, mark_px=mark,
inst_id=inst_id,
) )
return { return {
"ok": True, "ok": True,
@@ -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, "open_block_msg": "" if can_open else open_block_msg,
"index_px": idx, "index_px": idx,
"expiry_be_px": expiry_be, "expiry_be_px": expiry_be,
"dist_expiry_be": idx_distance_to_be(idx, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01, "ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
"min_sz": int(_safe_float(meta.get("minSz")) or 1), "min_sz": int(_safe_float(meta.get("minSz")) or 1),
"tick_sz": tick_sz, "tick_sz": tick_sz,
@@ -1312,6 +1461,15 @@ def format_option_history_row(
ctime = _safe_float(raw.get("cTime")) ctime = _safe_float(raw.get("cTime"))
opt_type, strike = option_fields_from_inst_id(inst_id) opt_type, strike = option_fields_from_inst_id(inst_id)
uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "") uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "")
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
premium_ccy = premium_ccy_for_mode(row_mode, uly or "ETH")
except Exception:
row_mode = "usdc"
premium_ccy = "USDC"
idx_px = _safe_float(raw.get("idxPx") or raw.get("idx_px"))
if close_type in ("3", "4"): if close_type in ("3", "4"):
status_label = "强平" status_label = "强平"
else: else:
@@ -1333,12 +1491,17 @@ def format_option_history_row(
"strike": strike, "strike": strike,
"sheets": sheets_i, "sheets": sheets_i,
"eth_amount": eth_amount, "eth_amount": eth_amount,
"ct_mult": ct_mult,
"open_avg_px": open_avg, "open_avg_px": open_avg,
"open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None, "open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None,
"close_avg_px": close_avg, "close_avg_px": close_avg,
"close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None, "close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None,
"premium_paid": premium_paid, "premium_paid": premium_paid,
"premium_paid_fmt": format_usdc_amount(premium_paid), "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"premium_ccy": premium_ccy,
"margin_mode": row_mode,
"margin_mode_label": "币本位" if row_mode == "coin" else "USDC",
"idx_px": idx_px,
"realized_pnl": realized, "realized_pnl": realized,
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None, "pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
"status": "closed", "status": "closed",
@@ -1361,6 +1524,7 @@ def format_live_option_history_row(
inst_id = str(row.get("inst_id") or "").strip() inst_id = str(row.get("inst_id") or "").strip()
pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None pos_id = str((row.get("raw") or {}).get("posId") or "").strip() or None
close_ms = open_ms close_ms = open_ms
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
return { return {
"source": "live", "source": "live",
"history_key": option_history_row_key( "history_key": option_history_row_key(
@@ -1382,6 +1546,10 @@ def format_live_option_history_row(
"close_avg_px_fmt": None, "close_avg_px_fmt": None,
"premium_paid": row.get("premium_paid"), "premium_paid": row.get("premium_paid"),
"premium_paid_fmt": row.get("premium_paid_fmt"), "premium_paid_fmt": row.get("premium_paid_fmt"),
"premium_ccy": premium_ccy,
"margin_mode": row.get("margin_mode"),
"margin_mode_label": row.get("margin_mode_label"),
"idx_px": row.get("idx_px"),
"realized_pnl": row.get("upl"), "realized_pnl": row.get("upl"),
"pnl_ratio_pct": row.get("upl_ratio_pct"), "pnl_ratio_pct": row.get("upl_ratio_pct"),
"status": "open", "status": "open",
@@ -1625,6 +1793,7 @@ def format_position_row(
close_breakeven_idx, close_breakeven_idx,
expiry_breakeven_px, expiry_breakeven_px,
idx_distance_to_be, idx_distance_to_be,
strike_distance_to_be,
total_premium, total_premium,
) )
@@ -1642,6 +1811,16 @@ def format_position_row(
opt_type = parsed_type opt_type = parsed_type
if strike is None: if strike is None:
strike = parsed_strike strike = parsed_strike
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = premium_ccy_for_mode(row_mode, underly)
except Exception:
row_mode = "usdc"
underly = (inst_id.split("-")[0] if inst_id else "ETH") or "ETH"
premium_ccy = "USDC"
eth_amount = round(abs(sheets) * ct_mult, 8) eth_amount = round(abs(sheets) * ct_mult, 8)
premium_paid = ( premium_paid = (
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
@@ -1652,6 +1831,8 @@ def format_position_row(
strike=strike, strike=strike,
avg_px=avg, avg_px=avg,
be_px_api=_safe_float(pos.get("bePx")), be_px_api=_safe_float(pos.get("bePx")),
inst_id=inst_id,
margin_mode=row_mode,
) )
close_be = close_breakeven_idx( close_be = close_breakeven_idx(
opt_type=str(opt_type or ""), opt_type=str(opt_type or ""),
@@ -1671,11 +1852,14 @@ def format_position_row(
"mark_px": mark, "mark_px": mark,
"avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None, "avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None,
"mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None, "mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None,
"premium_paid_fmt": format_usdc_amount(premium_paid), "premium_paid_fmt": format_premium_amount(premium_paid, ccy=premium_ccy),
"tick_sz": tick_sz, "tick_sz": tick_sz,
"ct_mult": ct_mult, "ct_mult": ct_mult,
"idx_px": idx_px, "idx_px": idx_px,
"premium_paid": premium_paid, "premium_paid": premium_paid,
"margin_mode": row_mode,
"premium_ccy": premium_ccy,
"underlying": underly,
"upl": upl, "upl": upl,
"upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None, "upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None,
"exp_time": exp_time_ms, "exp_time": exp_time_ms,
@@ -1685,7 +1869,7 @@ def format_position_row(
"avail_pos": _safe_float(pos.get("availPos")), "avail_pos": _safe_float(pos.get("availPos")),
"expiry_be_px": expiry_be, "expiry_be_px": expiry_be,
"close_be_px": close_be, "close_be_px": close_be,
"dist_expiry_be": idx_distance_to_be(idx_px, expiry_be), "dist_expiry_be": strike_distance_to_be(strike, expiry_be, opt_type=str(opt_type or "")),
"dist_close_be": idx_distance_to_be(idx_px, close_be), "dist_close_be": idx_distance_to_be(idx_px, close_be),
"raw": pos, "raw": pos,
} }
+158 -5
View File
@@ -58,6 +58,42 @@ def option_expiry_pnl(
return value - float(premium_paid) return value - float(premium_paid)
def spot_from_expiry_intrinsic_profit(
*,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
profit: float,
) -> float | None:
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
到期价值=实值×张数×乘数;盈亏=价值权利金 实值/=(profit+权利金)/(张数×乘数).
Call: spot=K+实值/; Put: spot=K实值/.
"""
try:
k = float(strike)
n = float(sheets or 0)
ct = float(ct_mult or 0.01)
prem = float(premium_paid or 0)
pnl = float(profit)
except (TypeError, ValueError):
return None
denom = n * ct
if denom <= 0:
return None
need = (pnl + prem) / denom
if need < 0:
need = 0.0
o = (opt_type or "").strip().upper()
if o in ("C", "CALL"):
return round(k + need, 2)
if o in ("P", "PUT"):
return round(k - need, 2)
return None
def suggest_contracts_from_notional( def suggest_contracts_from_notional(
*, *,
notional: float, notional: float,
@@ -447,11 +483,16 @@ def build_options_options_preview(
target_price: float | None = None, target_price: float | None = None,
target_price_up: float | None = None, target_price_up: float | None = None,
target_price_down: float | None = None, target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float, index_px: float,
leg_a: dict[str, Any], leg_a: dict[str, Any],
leg_b: dict[str, Any], leg_b: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
残值按亏损腿本合约权利金的 20% .
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float: def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl( return option_expiry_pnl(
@@ -463,15 +504,127 @@ def build_options_options_preview(
premium_paid=float(leg.get("premium_paid") or 0), premium_paid=float(leg.get("premium_paid") or 0),
) )
prem_a = float(leg_a.get("premium_paid") or 0)
prem_b = float(leg_b.get("premium_paid") or 0)
prem = prem_a + prem_b
rr = float(profit_rr) if profit_rr is not None else None
# 新:盈亏比情景(不依赖指数上下破价)
if rr is not None and rr > 0:
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
win_profit = rr * prem
a_at_a = win_profit
b_at_a_full = -prem_b
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
b_at_b = win_profit
a_at_b_full = -prem_a
a_at_b_res = -prem_a * 0.8
spot_a = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_a.get("opt_type") or ""),
strike=float(leg_a["strike"]),
sheets=float(leg_a.get("sheets") or 0),
ct_mult=float(leg_a.get("ct_mult") or 0.01),
premium_paid=prem_a,
profit=win_profit,
)
spot_b = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_b.get("opt_type") or ""),
strike=float(leg_b["strike"]),
sheets=float(leg_b.get("sheets") or 0),
ct_mult=float(leg_b.get("ct_mult") or 0.01),
premium_paid=prem_b,
profit=win_profit,
)
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"profit_rr": rr,
"target_price": None,
"target_price_up": None,
"target_price_down": None,
"winner_at_up": "a",
"winner_at_down": "b",
"winner_at_target": "a",
"scenarios": [
{
"id": "rr_leg_a_full",
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_full, 4),
"total": round(a_at_a + b_at_a_full, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_b_full",
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
"spot": spot_b,
"leg_a_pnl": round(a_at_b_full, 4),
"leg_b_pnl": round(b_at_b, 4),
"total": round(a_at_b_full + b_at_b, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_a_residual",
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_res, 4),
"total": round(a_at_a + b_at_a_res, 4),
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
},
{
"id": "expiry_flat",
"label": "到期·现价",
"spot": index_px,
"leg_a_pnl": round(a_flat, 4),
"leg_b_pnl": round(b_flat, 4),
"total": round(flat_total, 4),
"note": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"profit_rr": rr,
"spot_at_rr_a": spot_a,
"spot_at_rr_b": spot_b,
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
"at_target_total": round(a_at_a + b_at_a_full, 4),
"expiry_flat_total": round(flat_total, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
"rr_risk_premium": round(prem, 6),
"rr_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
},
}
# 兼容旧单目标:若未传上下目标则用 target_price 填两边 # 兼容旧单目标:若未传上下目标则用 target_price 填两边
up = target_price_up if target_price_up is not None else target_price up = target_price_up if target_price_up is not None else target_price
down = target_price_down if target_price_down is not None else target_price down = target_price_down if target_price_down is not None else target_price
if up is None or down is None: if up is None or down is None:
raise ValueError("缺少上破/下破目标价") raise ValueError("缺少盈亏比或上破/下破目标价")
up_f = float(up) up_f = float(up)
down_f = float(down) down_f = float(down)
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
a_up = _leg_pnl(leg_a, up_f) a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f) b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up at_up = a_up + b_up
@@ -528,8 +681,8 @@ def build_options_options_preview(
"id": "max_premium_loss", "id": "max_premium_loss",
"label": "最大保费损耗", "label": "最大保费损耗",
"spot": None, "spot": None,
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4), "leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4), "leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4), "total": round(-prem, 4),
"note": "双腿权利金全部损失", "note": "双腿权利金全部损失",
}, },
+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_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL") _ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状) # 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
# 永期「以期权为主」 # 永期「以期权为主」
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER") _ensure_column(conn, "hedge_plans", "option_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]]: def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。 """返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
这些目标由 hedge_plan_monitor_lib 执行绝不能写入 options_target_monitors 这些目标由 hedge_plan_monitor_lib 执行绝不能写入 options_target_monitors
否则两套监控会同时尝试平掉同一条期权腿 否则两套监控会同时尝试平掉同一条期权腿
@@ -272,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
l.inst_id, l.opt_type p.profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options' WHERE p.plan_type = 'options_options'
@@ -288,9 +290,24 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
row = dict(raw) row = dict(raw)
inst_id = str(row.get("inst_id") or "") inst_id = str(row.get("inst_id") or "")
opt_type = str(row.get("opt_type") or "").upper() opt_type = str(row.get("opt_type") or "").upper()
if not inst_id or inst_id in out:
continue
profit_rr = _sf(row.get("profit_rr"))
if profit_rr is not None and profit_rr > 0:
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"profit_rr": profit_rr,
"target_index": None,
"exit_mode": "profit_rr",
"managed_by": "hedge_plan",
}
continue
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target) target_f = _sf(target)
if not inst_id or target_f is None or target_f <= 0 or inst_id in out: if target_f is None or target_f <= 0:
continue continue
out[inst_id] = { out[inst_id] = {
"plan_id": int(row["plan_id"]), "plan_id": int(row["plan_id"]),
+228 -52
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: def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
"""盈利腿平后另一腿:close_all(平) / hold_expiry(到期平). """盈利腿平后另一腿:close_all(残值平) / hold_expiry(到期平).
- 方案C关闭 强制到期平 - 方案C关闭 强制到期平
- 计划未写 oo_close_mode(旧单) 到期平,避免误清残腿 - 计划未写 oo_close_mode(旧单) 到期平,避免误清残腿
- 新开仓默认写入 close_all - 新开仓默认写入 close_all(残值平:权利金初始20%且有买一)
""" """
if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True): if not _env_bool("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", True):
return "hold_expiry" return "hold_expiry"
@@ -190,6 +190,12 @@ def resolve_oo_rest_close_mode(plan: dict[str, Any]) -> str:
return "close_all" 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]]: def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) -> list[dict[str, Any]]:
out = [] out = []
for x in legs: for x in legs:
@@ -200,6 +206,63 @@ def _oo_option_legs(legs: list[dict[str, Any]], *, statuses: tuple[str, ...]) ->
return out 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( def _finalize_oo_all_closed(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]], *, reason: str
) -> dict[str, Any]: ) -> 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( def _tick_oo_close_rest(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[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": if resolve_oo_rest_close_mode(plan) != "close_all":
return None return None
open_legs = _oo_option_legs(legs, statuses=("open",)) open_legs = _oo_option_legs(legs, statuses=("open",))
@@ -998,6 +1066,7 @@ def _tick_oo_close_rest(
"target_win_leg", "target_win_leg",
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing", "oo_rest_closing",
"", "",
) )
@@ -1008,23 +1077,45 @@ def _tick_oo_close_rest(
idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
acted = False acted = False
waiting = False
for leg in list(open_legs): for leg in list(open_legs):
close_r = _sell_option( inst_id = str(leg.get("inst_id") or "")
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1) 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"): if not close_r.get("ok"):
notify_hedge( notify_hedge(
cfg, cfg,
build_hedge_alert_message( build_hedge_alert_message(
title="期期全平·残腿平仓失败(将重试)", title="期期残值平·亏损腿平仓失败(将重试)",
plan_id=plan.get("id"), plan_id=plan.get("id"),
detail=str(close_r.get("msg") or close_r), detail=str(close_r.get("msg") or close_r),
), ),
) )
update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing") update_plan(conn, int(plan["id"]), close_reason="oo_rest_closing")
return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True} return {"plan_id": plan["id"], "msg": "残腿平仓失败", "close": close_r, "retry": True}
bid = _sf(close_r.get("bid")) bid_fill = _sf(close_r.get("bid")) or bid
est = _estimate_leg_close_pnl(leg, idx, bid) est = _estimate_leg_close_pnl(leg, idx, bid_fill)
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
conn.execute( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "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 acted = True
if not acted: 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 return None
legs2 = get_plan_legs(conn, int(plan["id"])) legs2 = get_plan_legs(conn, int(plan["id"]))
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry")) 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( def _tick_oo_target(
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
) -> Optional[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")) idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
if idx is None: if idx is None:
return None return None
@@ -1065,10 +1275,8 @@ def _tick_oo_target(
return None return None
hit_side: Optional[str] = None hit_side: Optional[str] = None
# 上破:现价接近或超过上破目标
if up is not None and idx >= up * 0.998: if up is not None and idx >= up * 0.998:
hit_side = "up" hit_side = "up"
# 下破:现价接近或低于下破目标
elif down is not None and idx <= down * 1.002: elif down is not None and idx <= down * 1.002:
hit_side = "down" hit_side = "down"
if not hit_side: if not hit_side:
@@ -1102,52 +1310,20 @@ def _tick_oo_target(
) )
return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r} return {"plan_id": plan["id"], "msg": "平盈利腿失败", "close": close_r}
reason = "target_up_win_leg" if hit_side == "up" else "target_down_win_leg" 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)) closed_pnl = _option_leg_pnl_after_close(cfg, best, fallback=float(best_pnl))
conn.execute( conn.execute(
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
("closed", reason, _now(), closed_pnl, best["id"]), ("closed", reason, _now(), closed_pnl, best["id"]),
) )
rest_mode = resolve_oo_rest_close_mode(plan) return _after_oo_winner_closed(
update_plan(conn, int(plan["id"]), close_reason=reason) cfg,
mid = dict(plan) conn,
mid["close_reason"] = reason plan,
mid["status"] = "active" open_legs,
mid["oo_close_mode"] = rest_mode best,
notify_plan_end(cfg, conn, mid) reason=reason,
extra={"hit_side": hit_side, "index": idx},
# 全平:同轮尝试清残腿;失败则下轮 _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,
}
def _tick_oo_expiry( def _tick_oo_expiry(
+33 -11
View File
@@ -45,6 +45,15 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
] ]
) )
else:
rr = plan.get("profit_rr")
if rr not in (None, ""):
lines.extend(
[
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else: else:
lines.extend( lines.extend(
[ [
@@ -81,8 +90,9 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
"target_win_leg": "期期已平盈利腿(中间态)", "target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿", "target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿",
"oo_rest_closing": "期期全平·清残腿中", "profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
"oo_rest_closed": "期期全平·两腿已平", "oo_rest_closing": "期期残值平·清亏损腿中",
"oo_rest_closed": "期期残值平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损", "oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利", "oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口", "expiry": "到期收口",
@@ -152,25 +162,37 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
"target_win_leg", "target_win_leg",
"target_up_win_leg", "target_up_win_leg",
"target_down_win_leg", "target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing", "oo_rest_closing",
) and (plan.get("status") or "") != "closed": ) and (plan.get("status") or "") != "closed":
side = "上破" if "up" in str(plan.get("close_reason")) else ( cr = str(plan.get("close_reason") or "")
"下破" if "down" in str(plan.get("close_reason")) else "目标价" if "profit_rr" in cr:
) side = "盈亏比达标"
elif "up" in cr:
side = "上破"
elif "down" in cr:
side = "下破"
else:
side = "目标"
mode = (plan.get("oo_close_mode") or "").strip().lower() mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平"): if mode in ("close_all", "全平", "残值平"):
rest_txt = "另一腿将全平(买一清残腿,无2×门控,失败重试)" rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
else: else:
rest_txt = "另一腿到期平(持有至到期结算)" rest_txt = "另一腿到期平(持有至到期结算)"
rr = plan.get("profit_rr")
if rr not in (None, ""):
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
else:
detail = (
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
)
notify_hedge( notify_hedge(
cfg, cfg,
build_hedge_alert_message( build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}", title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"), plan_id=plan.get("id"),
detail=( detail=detail,
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
),
), ),
) )
return True return True
+11 -6
View File
@@ -1146,6 +1146,16 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
b = body.get("leg_b") or {} b = body.get("leg_b") or {}
if not a.get("inst_id") or not b.get("inst_id"): if not a.get("inst_id") or not b.get("inst_id"):
return "请选用两条期权腿" return "请选用两条期权腿"
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") up = body.get("target_price_up")
down = body.get("target_price_down") down = body.get("target_price_down")
legacy = body.get("target_price") legacy = body.get("target_price")
@@ -1154,7 +1164,7 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
if down in (None, "") and legacy not in (None, ""): if down in (None, "") and legacy not in (None, ""):
down = legacy down = legacy
if up in (None, "") or down in (None, ""): if up in (None, "") or down in (None, ""):
return "请填写上破与下破目标价" return "请填写盈亏比"
try: try:
if float(up) <= float(down): if float(up) <= float(down):
return "上破目标价必须大于下破目标价" return "上破目标价必须大于下破目标价"
@@ -1179,11 +1189,6 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
return {"opt_type": opt_type, "strike": strike} return {"opt_type": opt_type, "strike": strike}
index_px = body.get("index_px") 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( money_err = validate_oo_legs_moneyness(
_leg_for_money(a), _leg_for_money(a),
_leg_for_money(b), _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) + ( premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
float(b.get("premium") or 0) if b_ok else 0.0 float(b.get("premium") or 0) if b_ok else 0.0
) )
rr_raw = body.get("profit_rr")
try:
profit_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
except (TypeError, ValueError):
profit_rr = 2.0
if profit_rr <= 0:
profit_rr = 2.0
# 旧字段兼容:不再要求上/下破;有传则原样落库
def _opt_float(key: str, *alts: str) -> float | None:
for k in (key, *alts):
v = body.get(k)
if v not in (None, ""):
try:
return float(v)
except (TypeError, ValueError):
continue
return None
up_f = _opt_float("target_price_up", "target_price")
down_f = _opt_float("target_price_down", "target_price")
plan_id = insert_plan( plan_id = insert_plan(
conn, conn,
{ {
"plan_type": "options_options", "plan_type": "options_options",
"status": "partial" if is_partial else "active", "status": "partial" if is_partial else "active",
"underlying": str(body.get("underlying") or "ETH").upper(), "underlying": str(body.get("underlying") or "ETH").upper(),
"target_price": float( "target_price": up_f,
body.get("target_price_up") "target_price_up": up_f,
or body.get("target_price") "target_price_down": down_f,
or 0 "profit_rr": profit_rr,
),
"target_price_up": float(
body.get("target_price_up")
or body.get("target_price")
or 0
),
"target_price_down": float(
body.get("target_price_down")
or body.get("target_price")
or 0
),
"sizing_mode_at_open": load_position_sizing_mode(), "sizing_mode_at_open": load_position_sizing_mode(),
"premium_total": premium, "premium_total": premium,
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
@@ -817,6 +826,20 @@ def register_hedge_plan_routes(app: Flask, cfg: dict[str, Any]) -> None:
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
plan_type = (body.get("plan_type") or "perp_options").strip().lower() plan_type = (body.get("plan_type") or "perp_options").strip().lower()
dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False) dry_run = bool(body.get("dry_run")) or _env_bool("HEDGE_PLAN_DRY_RUN", False)
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(): with _hedge_start_lock():
gates = _gates_dict(cfg, plan_type) gates = _gates_dict(cfg, plan_type)
if not dry_run and not gates.get("can_start"): 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]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
rr_raw = body.get("profit_rr")
profit_rr = None
if rr_raw not in (None, ""):
profit_rr = float(rr_raw)
if profit_rr <= 0:
raise ValueError("盈亏比须大于0")
up = body.get("target_price_up") up = body.get("target_price_up")
down = body.get("target_price_down") down = body.get("target_price_down")
legacy = body.get("target_price") legacy = body.get("target_price")
@@ -1245,13 +1274,19 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
up = legacy up = legacy
if down in (None, "") and legacy not in (None, ""): if down in (None, "") and legacy not in (None, ""):
down = legacy down = legacy
if up in (None, "") or down in (None, ""): if profit_rr is None and (up in (None, "") or down in (None, "")):
raise ValueError("请填写上破与下破目标价") raise ValueError("请填写盈亏比")
up_f = float(up) up_f = float(up) if up not in (None, "") else None
down_f = float(down) down_f = float(down) if down not in (None, "") else None
if up_f <= down_f: if profit_rr is None and up_f is not None and down_f is not None and up_f <= down_f:
raise ValueError("上破目标价必须大于下破目标价") raise ValueError("上破目标价必须大于下破目标价")
index_px = float(body.get("index_px") or ((up_f + down_f) / 2)) index_px = body.get("index_px")
if index_px in (None, ""):
if up_f is not None and down_f is not None:
index_px = (up_f + down_f) / 2
else:
raise ValueError("缺少指数价格")
index_px = float(index_px)
leg_a = body.get("leg_a") or {} leg_a = body.get("leg_a") or {}
leg_b = body.get("leg_b") or {} leg_b = body.get("leg_b") or {}
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
@@ -1269,6 +1304,7 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
if money_err: if money_err:
raise ValueError(money_err) raise ValueError(money_err)
return build_options_options_preview( return build_options_options_preview(
profit_rr=profit_rr,
target_price_up=up_f, target_price_up=up_f,
target_price_down=down_f, target_price_down=down_f,
index_px=index_px, index_px=index_px,
@@ -213,7 +213,7 @@
<div class="tip-collapse-body rule-tip"> <div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p> <p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p> <p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p> <p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
</div> </div>
</details> </details>
<div class="form-row hp-uly-row"> <div class="form-row hp-uly-row">
@@ -221,8 +221,7 @@
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button> <button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div> </div>
<div class="form-row hp-target-row hp-oo-target-row"> <div class="form-row hp-target-row hp-oo-target-row">
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label> <label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span> <span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
</div> </div>
<div class="hp-oo-controls"> <div class="hp-oo-controls">
@@ -237,7 +236,7 @@
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row"> <div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span> <span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式"> <div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后立刻买一清另一腿(无2×,失败重试)"><span class="hp-oo-check" aria-hidden="true"></span></button> <button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true"></span>残值</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button> <button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true"></span>到期平</button>
</div> </div>
</div> </div>
+65 -8
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]: def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]:
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U."""
snap = options_snap if isinstance(options_snap, dict) else {} snap = options_snap if isinstance(options_snap, dict) else {}
if snap.get("enabled") is False or snap.get("ok") is False: if snap.get("enabled") is False or snap.get("ok") is False:
return None return None
upl = snap.get("upl_total_usdc")
if upl is not None: def _safe(v: Any) -> float | None:
if v is None or v == "":
return None
try: try:
return round(float(upl), 4) return float(v)
except (TypeError, ValueError): 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: try:
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_from_option_row
total = 0.0 total_u = 0.0
found = False found = False
missing_fx = False
for p in snap.get("positions") or []: for p in snap.get("positions") or []:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
@@ -96,9 +125,37 @@ def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[floa
if pnl is None: if pnl is None:
continue continue
found = True found = True
total += float(pnl) if snap_coin or _row_is_coin(p):
return round(total, 4) if found else None 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: except Exception:
pass
upl = snap.get("upl_total_usdc")
if upl is not None:
try:
raw = float(upl)
except (TypeError, ValueError):
return None
if snap_coin or any(
isinstance(p, dict) and _row_is_coin(p) for p in (snap.get("positions") or [])
):
if idx is None or idx <= 0:
return None
return round(raw * float(idx), 4)
return round(raw, 4)
return None return None
+45
View File
@@ -0,0 +1,45 @@
"""中控后台轮询等待:防止 request_refresh 连锁打满 CPU."""
from __future__ import annotations
import asyncio
import time
async def wait_poll_interval(
*,
refresh: asyncio.Event,
stop: asyncio.Event,
interval_sec: float,
started_at: float,
min_early_wake_sec: float | None = None,
) -> None:
"""距 started_at 至少间隔 interval_sec 再进入下一轮.
期间若收到 refresh:仅当已过 min_early_wake_sec 才提前结束(兼顾手动刷新与防抖).
"""
interval = max(0.05, float(interval_sec))
min_early = (
float(min_early_wake_sec)
if min_early_wake_sec is not None
else min(2.0, interval * 0.4)
)
while not stop.is_set():
left = interval - (time.monotonic() - started_at)
if left <= 0:
return
refresh.clear()
stop_task = asyncio.create_task(stop.wait())
refresh_task = asyncio.create_task(refresh.wait())
done, pending = await asyncio.wait(
{stop_task, refresh_task},
timeout=left,
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
if stop.is_set():
return
if not done:
return
if refresh_task in done and (time.monotonic() - started_at) >= min_early:
return
+51 -3
View File
@@ -121,20 +121,40 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
return default return default
def _format_profit_exit_mult(mult: Any) -> str:
try:
n = float(mult)
except (TypeError, ValueError):
return "1倍"
if n <= 0:
return "1倍"
if abs(n - round(n)) < 1e-9:
return f"{int(round(n))}"
return f"{n:g}"
def _format_options_target(p: dict[str, Any]) -> str: def _format_options_target(p: dict[str, Any]) -> str:
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
if hedge: if hedge:
rr = _safe_float(hedge.get("profit_rr"))
pid = hedge.get("plan_id")
if rr is not None and rr > 0:
return f"对冲#{pid} 盈亏比 {rr:g}" if pid is not None else f"盈亏比 {rr:g}"
ot = str(hedge.get("opt_type") or opt_type).upper() ot = str(hedge.get("opt_type") or opt_type).upper()
side = "Put ≤" if ot == "P" else "Call ≥" side = "Put ≤" if ot == "P" else "Call ≥"
tgt = _safe_float(hedge.get("target_index")) tgt = _safe_float(hedge.get("target_index"))
pid = hedge.get("plan_id")
if tgt is not None: if tgt is not None:
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
parts: list[str] = []
tgt = _safe_float(p.get("target_index")) tgt = _safe_float(p.get("target_index"))
if tgt is not None and tgt > 0: if tgt is not None and tgt > 0:
side = "Put ≤" if opt_type == "P" else "Call ≥" side = "Put ≤" if opt_type == "P" else "Call ≥"
return f"{side} {tgt:g}" parts.append(f"{side} {tgt:g}")
if p.get("profit_exit_enabled"):
parts.append(_format_profit_exit_mult(p.get("profit_exit_mult")))
if parts:
return " · ".join(parts)
return "" return ""
@@ -350,11 +370,39 @@ def collect_options_items(
raw = fetch_options_positions() or [] raw = fetch_options_positions() or []
except Exception: except Exception:
return [] return []
pe_map: dict[str, dict[str, Any]] = {}
tgt_map: dict[str, dict[str, Any]] = {}
hedge_map: dict[str, dict[str, Any]] = {}
if conn is not None:
try:
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import targets_by_inst
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
pe_map = profit_exit_by_inst(conn)
tgt_map = targets_by_inst(conn)
hedge_map = active_options_targets_by_inst(conn)
except Exception:
pe_map, tgt_map, hedge_map = {}, {}, {}
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for p in raw: for p in raw:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
out.append(_format_options_item(p, conn=conn)) row = dict(p)
inst = str(row.get("inst_id") or row.get("instId") or "").strip()
mon = tgt_map.get(inst)
if mon:
row["target_index"] = mon.get("target_index")
pe = pe_map.get(inst)
if pe:
row["profit_exit_enabled"] = pe.get("profit_exit_enabled")
row["profit_exit_mult"] = pe.get("profit_exit_mult")
hedge = hedge_map.get(inst)
if hedge:
row["hedge_plan_target"] = hedge
if not mon:
row["target_index"] = hedge.get("target_index")
out.append(_format_options_item(row, conn=conn))
return out return out
+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( def options_funding_label(
funding_usdc: float | None, funding_usdc: float | None,
funding_usdt: float | None = None, funding_usdt: float | None = None,
funding_eth: float | None = None,
margin_mode: str | None = None,
underly: str = "ETH",
) -> str: ) -> str:
"""期权侧顶栏仅展示 USDC(USDT 归永续资金/交易账户).funding_usdt 参数保留兼容,忽略.""" """期权侧顶栏文案(仅 USDC 模式使用;币本位不展示期权资金/交易两列)."""
_ = funding_usdt
if funding_usdc is None: if funding_usdc is None:
return "" return ""
try: try:
@@ -114,6 +116,61 @@ def options_funding_label(
return "" 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( def total_funds_usdt(
funding_usdt: float | None, funding_usdt: float | None,
trading_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: def embed_context_extras(exchange_key: str) -> dict:
# 顶栏共享模板调用 trading_account_label / options_funding_label;
# 须注入三所,否则 Gate/Binance 渲染会 UndefinedError → HTTP 500.
from lib.instance.instance_embed_context_lib import (
options_funding_label,
trading_account_label,
)
return { return {
"order_rule_tips_tpl": order_rule_tips_template(exchange_key), "order_rule_tips_tpl": order_rule_tips_template(exchange_key),
"include_transfer_block": include_transfer_block(exchange_key), "include_transfer_block": include_transfer_block(exchange_key),
"ui_open_guard_enabled": ui_open_guard_enabled(exchange_key), "ui_open_guard_enabled": ui_open_guard_enabled(exchange_key),
"ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key), "ui_orphan_recovery_enabled": ui_orphan_recovery_enabled(exchange_key),
"pwa_app_name": pwa_app_name(exchange_key), "pwa_app_name": pwa_app_name(exchange_key),
"options_funding_label": options_funding_label,
"trading_account_label": trading_account_label,
} }
@@ -106,6 +106,24 @@ def register_instance_settings_routes(
clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path) clean = coerce_hedge_partial_close_with_manual(clean, env_path=env_path)
if not clean: if not clean:
return jsonify({"ok": True, "changed_keys": [], "restart_required": False}) return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
if "OKX_OPTIONS_MARGIN_MODE" in clean:
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
from lib.options.options_spot_bridge_lib import mode_switch_block_msg
lines = read_env_lines(env_path)
old_mode = normalize_options_margin_mode(env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "coin")
new_mode = normalize_options_margin_mode(clean.get("OKX_OPTIONS_MARGIN_MODE"))
if old_mode != new_mode:
conn_m = get_db()
try:
block = mode_switch_block_msg(conn_m, None)
if block:
return jsonify({"ok": False, "msg": block}), 400
finally:
conn_m.close()
except Exception as e:
return jsonify({"ok": False, "msg": f"本位切换校验失败: {e}"}), 400
changed = apply_env_updates(env_path, clean) changed = apply_env_updates(env_path, clean)
groups = parse_env_example_schema(example_path) groups = parse_env_example_schema(example_path)
reload_info = apply_env_reload(env_path, get_db, changed, groups) reload_info = apply_env_reload(env_path, get_db, changed, groups)
+140 -20
View File
@@ -1075,7 +1075,7 @@ function refreshOrderDefaults(){
}).catch(()=>{}); }).catch(()=>{});
} }
function paintRealtimePnl(v){ function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]'); const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return; if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){ if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1086,23 +1086,48 @@ function paintRealtimePnl(v){
return; return;
} }
const n = Number(v); const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : ""; const sign = n > 0 ? "+" : "";
const text = `${sign}${n.toFixed(2)}U`; let text;
let tone = n;
if (u === "ETH" || u === "BTC") {
// 顶栏实时盈亏只显示 U(按指数/现货换算),不展示币数量
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
tone = uu;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${uSign}${uAbs}U`;
} else {
text = "—";
tone = 0;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
nodes.forEach((pnlEl) => { nodes.forEach((pnlEl) => {
pnlEl.innerText = text; pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", n > 0); pnlEl.classList.toggle("pnl-pos", tone > 0);
pnlEl.classList.toggle("pnl-neg", n < 0); pnlEl.classList.toggle("pnl-neg", tone < 0);
}); });
} }
let lastRealtimePnl = null; let lastRealtimePnl = null;
function updateRealtimePnl(v){ let lastRealtimePnlUnit = "U";
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){ if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v); lastRealtimePnl = Number(v);
paintRealtimePnl(v); if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return; return;
} }
if(lastRealtimePnl != null) return; if(lastRealtimePnl != null) return;
paintRealtimePnl(v); paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
} }
function sumOrdersFloatPnl(orders){ function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null; if(!orders || !orders.length) return null;
@@ -1130,19 +1155,76 @@ function paintRealtimePnlFromSnapshot(data){
const perp = data.order_prices && data.order_prices.length const perp = data.order_prices && data.order_prices.length
? sumOrdersFloatPnl(data.order_prices) ? sumOrdersFloatPnl(data.order_prices)
: null; : null;
const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl); const opt = data.options_unrealized_pnl;
if(combined !== null || perp !== null || data.options_unrealized_pnl != null){ const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
paintRealtimePnl(combined); const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) {
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0);
pnlEl.classList.toggle("pnl-neg", totalU < 0);
});
return;
}
paintRealtimePnl(opt, underly, spotPx);
return;
}
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) {
return;
}
}
const combined = combineRealtimeFloatPnl(perp, opt);
if(combined !== null || perp !== null || opt != null){
paintRealtimePnl(combined, "U");
} }
} }
function formatOptionsFundingLabel(usdc, usdt) { function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
if (usdc === null || usdc === undefined || usdc === "") return "—"; if (usdc === null || usdc === undefined || usdc === "") return "—";
const n = Number(usdc); const n = Number(usdc);
if (Number.isNaN(n)) return "—"; if (Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`; 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){ function setFundsFieldText(field, text){
if(text == null || text === "") return; if(text == null || text === "") return;
@@ -1156,6 +1238,11 @@ function applyPerpFundsVisibility(show){
el.style.display = on ? "" : "none"; el.style.display = on ? "" : "none";
}); });
} }
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){ function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true; if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){ if(data.show_perp_funds === false){
@@ -1175,9 +1262,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0; let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if(typeof data.show_perp_funds !== "undefined"){ 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 !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} }
@@ -1185,18 +1276,47 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} }
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`); setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
} }
if(data.options_funding_usdc != null || data.options_funding_usdt != null){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt); const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding); setFundsFieldText("options-funding-usdc", optFunding);
} }
if(data.options_trading_usdc != null || data.options_trading_usdt != null){ if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt); const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
updateRealtimePnl(data.unrealized_pnl); const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) {
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
} }
if(typeof data.total !== "undefined" && data.total !== null){ if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total)); setFundsFieldText("stat-total", String(data.total));
+2 -2
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=13"> <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/account_risk_badge.js?v=4"></script>
<script src="/static/open_submit_gate.js?v=1"></script> <script src="/static/open_submit_gate.js?v=1"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
@@ -170,7 +170,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script> <script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </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_live.js?v=6"></script>
<script src="/static/instance_embed.js?v=31"></script> <script src="/static/instance_embed.js?v=31"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script> <script src="/static/instance_mobile_nav.js?v=2"></script>
+1 -1
View File
@@ -37,7 +37,7 @@
{% endif %} {% endif %}
<div class="env-form-grid"> <div class="env-form-grid">
{% for field in group.fields %} {% for field in group.fields %}
<div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}"> <div class="env-field-row{% if field.restart_required %} env-field-row--restart{% endif %}" data-env-key="{{ field.key }}"{% if field.hidden %} hidden style="display:none"{% endif %}>
<label class="env-field-label" for="env-f-{{ field.key }}"> <label class="env-field-label" for="env-f-{{ field.key }}">
{{ field.label or field.key }} {{ field.label or field.key }}
{% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %} {% if field.restart_required %}<span class="env-restart-mark" title="需重启">*</span>{% endif %}
+144 -22
View File
@@ -19,7 +19,7 @@
<link rel="manifest" href="/static/icons/manifest.webmanifest"> <link rel="manifest" href="/static/icons/manifest.webmanifest">
<title>{{ pwa_app_name }}</title> <title>{{ pwa_app_name }}</title>
<link rel="stylesheet" href="/static/instance_page.css?v=13"> <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> </head>
<body <body
@@ -1556,7 +1556,7 @@ function refreshOrderDefaults(){
}).catch(()=>{}); }).catch(()=>{});
} }
function paintRealtimePnl(v){ function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]'); const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return; if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){ if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1567,23 +1567,48 @@ function paintRealtimePnl(v){
return; return;
} }
const n = Number(v); const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : ""; const sign = n > 0 ? "+" : "";
const text = `${sign}${n.toFixed(2)}U`; let text;
let tone = n;
if (u === "ETH" || u === "BTC") {
// 顶栏实时盈亏只显示 U(按指数/现货换算),不展示币数量
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
tone = uu;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${uSign}${uAbs}U`;
} else {
text = "—";
tone = 0;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
nodes.forEach((pnlEl) => { nodes.forEach((pnlEl) => {
pnlEl.innerText = text; pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", n > 0); pnlEl.classList.toggle("pnl-pos", tone > 0);
pnlEl.classList.toggle("pnl-neg", n < 0); pnlEl.classList.toggle("pnl-neg", tone < 0);
}); });
} }
let lastRealtimePnl = null; let lastRealtimePnl = null;
function updateRealtimePnl(v){ let lastRealtimePnlUnit = "U";
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){ if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v); lastRealtimePnl = Number(v);
paintRealtimePnl(v); if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return; return;
} }
if(lastRealtimePnl != null) return; if(lastRealtimePnl != null) return;
paintRealtimePnl(v); paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
} }
function sumOrdersFloatPnl(orders){ function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null; if(!orders || !orders.length) return null;
@@ -1611,19 +1636,77 @@ function paintRealtimePnlFromSnapshot(data){
const perp = data.order_prices && data.order_prices.length const perp = data.order_prices && data.order_prices.length
? sumOrdersFloatPnl(data.order_prices) ? sumOrdersFloatPnl(data.order_prices)
: null; : null;
const combined = combineRealtimeFloatPnl(perp, data.options_unrealized_pnl); const opt = data.options_unrealized_pnl;
if(combined !== null || perp !== null || data.options_unrealized_pnl != null){ const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
paintRealtimePnl(combined); const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) {
// 币本位期权盈亏单位为币,勿与永续 U 混加成「xxU」
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0);
pnlEl.classList.toggle("pnl-neg", totalU < 0);
});
return;
}
paintRealtimePnl(opt, underly, spotPx);
return;
}
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) {
return;
}
}
const combined = combineRealtimeFloatPnl(perp, opt);
if(combined !== null || perp !== null || opt != null){
paintRealtimePnl(combined, "U");
} }
} }
function formatOptionsFundingLabel(usdc, usdt) { function formatOptionsFundingLabel(usdc, usdt, eth, marginMode, underly) {
// 期权侧顶栏仅 USDC;usdt 参数忽略(USDT 在永续资金/交易账户)
if(usdc == null || usdc === "") return "—"; if(usdc == null || usdc === "") return "—";
const n = Number(usdc); const n = Number(usdc);
if(Number.isNaN(n)) return "—"; if(Number.isNaN(n)) return "—";
return `${n.toFixed(2)} USDC`; 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){ function setFundsFieldText(field, text){
if(text == null || text === "") return; if(text == null || text === "") return;
@@ -1637,6 +1720,11 @@ function applyPerpFundsVisibility(show){
el.style.display = on ? "" : "none"; el.style.display = on ? "" : "none";
}); });
} }
function applyOptionsFundsVisibility(show){
document.querySelectorAll("[data-options-funds='1']").forEach((el) => {
el.style.display = show ? "" : "none";
});
}
function accountSnapshotFundingMissing(data){ function accountSnapshotFundingMissing(data){
if(!data || typeof data !== "object") return true; if(!data || typeof data !== "object") return true;
if(data.show_perp_funds === false){ if(data.show_perp_funds === false){
@@ -1656,9 +1744,13 @@ function accountSnapshotFundingMissing(data){
let accountSnapshotRetryCount = 0; let accountSnapshotRetryCount = 0;
function applyAccountSnapshot(data){ function applyAccountSnapshot(data){
if(!data || typeof data !== "object") return; if(!data || typeof data !== "object") return;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if(typeof data.show_perp_funds !== "undefined"){ 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 !== ""){ if(data.funding_usdt != null && data.funding_usdt !== ""){
setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`); setFundsFieldText("total-capital", `${Number(data.funding_usdt).toFixed(2)}U`);
} }
@@ -1666,18 +1758,48 @@ function applyAccountSnapshot(data){
setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`); setFundsFieldText("total-funds", `${Number(data.total_funds).toFixed(2)}U`);
} }
if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){ if(data.current_capital != null && data.current_capital !== "" && !Number.isNaN(Number(data.current_capital))){
setFundsFieldText("current-capital", `${Number(data.current_capital).toFixed(2)}U`); setFundsFieldText(
"current-capital",
formatTradingAccountLabel(
data.current_capital,
data.options_trading_eth,
data.options_trading_btc,
data.options_margin_mode
)
);
} }
if(data.options_funding_usdc != null || data.options_funding_usdt != null){ if(!coinMode && (data.options_funding_usdc != null || data.options_funding_usdt != null || data.options_funding_eth != null)){
const optFunding = formatOptionsFundingLabel(data.options_funding_usdc, data.options_funding_usdt); const optFunding = formatOptionsFundingLabel(
data.options_funding_usdc,
data.options_funding_usdt,
data.options_funding_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-funding-usdc", optFunding); setFundsFieldText("options-funding-usdc", optFunding);
} }
if(data.options_trading_usdc != null || data.options_trading_usdt != null){ if(!coinMode && (data.options_trading_usdc != null || data.options_trading_usdt != null || data.options_trading_eth != null)){
const optTrading = formatOptionsFundingLabel(data.options_trading_usdc, data.options_trading_usdt); const optTrading = formatOptionsFundingLabel(
data.options_trading_usdc,
data.options_trading_usdt,
data.options_trading_eth,
data.options_margin_mode,
data.options_underly
);
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
updateRealtimePnl(data.unrealized_pnl); const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) {
// 币本位:顶栏跟持仓卡同口径(期权净盈亏),勿用混加后的 unrealized_pnl(会被抹成 0.00)
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
} }
if(typeof data.total !== "undefined" && data.total !== null){ if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total)); setFundsFieldText("stat-total", String(data.total));
@@ -2045,6 +2167,6 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
{% endif %} {% endif %}
</script> </script>
<script src="/static/instance_settings_prefs.js?v=19"></script> <script src="/static/instance_settings_prefs.js?v=22"></script>
</body> </body>
</html> </html>
@@ -38,11 +38,14 @@
{% include 'instance_header_stats.html' %} {% include 'instance_header_stats.html' %}
</div> </div>
<div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要"> <div class="instance-header-phone-strip instance-phone-only" aria-label="手机资金摘要">
<span class="inst-phone-chip"{% 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> <em>交易</em>
<b data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</b> <b data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth|default(none), options_trading_btc|default(none), margin_mode=_trading_margin_mode) }}</b>
</span> </span>
<span class="inst-phone-chip"{% 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> <em>资金</em>
<b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b> <b data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</b>
</span> </span>
@@ -1,4 +1,8 @@
{# 资金与统计条(顶栏 / 系统设置共用,单行展示) #} {# 资金与统计条(顶栏 / 系统设置共用,单行展示) #}
{# 币本位顶栏仅 OKX 期权开启时生效;Gate/Binance 保持原 xx.xxU #}
{% set _coin_margin = (options_enabled|default(false)) and (options_margin_mode|default('coin')) == 'coin' %}
{% set _show_perp = (show_perp_funds|default(true)) or _coin_margin %}
{% set _trading_margin_mode = 'coin' if _coin_margin else 'usdc' %}
<div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}"> <div class="instance-header-stats{% if options_enabled %} instance-header-stats--options{% endif %}">
<div class="stat-strip-item stat-strip-item--primary"> <div class="stat-strip-item stat-strip-item--primary">
<div class="label">交易所</div> <div class="label">交易所</div>
@@ -24,22 +28,22 @@
<div class="label">总资金</div> <div class="label">总资金</div>
<div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div> <div class="value" id="total-funds" data-funds-field="total-funds">{% if total_funds is not none %}{{ funds_fmt(total_funds) }}U{% else %}—{% endif %}</div>
</div> </div>
<div class="stat-strip-item"{% 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="label">资金账户</div>
<div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div> <div class="value" id="total-capital" data-funds-field="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div>
</div> </div>
<div class="stat-strip-item"{% 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="label">交易账户</div>
<div class="value" id="current-capital" data-funds-field="current-capital">{{ funds_fmt(current_capital) }}U</div> <div class="value" id="current-capital" data-funds-field="current-capital">{{ trading_account_label(current_capital, options_trading_eth|default(none), options_trading_btc|default(none), margin_mode=_trading_margin_mode) }}</div>
</div> </div>
{% if options_enabled %} {% if options_enabled and not _coin_margin %}
<div class="stat-strip-item"> <div class="stat-strip-item" data-options-funds="1">
<div class="label">期权资金账户</div> <div class="label">期权资金账户</div>
<div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc) }}</div> <div class="value" id="options-funding-usdc" data-funds-field="options-funding-usdc">{{ options_funding_label(options_funding_usdc|default(none), options_funding_usdt|default(none), options_funding_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}</div>
</div> </div>
<div class="stat-strip-item"> <div class="stat-strip-item" data-options-funds="1">
<div class="label">期权交易账户</div> <div class="label">期权交易账户</div>
<div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc) }}</div> <div class="value" id="options-trading-usdc" data-funds-field="options-trading-usdc">{{ options_funding_label(options_trading_usdc|default(none), options_trading_usdt|default(none), options_trading_eth|default(none), options_margin_mode|default(none), options_underly|default('ETH')) }}</div>
</div> </div>
{% endif %} {% endif %}
<div class="stat-strip-item stat-strip-item--pnl"> <div class="stat-strip-item stat-strip-item--pnl">
+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: if strike is None:
strike = ps strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) return close_ref_prices(
mark_px=mark,
opt_type=str(opt_type or ""),
strike=strike,
index_px=idx,
inst_id=inst_id,
)
def _avail_sheets(pos: dict[str, Any]) -> int: def _avail_sheets(pos: dict[str, Any]) -> int:
@@ -100,7 +106,7 @@ def close_option_by_bid1(
- 限价 = 校验通过时锁定的买一价 - 限价 = 校验通过时锁定的买一价
- 永不市价 - 永不市价
- 始终校验有效流动性(残档买一禁止) - 始终校验有效流动性(残档买一禁止)
- require_recycle_gate=True :首次还需可回收2×权利金并持续 hold ; - require_recycle_gate=True :首次还需目标门控(权利金×倍数或净盈亏,U 口径)并持续 hold ;
一旦通过后对同仓续批只验流动性 一旦通过后对同仓续批只验流动性
""" """
from lib.exchange.okx_options_lib import ( from lib.exchange.okx_options_lib import (
@@ -139,6 +145,8 @@ def close_option_by_bid1(
premium_paid = _open_premium_paid(cfg, inst_id) premium_paid = _open_premium_paid(cfg, inst_id)
if premium_paid is None: if premium_paid is None:
premium_paid = _safe_float(pos.get("premium_paid")) premium_paid = _safe_float(pos.get("premium_paid"))
premium_ccy = str(q.get("premium_ccy") or pos.get("premium_ccy") or "USDC").strip().upper() or "USDC"
index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px"))
# 已有未成交卖平单:等成交,不撤不重挂 # 已有未成交卖平单:等成交,不撤不重挂
try: try:
@@ -188,7 +196,13 @@ def close_option_by_bid1(
) )
if preview.get("bid_invalid") or preview.get("auto_close_blocked"): 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 { return {
"ok": False, "ok": False,
"msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓", "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
@@ -202,7 +216,13 @@ def close_option_by_bid1(
bid_px = _safe_float(q.get("bid")) bid_px = _safe_float(q.get("bid"))
stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub or bid_px is None or bid_px <= 0: if stub or bid_px is None or bid_px <= 0:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": stub_reason or "暂无买一,无法限价平仓", "msg": stub_reason or "暂无买一,无法限价平仓",
@@ -225,7 +245,13 @@ def close_option_by_bid1(
stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub_lv: if stub_lv:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": stub_lv_reason or "暂无有效买盘,禁止平仓", "msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
@@ -239,6 +265,8 @@ def close_option_by_bid1(
inst_id, inst_id,
recycle_usdc=_safe_float(preview.get("total_received")), recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=premium_paid, premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
) )
if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"): if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"):
return { return {
@@ -351,7 +379,7 @@ def close_option_by_bid1(
# 自动平已挂过单:同仓续批只验流动性 # 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id) mark_close_gate_passed(inst_id)
return { out = {
"ok": True, "ok": True,
"mode": "bid1", "mode": "bid1",
"orders": [{"order": order, "px": px, "sheets": level_sheets}], "orders": [{"order": order, "px": px, "sheets": level_sheets}],
@@ -369,6 +397,18 @@ def close_option_by_bid1(
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓") + ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
), ),
} }
if fully_closed:
try:
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out)
if spot_sell is not None:
out["spot_sell"] = spot_sell
if spot_sell.get("bridge_status") == "pending_sell_spot":
out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回"
except Exception as e:
out["spot_sell"] = {"ok": False, "msg": str(e)}
return out
# 兼容旧名 # 兼容旧名
+170 -20
View File
@@ -1,4 +1,4 @@
"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓.""" """期权按买盘平仓门控:可回收/净盈亏换算为 USDT 后校验,并持续 hold 秒才允许平仓."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -14,10 +14,44 @@ def _env_float(key: str, default: float) -> float:
return default return default
# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 def _env_optional_float(key: str) -> float | None:
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) raw = os.getenv(key)
if raw is None or str(raw).strip() == "":
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
def close_net_pnl_min_u() -> float:
return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", 0.0)
def close_gate_mode() -> str:
m = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower()
return m if m in ("premium", "net_pnl") else "premium"
def resolve_close_recycle_mult(premium_ccy: str | None, min_mult: float | None = None) -> float:
if min_mult is not None:
m = float(min_mult)
return m if m > 0 else 1.05
global_mult = _env_optional_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT")
if global_mult is not None and global_mult > 0:
return global_mult
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy in ("ETH", "BTC"):
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05)
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", 2.0)
# 兼容旧引用
CLOSE_RECYCLE_MIN_MULT = resolve_close_recycle_mult("USDC")
_lock = threading.Lock() _lock = threading.Lock()
# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float} # inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float}
_gates: dict[str, dict[str, Any]] = {} _gates: dict[str, dict[str, Any]] = {}
@@ -32,6 +66,39 @@ def _safe_float(v: Any) -> float | None:
return None return None
def _normalize_premium_ccy(premium_ccy: str | None) -> str:
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy not in ("ETH", "BTC", "USDC"):
ccy = "USDC"
return ccy
def _to_usdt(amount: float | None, ccy: str, index_px: float | None) -> float | None:
if amount is None:
return None
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return None
return float(amount) * float(idx)
return float(amount)
def _fmt_gate_amt(v: float, *, ccy: str) -> str:
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
txt = f"{float(v):.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{float(v):.4f}"
def _fmt_usdt(v: float | None) -> str:
if v is None:
return ""
return f"{float(v):.2f}"
def clear_close_gate(inst_id: str | None = None) -> None: def clear_close_gate(inst_id: str | None = None) -> None:
with _lock: with _lock:
if inst_id: if inst_id:
@@ -41,7 +108,7 @@ def clear_close_gate(inst_id: str | None = None) -> None:
def mark_close_gate_passed(inst_id: str) -> None: def mark_close_gate_passed(inst_id: str) -> None:
"""标记同仓已通过 2× 门控,续批平仓只验流动性.""" """标记同仓已通过门控,续批平仓只验流动性."""
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if not inst: if not inst:
return return
@@ -68,10 +135,13 @@ def update_close_gate(
now: float | None = None, now: float | None = None,
min_mult: float | None = None, min_mult: float | None = None,
hold_seconds: float | None = None, hold_seconds: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
根据当前买盘可回收金额刷新门控. 根据当前买盘可回收金额刷新门控(比较口径均为 USDT 估值).
条件不满足时重置计时;满足时从首次满足起累计持续时间. premium 模式:可回收(U) 权利金(U) × 倍数
net_pnl 模式:净盈亏(U) > OKX_OPTIONS_CLOSE_NET_PNL_MIN_U
""" """
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if not inst: if not inst:
@@ -82,18 +152,37 @@ def update_close_gate(
"msg": "缺少合约", "msg": "缺少合约",
} }
ts = float(now if now is not None else time.time()) ts = float(now if now is not None else time.time())
mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT) mode = close_gate_mode()
hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS)
if mult <= 0:
mult = 2.0
if hold < 0: if hold < 0:
hold = 0.0 hold = 0.0
with _lock:
prev_ccy = (_gates.get(inst) or {}).get("premium_ccy")
ccy = _normalize_premium_ccy(premium_ccy or prev_ccy)
mult = resolve_close_recycle_mult(ccy, min_mult)
min_pnl_u = close_net_pnl_min_u()
prem = _safe_float(premium_paid) prem = _safe_float(premium_paid)
recv = _safe_float(recycle_usdc) recv = _safe_float(recycle_usdc)
need = round(prem * mult, 4) if prem is not None and prem > 0 else None recv_u = _to_usdt(recv, ccy, index_px)
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( 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 prem_u is not None
and prem_u > 0
and recv_u is not None
and need_u is not None
and recv_u + 1e-9 >= need_u
) )
with _lock: with _lock:
@@ -112,11 +201,19 @@ def update_close_gate(
"ok_since": ok_since, "ok_since": ok_since,
"recycle": recv, "recycle": recv,
"premium": prem, "premium": prem,
"need": need, "need": need_u,
"updated": ts, "updated": ts,
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"passed": passed, "passed": passed,
"premium_ccy": ccy,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
} }
_gates[inst] = state _gates[inst] = state
@@ -125,15 +222,40 @@ def update_close_gate(
msg = "缺少权利金,无法校验平仓门控" msg = "缺少权利金,无法校验平仓门控"
elif recv is None: elif recv is None:
msg = "暂无有效买盘可回收金额" msg = "暂无有效买盘可回收金额"
elif not recycle_ok: elif missing_index:
msg = f"可回收 {recv:.4f} USDC < 权利金×{mult:g}({need:.4f}),目标平仓门控未过" 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: elif not ready:
msg = ( msg = (
f"可回收已达×{mult:g}({recv:.4f}/{need:.4f})," f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U,"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过" f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
) )
else: else:
msg = f"可回收已达×{mult:g}且持续≥{hold:.0f}s,目标触达后可按买一平仓" msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U"
f"且持续≥{hold:.0f}s,目标触达后可按买一平仓"
)
elif not recycle_ok:
msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) < 权利金×{mult:g}"
f"({_fmt_usdt(need_u)}U),目标平仓门控未过"
)
elif not ready:
msg = (
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"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}且持续≥{hold:.0f}s,"
f"目标触达后可按买一平仓"
)
auto_blocked = not (ready or passed) auto_blocked = not (ready or passed)
return { return {
@@ -143,13 +265,21 @@ def update_close_gate(
"recycle_ok": recycle_ok, "recycle_ok": recycle_ok,
"recycle_usdc": recv, "recycle_usdc": recv,
"premium_paid": prem, "premium_paid": prem,
"need_recycle_usdc": need, "need_recycle_usdc": need_u,
"premium_ccy": ccy,
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"held_seconds": round(held, 1) if recycle_ok else 0.0, "held_seconds": round(held, 1) if recycle_ok else 0.0,
"remain_seconds": round(remain, 1) if remain is not None else None, "remain_seconds": round(remain, 1) if remain is not None else None,
"ok_since": ok_since, "ok_since": ok_since,
"msg": msg, "msg": msg,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
"auto_close_blocked": auto_blocked, "auto_close_blocked": auto_blocked,
"close_gate_blocked": auto_blocked, "close_gate_blocked": auto_blocked,
} }
@@ -160,25 +290,45 @@ def check_close_gate(
*, *,
recycle_usdc: float | None = None, recycle_usdc: float | None = None,
premium_paid: float | None = None, premium_paid: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
refresh: bool = True, refresh: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""检查是否允许平仓;默认先用最新回收/权利金刷新.""" """检查是否允许平仓;默认先用最新回收/权利金刷新."""
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if refresh: if refresh:
if recycle_usdc is None or premium_paid is None: if recycle_usdc is None or premium_paid is None or premium_ccy is None or index_px is None:
with _lock: with _lock:
prev = _gates.get(inst) or {} prev = _gates.get(inst) or {}
if recycle_usdc is None: if recycle_usdc is None:
recycle_usdc = prev.get("recycle") recycle_usdc = prev.get("recycle")
if premium_paid is None: if premium_paid is None:
premium_paid = prev.get("premium") premium_paid = prev.get("premium")
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) if premium_ccy is None:
premium_ccy = prev.get("premium_ccy")
if index_px is None:
index_px = prev.get("index_px")
return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
with _lock: with _lock:
prev = _gates.get(inst) prev = _gates.get(inst)
if not prev: if not prev:
return update_close_gate(inst, recycle_usdc=recycle_usdc, premium_paid=premium_paid) return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return update_close_gate( return update_close_gate(
inst, inst,
recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"), recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"),
premium_paid=premium_paid if premium_paid is not None else prev.get("premium"), premium_paid=premium_paid if premium_paid is not None else prev.get("premium"),
premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"),
index_px=index_px if index_px is not None else prev.get("index_px"),
) )
+481
View File
@@ -0,0 +1,481 @@
"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回."""
from __future__ import annotations
import os
import time
from typing import Any
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
td_mode_for_option_buy,
wait_option_order_full_fill,
)
from lib.options.options_margin_mode_lib import (
calc_sheets_from_coin_balance,
compute_coin_budget_usdt,
is_coin_margin_mode,
margin_mode_from_inst_id,
normalize_options_margin_mode,
plan_coin_open_by_budget,
premium_ccy_for_mode,
)
from lib.options.options_spot_bridge_lib import (
BRIDGE_BOUGHT,
BRIDGE_HOLDING,
bridge_blocks_new_open_msg,
fetch_trading_coin_available,
insert_bridge,
rollback_bought_coin_to_usdt,
sell_residual_after_option_flat,
spot_market_buy_coin_with_usdt,
update_bridge,
)
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances
bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True)
trading = bal.get("trading_usdt_avail")
if trading is None:
trading = bal.get("trading_usdt")
try:
trading_f = float(trading or 0)
except (TypeError, ValueError):
trading_f = 0.0
buf = float(cfg.get("budget_buffer") or 0.95)
return compute_coin_budget_usdt(trading_f, buffer=buf)
def open_coin_option_buy_full(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
signal_note: str = "",
target_index: float | None = None,
profit_exit_enabled: bool = False,
profit_exit_mult: float = 1.0,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算)."""
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if not is_coin_margin_mode():
return {"ok": False, "msg": "当前非币本位模式"}
if margin_mode_from_inst_id(inst_id) != "coin":
return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"}
# 解析标的
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
block = bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return {"ok": False, "msg": compound_block, "can_open": False}
pos_limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id=inst_id,
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {"ok": False, "msg": pos_limit_msg, "can_open": False}
budget_info = coin_budget_preview(cfg, ex)
if not budget_info.get("ok"):
return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info}
budget_usdt = float(budget_info["budget_usdt"])
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return q
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"can_open": False,
}
ct_mult = float(q.get("ct_mult") or 0.01)
min_sz = int(q.get("min_sz") or 1)
idx = None
try:
idx = float(q.get("index_px") or q.get("idxPx") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
try:
from lib.exchange.okx_options_lib import fetch_index_price
idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0)
except Exception:
idx = 0.0
plan = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdt=budget_usdt,
index_px=float(idx),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if not plan.get("ok"):
return {
"ok": False,
"msg": plan.get("msg") or "无法规划买币张数",
"plan": plan,
"budget": budget_info,
"can_open": False,
}
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = fetch_trading_coin_available(ex, underlying) or 0.0
buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
if not buy.get("ok"):
return {
"ok": False,
"msg": f"现货买入 {underlying} 失败: {buy.get('msg')}",
"budget": budget_info,
"plan": plan,
}
bridge_id = insert_bridge(
conn,
underlying=underlying,
status=BRIDGE_BOUGHT,
budget_usdt=buy_usdt,
buy_ord_id=str(buy.get("ord_id") or ""),
inst_id=inst_id,
message="已买币,待开期权",
)
# 等余额落账
time.sleep(1.5)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
coin_after = fetch_trading_coin_available(ex, underlying)
if coin_after is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额"
)
return {
"ok": False,
"msg": "买币后读不到可用余额,已尝试卖回 USDT",
"rollback": rb,
"budget": budget_info,
"plan": plan,
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
if coin_bought <= 0:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
coin_available=float(coin_bought),
)
if not sizing.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=sizing.get("msg") or "张数不足",
coin_amount=float(coin_bought),
)
return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan}
# 实盘以买到的币为准,但不超过规划张数
sheets = min(int(sizing["sheets"]), int(plan["sheets"]))
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz)
if capped is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足"
)
return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb}
if capped < sheets:
sheets = int(capped)
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"ask_depth_capped": True,
}
else:
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
}
tick_sz = q.get("tick_sz")
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
# 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚)
if (not order.get("ok")) and sheets > 1:
msg_l = str(order.get("msg") or "").lower()
if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""):
sheets2 = max(1, sheets // 2)
if sheets2 < sheets:
order2 = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets2,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
if order2.get("ok"):
order = order2
sheets = sheets2
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"retried_half": True,
}
if not order.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败"
)
return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb}
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号"
)
return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb}
try:
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=int(sheets),
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=fill.get("msg") or "未成交",
)
return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb}
sheets = filled_n
eth_amount = round(float(sheets) * ct_mult, 8)
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
message="期权持仓中",
)
trade_id = _insert_coin_trade(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
strike=q.get("strike"),
exp_time=q.get("exp_time"),
sheets=int(sheets),
eth_amount=eth_amount,
open_quote=float(ask),
premium_paid=premium_paid,
signal_note=signal_note,
exchange_ord_id=ord_id,
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
# 目标位 / 翻倍离场 — 复用现有逻辑若存在
try:
if target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor
upsert_target_monitor(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
target_index=float(target_index),
trade_id=trade_id,
sheets=int(sheets),
)
except Exception:
pass
try:
from lib.options.options_notify_lib import notify_options_open
notify_options_open(
cfg,
conn,
trade_id=trade_id,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
sheets=int(sheets),
premium_paid=premium_paid,
open_quote=float(ask),
target_index=target_index,
signal_note=signal_note,
premium_ccy=premium_ccy,
margin_mode="coin",
)
except Exception:
pass
return {
"ok": True,
"msg": f"币本位开仓成功 {sheets}",
"margin_mode": "coin",
"budget": budget_info,
"sizing": sizing,
"sheets": sheets,
"eth_amount": eth_amount,
"premium_paid": premium_paid,
"premium_ccy": premium_ccy,
"bridge_id": bridge_id,
"trade_id": trade_id,
"order": order,
"fill": fill,
}
finally:
try:
conn.close()
except Exception:
pass
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle"
cur = conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
kwargs["underlying"],
kwargs["opt_type"],
kwargs.get("strike"),
str(kwargs.get("exp_time") or ""),
kwargs["sheets"],
kwargs["eth_amount"],
kwargs.get("open_quote"),
kwargs.get("premium_paid"),
kwargs.get("signal_note") or "",
kwargs.get("exchange_ord_id"),
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
pe,
pe_mult,
pe_state,
),
)
conn.commit()
return int(cur.lastrowid)
def maybe_sell_spot_after_close(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
if margin_mode_from_inst_id(inst_id) != "coin":
return None
# 仍有仓则不卖
try:
rows = cfg["fetch_option_positions"](ex) or []
for p in rows:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
except Exception:
pass
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
finally:
try:
conn.close()
except Exception:
pass
+15 -1
View File
@@ -98,11 +98,24 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
for ddl in ( for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
"ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'",
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
): ):
try: try:
conn.execute(ddl) conn.execute(ddl)
except Exception: except Exception:
pass pass
try:
from lib.options.options_spot_bridge_lib import ensure_bridge_table
ensure_bridge_table(conn)
except Exception:
pass
init_options_review_tables(conn) init_options_review_tables(conn)
@@ -121,7 +134,8 @@ def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | Non
).fetchone() ).fetchone()
if not row or int(row["n"] or 0) < 1: if not row or int(row["n"] or 0) < 1:
return None return None
return round(float(row["total"] or 0), 4) # 币本位权利金常 <1e-4,保留 8 位避免被裁成 0
return round(float(row["total"] or 0), 8)
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None: def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
+68 -5
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from typing import Any from typing import Any
from lib.exchange.okx_options_lib import format_premium_amount
from lib.options.options_db import init_options_tables, sum_open_premium_paid from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
def enrich_position_row_display( def enrich_position_row_display(
@@ -14,14 +16,73 @@ def enrich_position_row_display(
meta_cache: dict[str, dict[str, Any] | None] | None = None, meta_cache: dict[str, dict[str, Any] | None] | None = None,
premium_override: float | None = None, premium_override: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from lib.exchange.okx_options_lib import format_position_row, format_usdc_amount, tick_sz_and_ct_mult from lib.exchange.okx_options_lib import format_position_row, tick_sz_and_ct_mult
inst_id = str(raw_pos.get("instId") or "").strip() inst_id = str(raw_pos.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz) row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz)
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = str(row.get("underlying") or (inst_id.split("-")[0] if inst_id else "ETH") or "ETH")
premium_ccy = premium_ccy_for_mode(row_mode, underly)
row["margin_mode"] = row_mode
row["premium_ccy"] = premium_ccy
row["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
if premium_override is not None: if premium_override is not None:
row["premium_paid"] = premium_override row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_usdc_amount(premium_override) row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
return row
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _load_local_closed_by_inst(conn: Any) -> dict[str, dict[str, Any]]:
"""同合约取最新已平本地单,用于补交易所历史权利金/盈亏."""
out: dict[str, dict[str, Any]] = {}
try:
rows = conn.execute(
"""
SELECT inst_id, premium_paid, realized_pnl, premium_ccy, margin_mode,
open_quote, close_quote, sheets, closed_at
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
"""
).fetchall()
except Exception:
return out
for r in rows:
inst = str(r["inst_id"] or "").strip()
if not inst or inst in out:
continue
out[inst] = dict(r)
return out
def _overlay_local_closed(row: dict[str, Any], local: dict[str, Any] | None) -> dict[str, Any]:
if not local:
return row
prem = _safe_float(row.get("premium_paid"))
pnl = _safe_float(row.get("realized_pnl"))
local_prem = _safe_float(local.get("premium_paid"))
local_pnl = _safe_float(local.get("realized_pnl"))
# 交易所缺数或被两位小数抹成 0 时,用本地币本位落库值
if (prem is None or abs(prem) < 1e-10) and local_prem is not None and abs(local_prem) > 0:
row["premium_paid"] = local_prem
if (pnl is None or abs(pnl) < 1e-10) and local_pnl is not None and abs(local_pnl) > 0:
row["realized_pnl"] = local_pnl
if not row.get("premium_ccy") and local.get("premium_ccy"):
row["premium_ccy"] = local.get("premium_ccy")
if not row.get("margin_mode") and local.get("margin_mode"):
row["margin_mode"] = local.get("margin_mode")
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=ccy)
return row return row
@@ -48,6 +109,7 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
str(r["history_key"]) str(r["history_key"])
for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall() for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall()
} }
local_closed = _load_local_closed_by_inst(conn)
for p in raw_live: for p in raw_live:
inst = str(p.get("instId") or "").strip() inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None premium_override = sum_open_premium_paid(conn, inst) if inst else None
@@ -66,14 +128,15 @@ def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
except (TypeError, ValueError): except (TypeError, ValueError):
open_ms = None open_ms = None
items.append(format_live_option_history_row(row, open_ms=open_ms)) items.append(format_live_option_history_row(row, open_ms=open_ms))
finally:
conn.close()
hist_raw = fetch_all_option_positions_history(ex, limit=200) hist_raw = fetch_all_option_positions_history(ex, limit=200)
for raw in hist_raw: for raw in hist_raw:
inst_id = str(raw.get("instId") or "").strip() inst_id = str(raw.get("instId") or "").strip()
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache) tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)) 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()
open_rows = [x for x in items if x.get("status") == "open"] open_rows = [x for x in items if x.get("status") == "open"]
closed = [x for x in items if x.get("status") != "open"] closed = [x for x in items if x.get("status") != "open"]
+85
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
@@ -28,18 +29,36 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn) target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn) hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values()) target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
{
"inst_id": pe.get("inst_id"),
"exit_mode": "profit_exit",
"profit_exit_mult": pe.get("profit_exit_mult"),
"profit_exit_enabled": True,
}
)
for p in positions: for p in positions:
mon = tgt_map.get(str(p.get("inst_id") or "")) mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon: if mon:
p["target_index"] = mon.get("target_index") p["target_index"] = mon.get("target_index")
p["target_monitor_id"] = mon.get("id") p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon p["target_monitor"] = mon
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
if pe:
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target: if hedge_target:
p["hedge_plan_target"] = hedge_target p["hedge_plan_target"] = hedge_target
@@ -78,6 +97,65 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
has_upl = True has_upl = True
upl_total += float(pnl) upl_total += float(pnl)
bal = cfg["fetch_options_balances"](ex) bal = cfg["fetch_options_balances"](ex)
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
normalize_options_margin_mode,
premium_ccy_for_mode,
)
margin_mode = normalize_options_margin_mode()
for p in positions:
mid = str(p.get("inst_id") or "")
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode
p["margin_mode"] = row_mode
p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode(
row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH")
)
p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = None
try:
from lib.exchange.okx_options_lib import fetch_index_price
options_index_px = fetch_index_price(ex, underly)
except Exception:
options_index_px = None
if options_index_px is None:
for p in positions:
try:
px = float(p.get("idx_px") or p.get("idxPx") or 0)
except (TypeError, ValueError):
px = 0
if px > 0:
options_index_px = px
break
coin_budget = None
bridge_status = None
open_bridges = []
if is_coin_margin_mode():
try:
from lib.options.options_coin_open_lib import coin_budget_preview
coin_budget = coin_budget_preview(cfg, ex)
except Exception:
coin_budget = None
try:
conn_b = cfg["get_db"]()
try:
from lib.options.options_spot_bridge_lib import list_open_bridges
open_bridges = list_open_bridges(conn_b)
if open_bridges:
bridge_status = str(open_bridges[0].get("status") or "")
finally:
conn_b.close()
except Exception:
open_bridges = []
return { return {
"ok": True, "ok": True,
"enabled": True, "enabled": True,
@@ -95,6 +173,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
"trade_budget": cfg.get("trade_budget"), "trade_budget": cfg.get("trade_budget"),
"account_label": cfg.get("account_label") or "OKX期权", "account_label": cfg.get("account_label") or "OKX期权",
"max_active_positions": options_max_active_positions(), "max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": underly,
"options_index_px": options_index_px,
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
} }
except Exception as e: except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)} return {"ok": False, "enabled": True, "msg": str(e)}
+325
View File
@@ -0,0 +1,325 @@
"""OKX 单笔期权本位模式与币本位 USDT 预算."""
from __future__ import annotations
import os
from typing import Any
MODE_USDC = "usdc"
MODE_COIN = "coin"
def _env_bool(name: str, default: bool = False) -> bool:
v = (os.getenv(name) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on", "y")
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def normalize_options_margin_mode(raw: Any = None) -> str:
"""返回 usdc | coin;未配置时默认币本位."""
if raw is None:
raw = os.getenv("OKX_OPTIONS_MARGIN_MODE")
v = str(raw or MODE_COIN).strip().lower()
if v in ("usdc", "usdc_margin", "usd_margin", "u本位", "u"):
return MODE_USDC
if v in ("coin", "coin_margin", "crypto", "crypto_margin", "币本位"):
return MODE_COIN
# 空串或未知值:默认币本位
if not v:
return MODE_COIN
return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool:
return normalize_options_margin_mode(raw) == MODE_COIN
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
u = (underlying or "ETH").strip().upper() or "ETH"
mode = normalize_options_margin_mode(margin_mode)
if mode == MODE_COIN:
return f"{u}-USD"
return f"{u}-USD_UM"
def margin_mode_from_inst_id(inst_id: str) -> str:
inst = (inst_id or "").strip().upper()
if not inst:
return normalize_options_margin_mode()
if "_UM" in inst:
return MODE_USDC
# ETH-USD-260701-2500-C / BTC-USD-...
if "-USD-" in inst and "_UM" not in inst:
return MODE_COIN
return normalize_options_margin_mode()
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
return (underlying or "ETH").strip().upper() or "ETH"
return "USDC"
def spot_quote_inst_id(underlying: str) -> str:
"""现货市价买卖: ETH-USDT / BTC-USDT."""
u = (underlying or "ETH").strip().upper() or "ETH"
return f"{u}-USDT"
def compute_coin_budget_usdt(
trading_usdt: float,
*,
compound: bool | None = None,
buffer: float | None = None,
fixed_budget_usdt: float | None = None,
max_enabled: bool | None = None,
max_usdt: float | None = None,
) -> dict[str, Any]:
"""
币本位单笔 USDT 预算.
复利开: trading_usdt × buffer; 复利关: fixed × buffer.
上限开: min(..., max_usdt).
"""
bal = max(0.0, float(trading_usdt or 0))
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if buf <= 0:
buf = 0.95
fixed = (
float(fixed_budget_usdt)
if fixed_budget_usdt is not None
else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0)
)
if use_compound:
raw = bal * buf
source = "compound"
else:
raw = max(0.0, fixed) * buf
source = "fixed"
capped = False
max_on = (
_env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False)
if max_enabled is None
else bool(max_enabled)
)
max_n = (
float(max_usdt)
if max_usdt is not None
else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0)
)
budget = raw
if max_on and max_n > 0 and budget > max_n:
budget = max_n
capped = True
return {
"ok": budget > 0,
"budget_usdt": round(budget, 8),
"raw_usdt": round(raw, 8),
"trading_usdt": round(bal, 8),
"buffer": buf,
"compound": use_compound,
"source": source,
"max_enabled": max_on,
"max_usdt": max_n if max_on else None,
"capped_by_max": capped,
"msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算",
}
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
if v <= 0:
return 1.10
if v < 1.0:
return 1.0 + v
return v
def plan_coin_open_by_budget(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
budget_usdt: float,
index_px: float,
ask_sz: float | None = None,
spot_buy_buffer: float | None = None,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""
先按预算/卖一估最大可开张数,再按权利金 × 现货缓冲算应买现货 USDT.
不全额把预算换成币.
"""
import math
from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
budget = max(0.0, float(budget_usdt or 0))
idx = float(index_px or 0)
buf = normalize_coin_spot_buy_buffer(spot_buy_buffer)
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "buy_usdt": 0.0}
if idx <= 0:
return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "sheets": 0, "buy_usdt": 0.0}
if budget <= 0:
return {"ok": False, "msg": "USDT 预算无效", "sheets": 0, "buy_usdt": 0.0}
per_sheet_coin = ask * mult
# 每张开仓需买的币(含缓冲)及其约合 USDT
per_sheet_buy_coin = per_sheet_coin * buf
per_sheet_usdt = per_sheet_buy_coin * idx
if per_sheet_usdt <= 0:
return {"ok": False, "msg": "无法计算单张买币成本", "sheets": 0, "buy_usdt": 0.0}
max_by_budget = int(math.floor((budget / per_sheet_usdt) + 1e-12))
if target_sheets is not None:
try:
want = int(target_sheets)
except (TypeError, ValueError):
want = 0
if want < need:
return {
"ok": False,
"msg": f"指定张数无效(需≥{need})",
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
}
sheets = min(want, max_by_budget)
if sheets < want:
return {
"ok": False,
"msg": (
f"预算约可开 {max_by_budget} 张(含现货缓冲×{buf:g}),"
f"不足指定 {want}"
),
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
"spot_buy_buffer": buf,
}
else:
sheets = max_by_budget
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=need)
ask_depth_capped = False
if capped is None:
return {
"ok": False,
"msg": cap_msg or "卖一深度不足",
"sheets": 0,
"buy_usdt": 0.0,
"spot_buy_buffer": buf,
}
if int(capped) < sheets:
sheets = int(capped)
ask_depth_capped = True
if sheets < need:
return {
"ok": False,
"msg": (
f"预算不足,无法买入 {need}"
f"(单张约需 {per_sheet_usdt:.4f} USDT,含现货缓冲×{buf:g})"
),
"sheets": sheets,
"buy_usdt": 0.0,
"per_sheet_usdt": round(per_sheet_usdt, 8),
"spot_buy_buffer": buf,
"max_by_budget": max_by_budget,
}
premium_coin = sheets * per_sheet_coin
buy_coin = premium_coin * buf
buy_usdt = min(budget, buy_coin * idx)
# 再保险:向下对齐,避免浮点导致略超预算
buy_usdt = min(budget, round(buy_usdt, 8))
out = {
"ok": True,
"msg": "" if not ask_depth_capped else (cap_msg or f"已按卖一深度限制为 {sheets}"),
"sheets": sheets,
"eth_amount": round(sheets * mult, 8),
"coin_premium": round(premium_coin, 8),
"total_premium": round(premium_coin, 8),
"per_sheet_coin": per_sheet_coin,
"buy_coin": round(buy_coin, 8),
"buy_usdt": round(buy_usdt, 8),
"budget_usdt": round(budget, 8),
"spot_buy_buffer": buf,
"index_px": idx,
"max_by_budget": max_by_budget,
"ask_depth_capped": ask_depth_capped,
"est_note": (
f"按最大可开 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;"
f"不全额兑换预算"
),
}
if target_sheets is not None:
out["est_note"] = (
f"指定 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;不全额兑换"
)
out["target_sheets"] = int(target_sheets)
return out
def calc_sheets_from_coin_balance(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
coin_available: float,
) -> dict[str, Any]:
"""用可用标的币尽量开满(权利金以币计)."""
import math
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
coin = max(0.0, float(coin_available or 0))
# 留一点手续费/精度缓冲,避免算满张后下单 51008
coin_eff = coin * 0.97
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0}
per_sheet = ask * mult
if per_sheet <= 0:
return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0}
sheets = int(math.floor((coin_eff / per_sheet) + 1e-12))
if sheets < need:
return {
"ok": False,
"msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})",
"sheets": sheets,
"coin_premium": round(sheets * per_sheet, 8),
"per_sheet_coin": per_sheet,
}
prem = sheets * per_sheet
return {
"ok": True,
"msg": "",
"sheets": sheets,
"coin_premium": round(prem, 8),
"per_sheet_coin": per_sheet,
"eth_amount": round(sheets * mult, 8),
}
+27 -3
View File
@@ -30,16 +30,23 @@ def build_profit_alert_message(
upl: float, upl: float,
upl_ratio: float | None, upl_ratio: float | None,
bid: float | None, bid: float | None,
premium_ccy: str | None = None,
) -> str: ) -> str:
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id, premium_ccy=premium_ccy)
d = 6 if ccy in ("ETH", "BTC") else 4
pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "" pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else ""
bid_txt = f"{bid:.4f}" if bid is not None else "" bid_txt = f"{bid:.{d}f}" if bid is not None else ""
mode = "币本位" if ccy != "USDC" else "USDC"
return "\n".join( return "\n".join(
[ [
"【OKX期权·翻倍提醒】", "【OKX期权·翻倍提醒】",
f"账户:{account_label}", f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}", f"合约:{inst_id}",
f"已付权利金:{premium_paid:.4f} USDC", f"已付权利金:{premium_paid:.{d}f} {ccy}",
f"未实现盈亏:{upl:+.4f} USDC({pct})", f"未实现盈亏:{upl:+.{d}f} {ccy}({pct})",
f"当前买一:{bid_txt}(可考虑限价平仓锁利)", f"当前买一:{bid_txt}(可考虑限价平仓锁利)",
] ]
) )
@@ -428,6 +435,8 @@ def options_monitor_loop(
profit_ratio: float, profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None, sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None, target_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None, stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
stop_event: Any = None, stop_event: Any = None,
) -> None: ) -> None:
@@ -459,6 +468,21 @@ def options_monitor_loop(
account_label=account_label, account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label}, cfg={"send_wechat": send_wechat, "account_label": account_label},
) )
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
run_options_profit_exits(
conn,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
if sync_trades_fn is not None: if sync_trades_fn is not None:
sync_trades_fn(conn) sync_trades_fn(conn)
conn.commit() conn.commit()
+119 -7
View File
@@ -1,4 +1,4 @@
"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).""" """OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).支持 USDC / 币本位(ETH/BTC)."""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
@@ -23,6 +23,63 @@ def _opt_type_label(opt_type: Any) -> str:
return t or "" return t or ""
def resolve_options_premium_ccy(
*,
inst_id: str = "",
underlying: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
row: dict[str, Any] | None = None,
) -> str:
"""权利金计价币种:USDC 或 ETH/BTC."""
raw = premium_ccy
if (raw is None or str(raw).strip() == "") and row:
raw = row.get("premium_ccy")
ccy = str(raw or "").strip().upper()
if ccy:
return ccy
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
mid = str(inst_id or (row or {}).get("inst_id") or "").strip()
mode = margin_mode if margin_mode is not None else (row or {}).get("margin_mode")
if mode is None and mid:
mode = margin_mode_from_inst_id(mid)
u = str(
underlying
or (row or {}).get("underlying")
or (mid.split("-")[0] if mid else "ETH")
or "ETH"
).strip().upper() or "ETH"
return premium_ccy_for_mode(str(mode or "usdc"), u)
except Exception:
return "USDC"
def _amount_decimals(ccy: str) -> int:
c = (ccy or "USDC").strip().upper()
if c in ("ETH", "BTC"):
return 6
return 4
def _mode_tag(*, inst_id: str = "", premium_ccy: str = "", margin_mode: Any = None) -> str:
ccy = (premium_ccy or "").strip().upper()
if ccy and ccy != "USDC":
return "币本位"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode, margin_mode_from_inst_id
mode = margin_mode
if mode is None and inst_id:
mode = margin_mode_from_inst_id(inst_id)
if is_coin_margin_mode(mode):
return "币本位"
except Exception:
pass
return "USDC"
def ensure_options_notify_columns(conn: sqlite3.Connection) -> None: def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
for ddl in ( for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0", "ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
@@ -57,10 +114,21 @@ def build_options_open_message(
target_index: Any = None, target_index: Any = None,
signal_note: str = "", signal_note: str = "",
trade_id: Any = None, trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str: ) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [ lines = [
"【OKX期权·开仓】", "【OKX期权·开仓】",
f"账户:{account_label or 'OKX期权'}", f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
] ]
if trade_id is not None: if trade_id is not None:
lines.append(f"本地单号:#{trade_id}") lines.append(f"本地单号:#{trade_id}")
@@ -69,8 +137,8 @@ def build_options_open_message(
f"合约:{inst_id}", f"合约:{inst_id}",
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}", f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"张数:{sheets if sheets is not None else ''}", f"张数:{sheets if sheets is not None else ''}",
f"开仓报价:{_fmt(open_quote)} USDC", f"开仓报价:{_fmt(open_quote, d)} {ccy}",
f"权利金:{_fmt(premium_paid)} USDC", f"权利金:{_fmt(premium_paid, d)} {ccy}",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if target_index is not None and str(target_index).strip() != "":
@@ -98,10 +166,21 @@ def build_options_close_message(
target_index: Any = None, target_index: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
trade_id: Any = None, trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str: ) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [ lines = [
"【OKX期权·平仓】", "【OKX期权·平仓】",
f"账户:{account_label or 'OKX期权'}", f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
] ]
if trade_id is not None: if trade_id is not None:
lines.append(f"本地单号:#{trade_id}") lines.append(f"本地单号:#{trade_id}")
@@ -111,9 +190,9 @@ def build_options_close_message(
f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}", f"标的:{(underlying or '')} · {_opt_type_label(opt_type)}",
f"原因:{(reason or '平仓').strip()}", f"原因:{(reason or '平仓').strip()}",
f"张数:{sheets if sheets is not None else ''}", f"张数:{sheets if sheets is not None else ''}",
f"平仓报价:{_fmt(close_quote)} USDC", f"平仓报价:{_fmt(close_quote, d)} {ccy}",
f"已付/收回:{_fmt(premium_paid)} / {_fmt(premium_received)} USDC", f"已付/收回:{_fmt(premium_paid, d)} / {_fmt(premium_received, d)} {ccy}",
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", f"实现盈亏:{_fmt(realized_pnl, d)} {ccy}",
] ]
) )
if target_index is not None and str(target_index).strip() != "": if target_index is not None and str(target_index).strip() != "":
@@ -142,15 +221,26 @@ def notify_options_open(
open_quote: Any = None, open_quote: Any = None,
target_index: Any = None, target_index: Any = None,
signal_note: str = "", signal_note: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
) -> bool: ) -> bool:
ensure_options_notify_columns(conn) if conn is not None else None ensure_options_notify_columns(conn) if conn is not None else None
row_ccy = premium_ccy
row_mode = margin_mode
if conn is not None and trade_id is not None: if conn is not None and trade_id is not None:
row = conn.execute( row = conn.execute(
"SELECT wechat_open_sent FROM options_trades WHERE id=?", "SELECT wechat_open_sent, premium_ccy, margin_mode, underlying FROM options_trades WHERE id=?",
(int(trade_id),), (int(trade_id),),
).fetchone() ).fetchone()
if row and int(row["wechat_open_sent"] or 0): if row and int(row["wechat_open_sent"] or 0):
return False return False
if row:
if row_ccy is None:
row_ccy = row["premium_ccy"] if "premium_ccy" in row.keys() else None
if row_mode is None:
row_mode = row["margin_mode"] if "margin_mode" in row.keys() else None
if not underlying:
underlying = str(row["underlying"] or "") if "underlying" in row.keys() else underlying
msg = build_options_open_message( msg = build_options_open_message(
account_label=str(cfg.get("account_label") or "OKX期权"), account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id, inst_id=inst_id,
@@ -162,6 +252,8 @@ def notify_options_open(
target_index=target_index, target_index=target_index,
signal_note=signal_note, signal_note=signal_note,
trade_id=trade_id, trade_id=trade_id,
premium_ccy=row_ccy,
margin_mode=row_mode,
) )
ok = notify_options_send(cfg, msg) ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None: if ok and conn is not None and trade_id is not None:
@@ -197,6 +289,8 @@ def notify_options_close(
close_quote: Any = None, close_quote: Any = None,
target_index: Any = None, target_index: Any = None,
trigger_idx: Any = None, trigger_idx: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
force: bool = False, force: bool = False,
) -> bool: ) -> bool:
"""平仓必发.默认按 trade_id / 同合约未标记行幂等.""" """平仓必发.默认按 trade_id / 同合约未标记行幂等."""
@@ -230,6 +324,18 @@ def notify_options_close(
).fetchone() ).fetchone()
if q2: if q2:
rows = [dict(q2)] rows = [dict(q2)]
if not rows:
# 已有平仓记录且均已推送:幂等跳过,避免再走「无库行」重复推
exists = conn.execute(
"""
SELECT 1 FROM options_trades
WHERE inst_id=? AND status='closed'
LIMIT 1
""",
(inst_id,),
).fetchone()
if exists:
return False
if rows: if rows:
# 同次平仓可能多腿:合并一条推送,逐条标记 # 同次平仓可能多腿:合并一条推送,逐条标记
@@ -259,6 +365,8 @@ def notify_options_close(
target_index=target_index, target_index=target_index,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=head.get("id") if len(rows) == 1 else None, trade_id=head.get("id") if len(rows) == 1 else None,
premium_ccy=premium_ccy or head.get("premium_ccy"),
margin_mode=margin_mode or head.get("margin_mode"),
) )
ok = notify_options_send(cfg, msg) ok = notify_options_send(cfg, msg)
if ok and conn is not None: if ok and conn is not None:
@@ -288,6 +396,8 @@ def notify_options_close(
target_index=target_index, target_index=target_index,
trigger_idx=trigger_idx, trigger_idx=trigger_idx,
trade_id=trade_id, trade_id=trade_id,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
) )
return notify_options_send(cfg, msg) return notify_options_send(cfg, msg)
@@ -327,4 +437,6 @@ def notify_options_close_trade_ids(
premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None), premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None),
realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None), realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None),
close_quote=first.get("close_quote"), close_quote=first.get("close_quote"),
premium_ccy=first.get("premium_ccy"),
margin_mode=first.get("margin_mode"),
) )
+23
View File
@@ -119,3 +119,26 @@ def option_position_limit_block_msg(
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓" f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
) )
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开" 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"), row.get("opt_type") or row.get("optType"),
_safe_float(row.get("strike") or row.get("stk")), _safe_float(row.get("strike") or row.get("stk")),
_safe_float(row.get("idx_px") or row.get("idxPx")), _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( preview = estimate_close_by_bids(
@@ -51,9 +53,17 @@ def attach_close_preview(
intrinsic_px=intrinsic, intrinsic_px=intrinsic,
max_levels=1, 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"): 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"] = gate
preview["close_gate_blocked"] = True preview["close_gate_blocked"] = True
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg") preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
@@ -64,6 +74,8 @@ def attach_close_preview(
inst_id, inst_id,
recycle_usdc=_safe_float(preview.get("total_received")), recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid, 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")) passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
preview["close_gate"] = gate preview["close_gate"] = gate
@@ -138,7 +150,7 @@ def sum_options_net_pnl_usdc(
continue continue
found = True found = True
total += float(pnl) 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( def build_display_option_positions(
+165 -13
View File
@@ -64,7 +64,46 @@ def _safe_px(v: Any) -> float | None:
return x if x > 0 else 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() o = (opt_type or "").strip().upper()
if strike is None or index_px is None: if strike is None or index_px is None:
return None return None
@@ -74,10 +113,18 @@ def intrinsic_px_per_unit(opt_type: str | None, strike: float | None, index_px:
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
if o == "C" and idx > k: if o == "C" and idx > k:
return idx - k points = idx - k
if o == "P" and idx < k: elif o == "P" and idx < k:
return k - idx points = k - idx
else:
return None 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( def is_stub_bid_px(
@@ -128,9 +175,19 @@ def close_ref_prices(
opt_type: str | None = None, opt_type: str | None = None,
strike: float | None = None, strike: float | None = None,
index_px: 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]: ) -> tuple[float | None, float | None]:
"""返回 (mark_px, intrinsic_px) 供残档判断.""" """返回 (mark_px, intrinsic_px) 供残档判断;intrinsic 与盘口同单位."""
return _safe_px(mark_px), intrinsic_px_per_unit(opt_type, strike, index_px) 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( 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)) 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( def calc_order_size(
*, *,
quote_per_unit: float, quote_per_unit: float,
@@ -362,10 +438,20 @@ def expiry_breakeven_from_ask(
strike: float | None, strike: float | None,
ask_px: float | None, ask_px: float | None,
mark_px: float | None = None, mark_px: float | None = None,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> float | None: ) -> float | None:
"""买入前预估到期平衡:权利金按卖一;无卖一时回退标记价.""" """买入前预估到期平衡:权利金按卖一;无卖一时回退标记价."""
prem = ask_px if ask_px is not None and ask_px > 0 else mark_px 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( def expiry_breakeven_px(
@@ -374,17 +460,39 @@ def expiry_breakeven_px(
strike: float | None, strike: float | None,
avg_px: float | None, avg_px: float | None,
be_px_api: float | None = 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: ) -> float | None:
"""到期平衡点:持有至到期时标的指数盈亏为 0 的价格.优先 OKX bePx.""" """到期平衡点:持有至到期时标的指数盈亏为 0 的价格.优先 OKX bePx."""
if be_px_api is not None and be_px_api > 0: if be_px_api is not None and be_px_api > 0:
return round(float(be_px_api), 2) return round(float(be_px_api), 2)
if strike is None or avg_px is None: if strike is None or avg_px is None:
return 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() 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 o == "C":
return round(strike + avg_px, 2) if p >= 1:
return None
return round(k / (1 - p), 2)
if o == "P": if o == "P":
return round(strike - avg_px, 2) return round(k / (1 + p), 2)
return None
if o == "C":
return round(k + p, 2)
if o == "P":
return round(k - p, 2)
return None 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) 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( def format_options_breakeven_line(
*, *,
expiry_be_px: float | None, expiry_be_px: float | None,
@@ -493,12 +620,25 @@ def equivalent_contract_leverage(
index_px: float | None, index_px: float | None,
eth_amount: float | None, eth_amount: float | None,
total_premium: float | None, total_premium: float | None,
margin_mode: str | None = None,
) -> float | 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: if index_px is None or eth_amount is None or total_premium is None:
return None return None
if eth_amount <= 0 or total_premium <= 0: if eth_amount <= 0 or total_premium <= 0:
return None 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) return round(float(index_px) * float(eth_amount) / float(total_premium), 1)
@@ -528,12 +668,24 @@ def straddle_premium_total(
def straddle_breakeven_band( def straddle_breakeven_band(
strike: float | None, 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]: ) -> tuple[float | None, float | None]:
"""跨式到期平衡带:下平衡 ~ 上平衡(按双卖一报价和).""" """跨式到期平衡带:下平衡 ~ 上平衡."""
if strike is None or combined_ask_per_unit is None: if strike is None:
return None, None return None, None
k = float(strike) 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) d = float(combined_ask_per_unit)
return round(k - d, 2), round(k + d, 2) 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
+595 -15
View File
@@ -103,6 +103,10 @@ def _build_cfg(app_module: Any) -> dict[str, Any]:
"render_main_page": app_module.render_main_page, "render_main_page": app_module.render_main_page,
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0), "trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
"budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95), "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(), "default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0), "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), "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)), "" 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: def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
@@ -355,7 +422,40 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
return jsonify({"ok": False, "msg": err}) return jsonify({"ok": False, "msg": err})
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
bal = cfg["fetch_options_balances"](ex, force=force, scope="main") 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") @app.route("/api/options/chain")
@lr @lr
@@ -367,12 +467,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:链展示天数每次读 env,保存后刷新链即可 # 热更新:链展示天数每次读 env,保存后刷新链即可
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14)) chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
try: try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
margin_mode = normalize_options_margin_mode()
chain = cfg["build_option_chain"]( chain = cfg["build_option_chain"](
ex, ex,
u, u,
max_dte_days=chain_max_dte, max_dte_days=chain_max_dte,
itm_only=False, itm_only=False,
itm_max_dist_usd=cfg["itm_max_dist"], itm_max_dist_usd=cfg["itm_max_dist"],
margin_mode=margin_mode,
) )
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"}) return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
@@ -381,6 +485,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
# 热更新:每次读 env,保存配置后刷新链即可生效 # 热更新:每次读 env,保存配置后刷新链即可生效
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True) ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95) 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: if not expiries:
return jsonify( return jsonify(
{ {
@@ -391,6 +504,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter, "ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer, "budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"], "trade_budget": cfg["trade_budget"],
"options_margin_mode": chain.get("margin_mode") or margin_mode,
"coin_budget": coin_budget,
} }
) )
return jsonify( return jsonify(
@@ -401,6 +516,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ask_liq_filter_enabled": ask_liq_filter, "ask_liq_filter_enabled": ask_liq_filter,
"budget_buffer": budget_buffer, "budget_buffer": budget_buffer,
"trade_budget": cfg["trade_budget"], "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") ask = q.get("ask")
ct_mult = q.get("ct_mult") or 0.01 ct_mult = q.get("ct_mult") or 0.01
min_sz = q.get("min_sz") or 1 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 sheet_count = None
try: try:
if request.args.get("sheets"): if request.args.get("sheets"):
@@ -429,18 +546,196 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
if mode == "close_preview": if mode == "close_preview":
paid = _open_premium_paid(cfg, inst_id) paid = _open_premium_paid(cfg, inst_id)
target = sheet_count if sheet_count is not None else 0 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 = cfg["trade_budget"]
budget_cap = cfg["trade_budget"] budget_cap = cfg["trade_budget"]
available_usdc = None available_usdc = None
if mode == "budget_full": 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) budget, budget_err = _budget_full_usdc(cfg, ex)
if budget is None: 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 budget_cap = budget
from lib.exchange.okx_options_lib import fetch_options_trading_usdc from lib.exchange.okx_options_lib import fetch_options_trading_usdc
available_usdc = fetch_options_trading_usdc(ex) 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 eth_amount = None
try: try:
if request.args.get("eth_amount"): 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, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
} }
) )
except Exception as e: except Exception as e:
@@ -507,6 +803,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
}, },
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
} }
) )
except Exception as e: except Exception as e:
@@ -531,9 +828,39 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
}, },
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "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 (
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,
} }
) )
from lib.options.options_position_limit_lib import option_position_limit_block_msg
pos_limit_msg = option_position_limit_block_msg( pos_limit_msg = option_position_limit_block_msg(
ex, ex,
@@ -558,17 +885,20 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
}, },
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "budget_full_usdc": budget if mode == "budget_full" else None,
"compound_full_usdc": budget if mode == "compound_full" else None,
} }
) )
sizing = calc_order_size( sizing = calc_order_size(
quote_per_unit=float(ask), quote_per_unit=float(ask),
ct_mult=float(ct_mult), ct_mult=float(ct_mult),
min_sz=int(min_sz), 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"], budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount if mode == "eth_amount" else None, eth_amount=eth_amount if mode == "eth_amount" else None,
sheets=sheet_count if mode == "sheets" 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"): if sizing.get("ok"):
capped, cap_msg = cap_option_buy_sheets_to_ask_depth( 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), ct_mult=float(ct_mult),
min_sz=int(min_sz), min_sz=int(min_sz),
sheets=capped, 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"): if sizing.get("ok"):
sizing["ask_depth_capped"] = True sizing["ask_depth_capped"] = True
@@ -612,6 +944,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"sizing": sizing, "sizing": sizing,
"available_usdc": available_usdc, "available_usdc": available_usdc,
"budget_full_usdc": budget if mode == "budget_full" else None, "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}"}) return jsonify({"ok": False, "msg": f"互斥校验失败: {e}"})
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
inst_id = (data.get("inst_id") or "").strip() 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() 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 target_index = None
raw_target = data.get("target_index") raw_target = data.get("target_index")
if raw_target is not None and str(raw_target).strip() != "": 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": "目标位无效"}) return jsonify({"ok": False, "msg": "目标位无效"})
if target_index <= 0: if target_index <= 0:
return jsonify({"ok": False, "msg": "目标位无效"}) 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: if not inst_id:
return jsonify({"ok": False, "msg": "缺少 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) q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"): if not q.get("ok"):
return jsonify(q) return jsonify(q)
@@ -672,7 +1060,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"ref_ask": q.get("ref_ask"), "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( pos_limit_msg = option_position_limit_block_msg(
ex, ex,
@@ -694,23 +1092,49 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
try: try:
sheet_count = int(data.get("sheets")) sheet_count = int(data.get("sheets"))
except (TypeError, ValueError): except (TypeError, ValueError):
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": "张数无效"}) return jsonify({"ok": False, "msg": "张数无效"})
budget = cfg["trade_budget"] budget = cfg["trade_budget"]
budget_cap = cfg["trade_budget"] budget_cap = cfg["trade_budget"]
if mode == "budget_full": 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) budget, budget_err = _budget_full_usdc(cfg, ex)
if budget is None: if budget is None:
return jsonify({"ok": False, "msg": budget_err}) return jsonify({"ok": False, "msg": budget_err})
budget_cap = budget 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( sizing = calc_order_size(
quote_per_unit=float(ask), quote_per_unit=float(ask),
ct_mult=ct_mult, ct_mult=ct_mult,
min_sz=min_sz, 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"], budget_buffer=cfg["budget_buffer"],
eth_amount=eth_amount, eth_amount=eth_amount,
sheets=sheet_count, 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"): if not sizing.get("ok"):
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing}) 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 open_opt_type = None
try: try:
init_options_tables(conn) 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 {} meta = q.get("meta") or {}
u = str(meta.get("uly") or inst_id).split("-")[0] u = str(meta.get("uly") or inst_id).split("-")[0]
opt_type = meta.get("optType") opt_type = meta.get("optType")
@@ -802,8 +1229,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
""" """
INSERT INTO options_trades INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id) open_quote, premium_paid, status, signal_note, exchange_ord_id,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?) profit_exit_enabled, profit_exit_mult, profit_exit_state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
""", """,
( (
inst_id, inst_id,
@@ -817,6 +1245,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
sizing["total_premium"], sizing["total_premium"],
signal_note, signal_note,
ord_id, 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) trade_id = int(cur.lastrowid)
@@ -832,6 +1263,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
trade_id=trade_id, trade_id=trade_id,
sheets=sheets, sheets=sheets,
) )
if profit_exit_enabled:
pass # 列已由 init_options_tables / ensure 迁移
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
@@ -950,9 +1383,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.options.options_target_lib import targets_by_inst 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 from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn) hedge_target_map = active_options_targets_by_inst(conn)
rows = [] rows = []
for p in raw: 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_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id") row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon row["target_monitor"] = mon
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) hedge_target = hedge_target_map.get(inst)
if hedge_target: if hedge_target:
row["hedge_plan_target"] = hedge_target row["hedge_plan_target"] = hedge_target
@@ -1097,6 +1538,62 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
finally: finally:
conn.close() 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"]) @app.route("/api/options/close", methods=["POST"])
@lr @lr
def api_options_close(): def api_options_close():
@@ -1171,9 +1668,46 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn2.close() conn2.close()
except Exception: except Exception:
pass 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) _mark_balances_stale(cfg)
return jsonify(result) 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"]) @app.route("/api/options/convert/quote", methods=["POST"])
@lr @lr
def api_options_convert_quote(): 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: if raw_live is None:
return jsonify({"ok": False, "msg": "获取期权持仓失败"}) return jsonify({"ok": False, "msg": "获取期权持仓失败"})
history = load_options_history(ex, cfg) 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) 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 net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0
total_pnl = None total_pnl = None
if open_float is not None: if open_float is not None:
@@ -1450,6 +2010,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
pass pass
return result 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]: def _stale_pending() -> dict[str, Any]:
from lib.exchange.okx_options_lib import invalidate_option_positions_cache from lib.exchange.okx_options_lib import invalidate_option_positions_cache
from lib.options.options_pending_lib import cancel_stale_close_pending_orders 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"], "profit_ratio": cfg["profit_ratio"],
"sync_trades_fn": _sync, "sync_trades_fn": _sync,
"target_close_fn": _target_close, "target_close_fn": _target_close,
"profit_exit_close_fn": _profit_exit_close,
"profit_exit_cfg": cfg,
"stale_pending_fn": _stale_pending, "stale_pending_fn": _stale_pending,
}, },
daemon=True, 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", "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_up", "REAL")
_ensure_column(conn, "options_review_trades", "target_price_down", "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: 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 return True
def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str: def _is_coin_option_row(row: dict[str, Any]) -> bool:
"""幂等写入纯期权快照;不触碰 options_review_entries;已隐藏的不再导入.""" 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() history_key = str(row.get("history_key") or "").strip()
if not history_key: if not history_key:
return "skip" 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" 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") opened_at = row.get("created_at") or row.get("opened_at")
closed_at = row.get("closed_at") closed_at = row.get("closed_at")
pnl = _safe_float(row.get("realized_pnl")) 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")), "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")), "premium_paid": _safe_float(row.get("premium_paid")),
"realized_pnl": pnl, "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()) cols = list(fields.keys())
if existing: 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]: def sync_options_from_local_trades(
"""从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" conn: sqlite3.Connection,
*,
ex: Any = None,
) -> dict[str, Any]:
"""从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).
币本位金额按指数换算为 USDT 后入库.
"""
init_options_review_tables(conn) init_options_review_tables(conn)
from lib.options.options_db import init_options_tables 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, SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets,
open_quote, close_quote, premium_paid, realized_pnl, 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 FROM options_trades
WHERE status = 'closed' WHERE status = 'closed'
ORDER BY id DESC ORDER BY id DESC
@@ -289,6 +432,7 @@ def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]:
""" """
).fetchall() ).fetchall()
inserted = updated = skipped = 0 inserted = updated = skipped = 0
index_cache: dict[str, float | None] = {}
for r in rows: for r in rows:
trade_id = int(r["id"]) trade_id = int(r["id"])
history_key = f"local_opt:{trade_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, "created_at": opened_at,
"closed_at": closed_at, "closed_at": closed_at,
"status_label": "已平", "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": if action == "inserted":
inserted += 1 inserted += 1
@@ -354,6 +502,7 @@ def sync_options_from_exchange(
fmt = format_fn or format_option_history_row fmt = format_fn or format_option_history_row
raw_rows = fetch(ex, limit=limit) raw_rows = fetch(ex, limit=limit)
meta_cache: dict[str, dict[str, Any] | None] = {} meta_cache: dict[str, dict[str, Any] | None] = {}
index_cache: dict[str, float | None] = {}
inserted = updated = skipped = 0 inserted = updated = skipped = 0
for raw in raw_rows: for raw in raw_rows:
inst_id = str(raw.get("instId") or "").strip() inst_id = str(raw.get("instId") or "").strip()
@@ -363,7 +512,9 @@ def sync_options_from_exchange(
except Exception: except Exception:
pass pass
formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult) 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": if action == "inserted":
inserted += 1 inserted += 1
elif action == "updated": elif action == "updated":
@@ -450,6 +601,7 @@ def upsert_hedge_plan_row(
"target_price": _safe_float(plan.get("target_price")), "target_price": _safe_float(plan.get("target_price")),
"target_price_up": _safe_float(plan.get("target_price_up")), "target_price_up": _safe_float(plan.get("target_price_up")),
"target_price_down": _safe_float(plan.get("target_price_down")), "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), "legs_json": _legs_json_from_plan(legs),
} }
existing = conn.execute( 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 conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn
) )
else: 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) out["hedge"] = sync_hedge_plans_closed(conn)
return out return out
@@ -578,7 +730,12 @@ def ensure_local_review_synced(
backfill_hedge_option_legs_realized_pnl(conn, hist) backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception: except Exception:
pass 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]: 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.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
from lib.options.options_db import init_options_tables 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: 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) 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] = [] wins: list[float] = []
losses: list[float] = [] losses: list[float] = []
win_holds: 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] = [] all_holds: list[float] = []
open_holds: list[float] = [] open_holds: list[float] = []
now = datetime.now() 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: for row in history:
ccy = _row_premium_ccy(row)
if ccy in ("ETH", "BTC"):
coinish = True
if row.get("status") == "open": if row.get("status") == "open":
start = _parse_ts(row.get("created_at")) start = _parse_ts(row.get("created_at"))
if start is not None: 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: if sec >= 0:
open_holds.append(sec) open_holds.append(sec)
continue continue
pnl_raw = row.get("realized_pnl") pnl = _pnl_as_usdt(row, fallback_index=fallback_idx)
if pnl_raw is None: if pnl is None:
continue
try:
pnl = float(pnl_raw)
except (TypeError, ValueError):
continue continue
hold = _hold_seconds(row.get("created_at"), row.get("closed_at")) hold = _hold_seconds(row.get("created_at"), row.get("closed_at"))
if hold is not None: 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), "avg_loss_hold_sec": _avg_seconds(loss_holds),
"open_count": len(open_holds), "open_count": len(open_holds),
"avg_open_hold_sec": _avg_seconds(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) init_options_tables(conn)
closed_rows = conn.execute( 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 FROM options_trades
WHERE status = 'closed' AND realized_pnl IS NOT NULL WHERE status = 'closed' AND realized_pnl IS NOT NULL
""" """
@@ -116,58 +174,19 @@ def compute_options_stats(get_db) -> dict[str, Any]:
finally: finally:
conn.close() conn.close()
wins: list[float] = [] hist = []
losses: list[float] = []
win_holds: list[float] = []
loss_holds: list[float] = []
all_holds: list[float] = []
now = datetime.now()
for row in closed_rows: for row in closed_rows:
pnl = float(row["realized_pnl"]) hist.append(
hold = _hold_seconds(row["created_at"], row["closed_at"]) {
if hold is not None: "status": "closed",
all_holds.append(hold) "realized_pnl": row["realized_pnl"],
if pnl > 0: "created_at": row["created_at"],
wins.append(pnl) "closed_at": row["closed_at"],
if hold is not None: "inst_id": row["inst_id"] if "inst_id" in row.keys() else None,
win_holds.append(hold) "premium_ccy": row["premium_ccy"] if "premium_ccy" in row.keys() else None,
elif pnl < 0: "margin_mode": row["margin_mode"] if "margin_mode" in row.keys() else None,
losses.append(pnl)
if hold is not None:
loss_holds.append(hold)
open_holds: list[float] = []
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),
} }
)
for row in open_rows:
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: if strike is None:
strike = ps strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) return close_ref_prices(
mark_px=mark,
opt_type=str(opt_type or ""),
strike=strike,
index_px=idx,
inst_id=inst_id,
)
def ensure_target_tables(conn: sqlite3.Connection) -> None: def ensure_target_tables(conn: sqlite3.Connection) -> None:
@@ -303,6 +309,11 @@ def _notify_target_close(
conn: Any = None, conn: Any = None,
) -> None: ) -> None:
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" """目标位平仓推送:优先走统一平仓必发(幂等);无 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 result.get("fully_closed") or result.get("already_flat"):
if cfg is not None: if cfg is not None:
try: try:
@@ -318,23 +329,28 @@ def _notify_target_close(
close_quote=result.get("locked_bid_px") or result.get("bid"), close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target, target_index=target,
trigger_idx=idx, trigger_idx=idx,
premium_ccy=ccy,
) )
# 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推
return return
except Exception: except Exception:
pass pass
if not send_wechat: if not send_wechat:
return return
try: try:
recv = result.get("premium_received")
recv_txt = f"{float(recv):.{d}f} {ccy}" if recv is not None else f"{ccy}"
send_wechat( send_wechat(
"\n".join( "\n".join(
[ [
"【OKX期权·目标位平仓】", "【OKX期权·目标位平仓】",
f"账户:{account_label}", f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}", f"合约:{inst_id}",
f"目标指数:{target:g}", f"目标指数:{target:g}",
f"触发指数:{idx:g}", f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or ''}", 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 '挂单中/部分'}", 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) cancel_orphans_without_position(conn, live_inst_ids=live_ids)
_commit_monitor(conn) _commit_monitor(conn)
# 先处理已挂单等待成交的,绝不再发微信 # 先处理已挂单等待成交的;首次触发已推过「挂单中」,此处仅在全平时走幂等平仓推送
for mon in list_closing_targets(conn): for mon in list_closing_targets(conn):
inst_id = str(mon.get("inst_id") or "") inst_id = str(mon.get("inst_id") or "")
if not inst_id: if not inst_id:
@@ -402,6 +418,18 @@ def run_options_target_closes(
if inst_id not in pos_by_inst: if inst_id not in pos_by_inst:
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn) _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 continue
result = close_fn(inst_id) result = close_fn(inst_id)
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx")) 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="目标位限价平仓完成", message="目标位限价平仓完成",
) )
_commit_monitor(conn) _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 continue
mark_monitor( mark_monitor(
conn, conn,
@@ -458,6 +497,17 @@ def run_options_target_closes(
if result.get("already_flat"): if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn) _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 continue
if not result.get("ok"): if not result.get("ok"):
mark_monitor( mark_monitor(
+84 -28
View File
@@ -2,7 +2,11 @@
data-default-underly="{{ options_default_underly | default('ETH') }}" data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}" data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}" 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 %}"> 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 %} {% 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> <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 %} {% 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 %}> <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> <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"> <details class="opt-close-rule opt-open-rule">
<summary>开仓规则说明</summary> <summary>仓规则说明</summary>
<div class="opt-close-rule-body"> <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> <ul>
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li> <li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li> <li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li> <li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li> <li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li> <li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(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> </ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p> <p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div> </div>
@@ -57,10 +72,10 @@
<th>类型</th> <th>类型</th>
<th>合约</th> <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> <th>平衡价差</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
<tr id="opt-strike-head-t" class="hidden" hidden> <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-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-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-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>
<div class="options-estimate-row"> <div class="options-estimate-row">
<div class="opt-est-main"> <div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-target-idx">目标位(指数)</label> <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="达价限价平仓" <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"> autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span class="k">预计价值</span> <span class="k">预计价值</span>
<span id="opt-est-value" class="v"></span> <span id="opt-est-value" class="v"></span>
<span class="k">盈利</span> <span class="k">盈利</span>
<span id="opt-est-profit" class="v"></span> <span id="opt-est-profit" class="v"></span>
<span class="k">目标杠杆</span> <span class="k">盈亏比</span>
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金"></span> <span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金"></span>
</div> </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>
<div class="form-row options-order-mode-row"> <div class="form-row options-order-mode-row">
<div class="opt-size-mode-bar"> <div class="opt-size-mode-bar">
<label class="btn-secondary opt-order-chip opt-size-mode-chip"> <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> <span>指定张数</span>
</label> </label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数" <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"> 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"> <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"> <input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
<span>按可用余额打满</span> <span>按可用余额打满</span>
</label> </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"> <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"> <input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<span>指定币数量</span> <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"> <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)时按预算;余额不足时按余额;再乘预算缓冲算张数。 余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
</p> </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="备注(关键位说明)" <input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly> data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
@@ -159,8 +193,12 @@
<div class="card options-pos-card-wrap"> <div class="card options-pos-card-wrap">
<div class="options-pos-head"> <div class="options-pos-head">
<h2>持仓</h2> <h2>持仓</h2>
<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> <button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
</div> </div>
</div>
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板"> <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> <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>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="pending" role="tab" aria-selected="false" id="opt-pos-tab-pending">当前委托</button> <button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="pending" role="tab" aria-selected="false" id="opt-pos-tab-pending">当前委托</button>
@@ -177,19 +215,6 @@
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div> <div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
<div id="opt-pos-cards"></div> <div id="opt-pos-cards"></div>
</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>
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden> <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"> <div class="opt-pos-pending-pane">
@@ -320,8 +345,39 @@
</div> </div>
</div> </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> </div>
</div> </div>
<script src="/static/options_expiry_countdown.js?v=1"></script> <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 %} {% endif %}
{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%} {% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%}
{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %} {% 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"> {% set wl = trade_policy.symbol_whitelist %}
<option value="">选择币种</option> {% set sole_sym = wl[0] if (wl|length) == 1 else '' %}
{% for sym in trade_policy.symbol_whitelist %} {% set effective = value if value else sole_sym %}
<option value="{{ sym }}" {% if value and ((value|upper) == sym or (value|upper).startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option> <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 %} {% endfor %}
</select> </select>
{% else %} {% 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: 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: 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 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: if base not in policy.symbol_whitelist:
return f"{policy.symbol_whitelist[0]}/USDT" 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( def check_symbol_policy(
policy: TradePolicy, 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) # true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1(反代须指向 127.0.0.1:5100)
HUB_TRUST_LAN=true HUB_TRUST_LAN=true
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"}) # 默认 true(代码默认允许公网/反代访问中控,靠 HUB_PASSWORD 保护)
# HUB_ALLOW_PUBLIC=true # 仅本机调试可关: HUB_ALLOW_PUBLIC=false
HUB_ALLOW_PUBLIC=true
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改) # 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
HUB_USERNAME=admin HUB_USERNAME=admin
+72 -13
View File
@@ -37,6 +37,11 @@ from lib.hub.hub_position_metrics import (
parse_position_unrealized_pnl, parse_position_unrealized_pnl,
resolve_position_display_upnl, resolve_position_display_upnl,
) )
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
normalize_api_credential,
strip_ccxt_credentials,
)
import ccxt import ccxt
from fastapi import FastAPI, Header, HTTPException, Request 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) app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None)
_ccxt_ex: Any = None _ccxt_ex: Any = None
_markets_loaded = False _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: def _socks_proxy_url(prefix: str) -> str:
return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip() 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: def _http_https_proxy(prefix: str) -> dict[str, str] | None:
http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip() http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip()
https = (os.getenv(f"{prefix}_HTTPS_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: def _make_exchange() -> Any:
_raise_if_auth_cooling()
if EXCHANGE_KIND == "binance": if EXCHANGE_KIND == "binance":
key = (os.getenv("BINANCE_API_KEY") or "").strip() key = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
secret = (os.getenv("BINANCE_API_SECRET") or "").strip() secret = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
if not key or not 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( ex = ccxt.binance(
{ {
"apiKey": key, "apiKey": key,
@@ -133,11 +158,11 @@ def _make_exchange() -> Any:
return ex return ex
if EXCHANGE_KIND == "okx": if EXCHANGE_KIND == "okx":
key = (os.getenv("OKX_API_KEY") or "").strip() key = normalize_api_credential(os.getenv("OKX_API_KEY"))
secret = (os.getenv("OKX_API_SECRET") or "").strip() secret = normalize_api_credential(os.getenv("OKX_API_SECRET"))
password = (os.getenv("OKX_API_PASSPHRASE") or "").strip() password = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
if not key or not secret or not password: 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( ex = ccxt.okx(
{ {
"apiKey": key, "apiKey": key,
@@ -154,10 +179,10 @@ def _make_exchange() -> Any:
return ex return ex
# gate # gate
key = (os.getenv("GATE_API_KEY") or "").strip() key = normalize_api_credential(os.getenv("GATE_API_KEY"))
secret = (os.getenv("GATE_API_SECRET") or "").strip() secret = normalize_api_credential(os.getenv("GATE_API_SECRET"))
if not key or not 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 from lib.exchange.gate_ccxt_lib import gate_ccxt_class
ex = gate_ccxt_class()( ex = gate_ccxt_class()(
@@ -177,6 +202,7 @@ def _make_exchange() -> Any:
def get_exchange() -> Any: def get_exchange() -> Any:
global _ccxt_ex global _ccxt_ex
_raise_if_auth_cooling()
if _ccxt_ex is None: if _ccxt_ex is None:
_ccxt_ex = _make_exchange() _ccxt_ex = _make_exchange()
return _ccxt_ex return _ccxt_ex
@@ -184,9 +210,17 @@ def get_exchange() -> Any:
def _ensure_markets() -> None: def _ensure_markets() -> None:
global _markets_loaded global _markets_loaded
if not _markets_loaded: if _markets_loaded:
return
_raise_if_auth_cooling()
try:
get_exchange().load_markets() get_exchange().load_markets()
_markets_loaded = True _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: 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 {} u = bal.get("USDT") or {}
if isinstance(u, dict) and u.get("total") is not None: if isinstance(u, dict) and u.get("total") is not None:
balance_usdt = _finite_or_none(u["total"]) balance_usdt = _finite_or_none(u["total"])
except Exception: except Exception as e:
pass 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]] = [] positions_out: list[dict[str, Any]] = []
total_upnl = 0.0 total_upnl = 0.0
@@ -587,6 +633,19 @@ def _status_inner(x_control_token: str | None) -> Any:
else: else:
raw = ex.fetch_positions() or [] raw = ex.fetch_positions() or []
except Exception as e: 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( return JSONResponse(
{ {
"ok": False, "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() 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() _trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off") HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
_allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower() # 默认 true:云端域名/反代可访问;仅靠 HUB_PASSWORD 保护.本地若要强制仅本机,设 HUB_ALLOW_PUBLIC=false
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护 _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC", "true") or "").strip().lower()
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on") HUB_ALLOW_PUBLIC = _allow_pub_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260607-hub-archive" HUB_BUILD = "20260607-hub-archive"
_archive_sync_stop: asyncio.Event | None = None _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 []) await asyncio.to_thread(record_fund_snapshot_from_board, body.get("rows") or [])
except Exception: except Exception:
pass pass
# 监控聚合完成即唤醒数据看板,持仓来源与监控 5s 同步. # 看板自有轮询即可;此处再 request_refresh 会与监控锁步,聚合变慢时几乎不睡眠打满 CPU.
dashboard_store.request_refresh()
return {"ok": True, **body} return {"ok": True, **body}
except asyncio.TimeoutError: except asyncio.TimeoutError:
return { return {
+7
View File
@@ -123,9 +123,16 @@ def _format_options_position_detail_line(p: dict) -> str:
if sheets is None: if sheets is None:
sheets = "?" sheets = "?"
parts = [f"期权 {inst} {label}", f"来源{src}", f"张数{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")) paid = _safe_float(p.get("premium_paid"))
if paid is not None: if paid is not None:
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") parts.append(f"权利金{paid:g}U")
else:
parts.append(f"权利金{paid:g}{ccy}")
net: Optional[float] = None net: Optional[float] = None
try: try:
from lib.options.options_positions_lib import net_pnl_from_display_row 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 asyncio
import json import json
import os import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any 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_POLL_INTERVAL = float(os.getenv("HUB_BOARD_POLL_INTERVAL", "5"))
HUB_BOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_BOARD_SSE_HEARTBEAT_SEC", "25")) 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: async def _loop(self) -> None:
assert self._build_fn is not None assert self._build_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._aggregate_once(self._build_fn) await self._aggregate_once(self._build_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(HUB_BOARD_POLL_INTERVAL)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=HUB_BOARD_POLL_INTERVAL,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _aggregate_once(self, build_fn: BuildFn) -> None: async def _aggregate_once(self, build_fn: BuildFn) -> None:
async with self._lock: async with self._lock:
+7 -8
View File
@@ -11,6 +11,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from hub_board_cache import board_store 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_POLL_INTERVAL = float(os.getenv("HUB_CHART_POLL_INTERVAL", "5"))
HUB_CHART_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_CHART_SSE_HEARTBEAT_SEC", "25")) 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: async def _loop(self) -> None:
assert self._poll_fn is not None assert self._poll_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._poll_once(self._poll_fn) await self._poll_once(self._poll_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(HUB_CHART_POLL_INTERVAL)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=HUB_CHART_POLL_INTERVAL,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _poll_once(self, poll_fn: PollFn) -> None: async def _poll_once(self, poll_fn: PollFn) -> None:
async with self._lock: async with self._lock:
+8 -8
View File
@@ -5,10 +5,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any from typing import Any
from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC 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")) 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: async def _loop(self) -> None:
assert self._build_fn is not None assert self._build_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._aggregate_once(self._build_fn) await self._aggregate_once(self._build_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(DASHBOARD_POLL_INTERVAL_SEC)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=DASHBOARD_POLL_INTERVAL_SEC,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _aggregate_once(self, build_fn: BuildFn) -> None: async def _aggregate_once(self, build_fn: BuildFn) -> None:
async with self._lock: async with self._lock:
+9 -8
View File
@@ -4,9 +4,12 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import time
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any 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_POLL_INTERVAL_SEC = float(os.getenv("SUPERVISOR_POLL_INTERVAL_SEC", "30"))
SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25")) SUPERVISOR_SSE_HEARTBEAT_SEC = float(os.getenv("SUPERVISOR_SSE_HEARTBEAT_SEC", "25"))
@@ -65,18 +68,16 @@ class SupervisorStore:
async def _loop(self) -> None: async def _loop(self) -> None:
assert self._tick_fn is not None assert self._tick_fn is not None
while not self._stop.is_set(): while not self._stop.is_set():
started = time.monotonic()
await self._tick_once(self._tick_fn) await self._tick_once(self._tick_fn)
if self._stop.is_set(): if self._stop.is_set():
break break
self._refresh.clear() await wait_poll_interval(
sleep_task = asyncio.create_task(asyncio.sleep(SUPERVISOR_POLL_INTERVAL_SEC)) refresh=self._refresh,
refresh_task = asyncio.create_task(self._refresh.wait()) stop=self._stop,
done, pending = await asyncio.wait( interval_sec=SUPERVISOR_POLL_INTERVAL_SEC,
{sleep_task, refresh_task}, started_at=started,
return_when=asyncio.FIRST_COMPLETED,
) )
for t in pending:
t.cancel()
async def _tick_once(self, tick_fn: TickFn) -> None: async def _tick_once(self, tick_fn: TickFn) -> None:
async with self._lock: async with self._lock:
+232 -18
View File
@@ -746,6 +746,93 @@
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); 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) */ /** 交易所持仓开仓价(三所子代理 entry_price) */
function positionEntryPrice(pos) { function positionEntryPrice(pos) {
if (!pos) return null; if (!pos) return null;
@@ -3882,27 +3969,95 @@
}; };
} }
function renderStatRow(funding, trading, upnl, kind) { function renderStatRow(funding, trading, upnl, kind, optMeta) {
if (!showAccountPnlPref()) return ""; if (!showAccountPnlPref()) return "";
const isOpt = kind === "options"; const isOpt = kind === "options";
const fundLabel = isOpt ? "期权资金账户" : "资金账户"; const fundLabel = isOpt ? "期权资金账户" : "资金账户";
const tradeLabel = isOpt ? "期权交易账户" : "交易账户"; const tradeLabel = isOpt ? "期权交易账户" : "交易账户";
const pnlLabel = isOpt ? "期权浮盈" : "浮盈合计"; const pnlLabel = isOpt ? "期权浮盈" : "浮盈合计";
const rowCls = isOpt ? "stat-row stat-row-options" : "stat-row"; 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}"> 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">资金账户</div><div class="stat-value">${fundTxt}</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">交易账户</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">${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 class="stat-box"><div class="stat-label">${pnlLabel}</div><div class="stat-value ${pnlCls(upnl)}">${fmt(upnl, 2)}</div></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) { function renderAccountStatRow(row, ag) {
return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl); return renderStatRow(row.funding_usdt, row.trading_usdt, ag.total_unrealized_pnl);
} }
function renderOptionsAccountStatRow(opt) { function renderOptionsAccountStatRow(opt) {
const bal = optionsBalanceFields(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) { function shortOptionsInst(instId) {
@@ -3928,14 +4083,47 @@
); );
} }
function renderOptionsTargetCell(target) { function formatProfitExitMultLabel(mult) {
if (!target) return "<td>—</td>"; 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 side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
const px = target.target_index != null ? fmt(target.target_index, 1) : "—"; const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
if (target.managed_by === "hedge_plan") {
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 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) { function renderOptionsPositionsTable(pos, targets) {
@@ -3960,13 +4148,16 @@
} }
const target = findOptionsTargetForInst(targets, p.inst_id); const target = findOptionsTargetForInst(targets, p.inst_id);
html += `<tr> 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(optType)}</td>
<td>${esc(p.pos)}</td> <td>${esc(p.pos)}</td>
<td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td> <td>${optionsExpiryCdHtml(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
${renderOptionsTargetCell(target)}`; ${renderOptionsTargetCell(target, p)}`;
if (showPnl) { 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>`; <td class="${pnlCls(net)}">${roi == null ? "—" : esc(Number(roi).toFixed(2)) + "%"}</td>`;
} }
html += "</tr>"; html += "</tr>";
@@ -4023,7 +4214,14 @@
const pos = Array.isArray(opt.positions) ? opt.positions : []; const pos = Array.isArray(opt.positions) ? opt.positions : [];
const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : []; const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : [];
html += renderOptionsAccountStatRow(opt); 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 += html +=
layout === "cards" layout === "cards"
? renderOptionsPositionsCards(pos) ? renderOptionsPositionsCards(pos)
@@ -4444,6 +4642,8 @@
let optLine = ""; let optLine = "";
let pnlShow = upnl; let pnlShow = upnl;
let pnlSuffix = ""; let pnlSuffix = "";
let pnlUnit = "U";
let pnlSpotPx = null;
if (hasOptCap) { if (hasOptCap) {
if (opt.enabled === false) { if (opt.enabled === false) {
optLine = "期权未启用"; optLine = "期权未启用";
@@ -4456,17 +4656,29 @@
const n = Number.isFinite(optCount) ? optCount : 0; const n = Number.isFinite(optCount) ? optCount : 0;
const bal = optionsBalanceFields(opt); const bal = optionsBalanceFields(opt);
const optUpl = bal.upl != null ? bal.upl : null; const optUpl = bal.upl != null ? bal.upl : null;
const optCcy = optionsPanelCcy(opt);
const parts = [n > 0 ? `期权 ${n}` : "期权 空仓"]; const parts = [n > 0 ? `期权 ${n}` : "期权 空仓"];
if (showAccountPnlPref()) { if (showAccountPnlPref()) {
if (bal.funding != null) parts.push(`资金 ${fmt(bal.funding, 2)}U`); 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))) { 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) { if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) {
// 永续空仓时主数字优先展示期权浮盈,避免一直显示 0U // 永续空仓时主数字优先展示期权浮盈(只显示 U)
pnlShow = optUpl; pnlShow = optUpl;
pnlSuffix = "期权"; pnlSuffix = "期权";
pnlUnit = optCcy;
pnlSpotPx = spotPxFromOptMeta(opt);
} }
} }
optLine = parts.join(" · "); optLine = parts.join(" · ");
@@ -4475,6 +4687,10 @@
const hm = row.hub_monitor || {}; const hm = row.hub_monitor || {};
const flaskOk = row.flask_ok !== false && hm.ok !== false; const flaskOk = row.flask_ok !== false && hm.ok !== false;
const strategyStats = renderCardStrategyStats(row, hm, flaskOk); 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)}"> 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-body card-expand-zone" title="点击进入全屏详情">
<div class="hub-tile-top"> <div class="hub-tile-top">
@@ -4484,9 +4700,7 @@
</div> </div>
${ ${
showAccountPnlPref() showAccountPnlPref()
? `<div class="hub-tile-pnl ${pnlCls(pnlShow)}">${fmt(pnlShow, 2)} <small>U${ ? `<div class="hub-tile-pnl ${pnlCls(pnlShow)}">${tilePnlHtml}</div>`
pnlSuffix ? " · " + pnlSuffix : ""
}</small></div>`
: "" : ""
} }
<div class="hub-tile-meta">${esc(posLine)}</div> <div class="hub-tile-meta">${esc(posLine)}</div>
+38 -1
View File
@@ -40,6 +40,35 @@
return `${n > 0 ? "+" : "-"}${abs}U`; 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) { function esc(s) {
return String(s == null ? "" : s) return String(s == null ? "" : s)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -338,7 +367,15 @@
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td> <td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
<td><span class="${targetCls}">${esc(target)}</span></td>`; <td><span class="${targetCls}">${esc(target)}</span></td>`;
if (showPnl) { 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>`; <td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>`;
} }
html += "</tr>"; html += "</tr>";
+1 -1
View File
@@ -1767,6 +1767,6 @@
<script src="/assets/options_expiry_countdown.js?v=1"></script> <script src="/assets/options_expiry_countdown.js?v=1"></script>
<script src="/assets/options_position_cards.js?v=4"></script> <script src="/assets/options_position_cards.js?v=4"></script>
<script src="/assets/backup.js?v=1"></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> </body>
</html> </html>
+1 -1
View File
@@ -146,7 +146,7 @@
return; return;
} }
if (r.status === 403) { if (r.status === 403) {
showErr("访问被拒绝(403):云端 hub 需设置 HUB_ALLOW_PUBLIC=true"); showErr("访问被拒绝(403):请确认 HUB_ALLOW_PUBLIC 未设为 false,并检查反代/登录配置");
} else { } else {
showErr(j.detail || j.msg || "用户名或密码错误 (" + r.status + ")"); 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`)聚合三户数据到内存快照. 1. `hub.py` 启动后 `dashboard_store`**60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合三户数据到内存快照.
2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`). 2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`).
3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**. 3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**.
4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步. 4. 监控区触发 board 刷新(全平,撤单等)时,会一并 `request_refresh` 看板;常规轮询二者各自按间隔跑,避免连锁打满 CPU.
5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合. 5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合.
可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔). 可选环境变量:`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} 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} b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
p = build_options_options_preview( p = build_options_options_preview(
target_price_up=3500, profit_rr=2,
target_price_down=3000,
index_px=3200, index_px=3200,
leg_a=a, leg_a=a,
leg_b=b, leg_b=b,
) )
self.assertEqual(p["summary"]["premium_paid"], 10) self.assertEqual(p["summary"]["premium_paid"], 10)
self.assertTrue(p["summary"]["expiry_is_loss"]) self.assertTrue(p["summary"]["expiry_is_loss"])
self.assertEqual(p["summary"]["rr_risk_premium"], 10) self.assertEqual(p["summary"]["profit_rr"], 2)
self.assertIsNotNone(p["summary"]["rr_at_up"]) self.assertEqual(p["summary"]["at_rr_a_full_total"], 15) # 盈利=2*10, 亏腿-5
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4) self.assertEqual(len(p["scenarios"]), 5)
self.assertEqual(len(p["scenarios"]), 4) self.assertEqual(p["scenarios"][0]["id"], "rr_leg_a_full")
self.assertEqual(p["scenarios"][0]["id"], "target_up") self.assertEqual(p["scenarios"][1]["id"], "rr_leg_b_full")
self.assertEqual(p["scenarios"][1]["id"], "target_down") # 到期实值反推: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): def test_oo_legacy_single_target_still_works(self):
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5} 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-1850-P"]["target_index"], 1800)
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+1 -2
View File
@@ -104,8 +104,7 @@ class TestHedgeMoneyness(unittest.TestCase):
err = validate_start_body( err = validate_start_body(
"options_options", "options_options",
{ {
"target_price_up": 1900, "profit_rr": 2,
"target_price_down": 1700,
"index_px": 1800, "index_px": 1800,
"leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700}, "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}, "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, "budget_buffer": 0.95,
} }
body = { body = {
"target_price": 1900, "profit_rr": 2,
"target_price_up": 1950,
"target_price_down": 1750,
"oo_sheets_mode": "same_sheets", "oo_sheets_mode": "same_sheets",
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"}, "leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"}, "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["options_float_pnl_u"], 1.5)
self.assertEqual(out["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): def test_aggregate_excludes_option_like_agent_positions(self):
"""子代理误把期权当永续上报时:不算进持仓数,浮盈只用期权 snap.""" """子代理误把期权当永续上报时:不算进持仓数,浮盈只用期权 snap."""
rows = [ rows = [
@@ -74,7 +101,7 @@ class TestHubMonitorTotals(unittest.TestCase):
"options": { "options": {
"ok": True, "ok": True,
"enabled": 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, "upl_total_usdc": -0.4,
}, },
"agent": { "agent": {
+22
View File
@@ -63,6 +63,28 @@ class HubOptionsFundsLibTests(TestCase):
self.assertEqual(out["options_open_position_count"], 1) self.assertEqual(out["options_open_position_count"], 1)
self.assertEqual(out["options_float_pnl_u"], 0.5) 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): def test_repair_double_counted_fund_entry(self):
raw = { raw = {
"funding_usdt": 586.82, "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", "OKX_POS_MODE",
"POSITION_SIZING_MODE", "POSITION_SIZING_MODE",
"TRADE_DIRECTION", "TRADE_DIRECTION",
"OKX_OPTIONS_MARGIN_MODE",
): ):
self.assertIn(key, SELECT_OPTIONS) self.assertIn(key, SELECT_OPTIONS)
@@ -101,9 +102,19 @@ class TestEnvSchema(unittest.TestCase):
self.skipTest("missing okx .env.example") self.skipTest("missing okx .env.example")
groups = build_env_ui_payload("okx", example, env_path if os.path.isfile(env_path) else 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"]} 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.assertEqual(by_key[key]["type"], "select")
self.assertTrue(by_key[key]["options"]) 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.assertIn("KEY_AUTO_ORDER_ENABLED", by_key)
self.assertEqual(by_key["KEY_AUTO_ORDER_ENABLED"]["label"], "关键位自动单") self.assertEqual(by_key["KEY_AUTO_ORDER_ENABLED"]["label"], "关键位自动单")
self.assertEqual(by_key["KEY_AUTO_ORDER_ENABLED"]["type"], "bool") 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.assertTrue(by_key["OKX_SHOW_PERP_FUNDS"].get("hot_reload"))
self.assertNotIn("OKX_OPTIONS_API_KEY", by_key) self.assertNotIn("OKX_OPTIONS_API_KEY", by_key)
self.assertNotIn("OKX_SUB_ACCOUNT_NAME", 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"]]}] groups_v = [{"title": "t", "fields": [by_key["TRADE_DIRECTION"]]}]
clean, errors = validate_env_updates(groups_v, {"TRADE_DIRECTION": "long_only"}) 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 os.environ["OKX_SHOW_PERP_FUNDS"] = old
def test_options_funding_label_usdc_only(self): 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(12.5, 99.0), "12.50 USDC")
self.assertEqual(options_funding_label(0.0, 50.0), "0.00 USDC") self.assertEqual(options_funding_label(0.0, 50.0), "0.00 USDC")
self.assertEqual(options_funding_label(None, 10.0), "") 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__": if __name__ == "__main__":
+21 -3
View File
@@ -27,18 +27,36 @@ class TestHeaderStatsLib(unittest.TestCase):
def test_total_funds_usdt(self): def test_total_funds_usdt(self):
self.assertEqual(total_funds_usdt(100.5, 59.27), 159.77) 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( self.assertEqual(
total_funds_usdt(100, 50, options_trading_usdc=0.2, options_trading_usdt=10), total_funds_usdt(100, 50, options_trading_usdc=0.2, options_trading_usdt=10),
160.2, 160.2,
) )
def test_options_funding_label(self): 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(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), "") 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__": if __name__ == "__main__":
unittest.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) result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20)
self.assertFalse(result["ok"]) self.assertFalse(result["ok"])
self.assertEqual(result["msg"], "资金账户 USDT 可用余额不足") self.assertEqual(result["msg"], "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)")
self.assertNotIn("{", result["msg"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+92 -10
View File
@@ -1,16 +1,98 @@
"""按可用余额打满:min(余额, 单笔预算).""" """按可用余额打满 / 全仓复利定仓."""
from __future__ import annotations 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(): class TestOptionsBudgetModes(unittest.TestCase):
assert resolve_budget_full_usdc(100.0, 10.0) == 10.0 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(): if __name__ == "__main__":
assert resolve_budget_full_usdc(5.0, 10.0) == 5.0 unittest.main()
def test_balance_equals_budget():
assert resolve_budget_full_usdc(10.0, 10.0) == 10.0
+142 -15
View File
@@ -1,6 +1,7 @@
"""期权平仓门控:可回收≥2×权利金且持续持有.""" """期权平仓门控:USDT 口径(权利金×倍数 / 净盈亏阈值)且持续持有."""
from __future__ import annotations from __future__ import annotations
import os
import unittest import unittest
from lib.options.options_close_gate_lib import ( from lib.options.options_close_gate_lib import (
@@ -14,43 +15,169 @@ from lib.options.options_close_gate_lib import (
class OptionsCloseGateTests(unittest.TestCase): class OptionsCloseGateTests(unittest.TestCase):
def setUp(self): def setUp(self):
clear_close_gate() 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): def tearDown(self):
clear_close_gate() 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): def test_usdc_below_2x_not_ready(self):
g = update_close_gate("ETH-X", recycle_usdc=15.0, premium_paid=10.0, now=1000.0) 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["recycle_ok"])
self.assertFalse(g["ready"]) self.assertFalse(g["ready"])
self.assertIn("U(估)", g["msg"])
def test_meets_2x_needs_hold(self): def test_usdc_meets_2x_needs_hold(self):
g1 = update_close_gate("ETH-X", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) 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.assertTrue(g1["recycle_ok"])
self.assertFalse(g1["ready"]) self.assertFalse(g1["ready"])
self.assertAlmostEqual(g1["remain_seconds"], 120.0) 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.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): 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(
update_close_gate("ETH-X", recycle_usdc=21.0, premium_paid=10.0, now=1100.0) "ETH-X",
g_break = update_close_gate("ETH-X", recycle_usdc=12.0, premium_paid=10.0, now=1110.0) 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"]) 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.assertTrue(g_again["recycle_ok"])
self.assertFalse(g_again["ready"]) self.assertFalse(g_again["ready"])
self.assertAlmostEqual(g_again["held_seconds"], 0.0) self.assertAlmostEqual(g_again["held_seconds"], 0.0)
def test_passed_latches_after_ready(self): def test_passed_latches_after_ready(self):
update_close_gate("ETH-Y", recycle_usdc=20.0, premium_paid=10.0, now=1000.0) update_close_gate(
g_ready = update_close_gate("ETH-Y", recycle_usdc=21.0, premium_paid=10.0, now=1120.0) "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["ready"])
self.assertTrue(g_ready["passed"]) self.assertTrue(g_ready["passed"])
self.assertTrue(is_close_gate_passed("ETH-Y")) self.assertTrue(is_close_gate_passed("ETH-Y"))
# 后续回收跌破 2×:计时重置,但 passed 仍保留供续批只验流动性 g_drop = update_close_gate(
g_drop = update_close_gate("ETH-Y", recycle_usdc=5.0, premium_paid=10.0, now=1130.0) "ETH-Y",
recycle_usdc=5.0,
premium_paid=10.0,
premium_ccy="USDC",
now=1130.0,
)
self.assertFalse(g_drop["recycle_ok"]) self.assertFalse(g_drop["recycle_ok"])
self.assertTrue(g_drop["passed"]) self.assertTrue(g_drop["passed"])
self.assertFalse(g_drop["auto_close_blocked"]) 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): 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( open_msg = build_options_open_message(
account_label="OKX期权", account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C", inst_id="ETH-USD_UM-250725-3200-C",
underlying="ETH", underlying="ETH",
opt_type="C", opt_type="C",
sheets=2, sheets=2,
@@ -23,14 +23,17 @@ class TestOptionsNotify(unittest.TestCase):
target_index=3400, target_index=3400,
signal_note="假突破", signal_note="假突破",
trade_id=12, trade_id=12,
premium_ccy="USDC",
margin_mode="usdc",
) )
self.assertIn("【OKX期权·开仓】", open_msg) 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("目标指数:3400", open_msg)
self.assertIn("USDC", open_msg)
close_msg = build_options_close_message( close_msg = build_options_close_message(
account_label="OKX期权", account_label="OKX期权",
inst_id="ETH-USD-250725-3200-C", inst_id="ETH-USD_UM-250725-3200-C",
reason="手动平仓", reason="手动平仓",
underlying="ETH", underlying="ETH",
opt_type="C", opt_type="C",
@@ -38,10 +41,45 @@ class TestOptionsNotify(unittest.TestCase):
premium_paid=8.5, premium_paid=8.5,
premium_received=12.0, premium_received=12.0,
realized_pnl=3.5, realized_pnl=3.5,
premium_ccy="USDC",
) )
self.assertIn("【OKX期权·平仓】", close_msg) self.assertIn("【OKX期权·平仓】", close_msg)
self.assertIn("手动平仓", close_msg) self.assertIn("手动平仓", close_msg)
self.assertIn("3.5000", 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__": if __name__ == "__main__":
+68 -1
View File
@@ -267,6 +267,35 @@ def test_stub_bid_blocks_auto_close_estimate():
assert good["covered_sheets"] == 10 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(): def test_expiry_breakeven_from_ask():
from lib.options.options_pricing_lib import 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 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(): def test_close_breakeven_at_mark_equals_avg():
from lib.options.options_pricing_lib import close_breakeven_idx 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["expiry_be_px"] == 3515.6
assert row["idx_px"] == 3480.0 assert row["idx_px"] == 3480.0
assert row["close_be_px"] is not None 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.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2)
self.assertEqual(out["open_count"], 1) self.assertEqual(out["open_count"], 1)
self.assertAlmostEqual(out["net_realized_pnl"], round(0.87 - 3.99 - 1.33, 4), places=4) 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") 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"