Compare commits
61 Commits
6fad68f7b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b63f6f0962 | |||
| 75f50fe083 | |||
| f675f9997a | |||
| 081afeba76 | |||
| e38039d99a | |||
| c1a84013b9 | |||
| fe346571b1 | |||
| 5f9901db0f | |||
| 5e2f332bdd | |||
| aa1a2da2b7 | |||
| 57cca5554e | |||
| aa14688a7e | |||
| fe5bb923d7 | |||
| 893a2cc115 | |||
| 9421ff7360 | |||
| e261df0009 | |||
| 1ddfe3f72e | |||
| 467f4f092f | |||
| 8b5080bdda | |||
| 9bb05113f3 | |||
| f5c553844f | |||
| d4d2110412 | |||
| 2e2d5ddda0 | |||
| b763937ec2 | |||
| a7bec5e121 | |||
| b0c331aa26 | |||
| 87910ed71a | |||
| 4fb3be35ef | |||
| 6c49c7d51c | |||
| 6c9825284f | |||
| 70f6cc2e7b | |||
| 9f3360e968 | |||
| ed3f4898dd | |||
| 15694e8ea8 | |||
| f3de3763bb | |||
| 73efad6fa5 | |||
| a84554e613 | |||
| 1314349fe5 | |||
| 72c84bb993 | |||
| dd8fbae0dd | |||
| 1c2d6ef3a9 | |||
| 2028251fc1 | |||
| 339f5e6db0 | |||
| 71a91484a3 | |||
| 86cf722117 | |||
| 2a33f74252 | |||
| 271865fa3d | |||
| 9c19afc8d4 | |||
| 8605efa2ed | |||
| cd23ea74a6 | |||
| 8dda7500df | |||
| 886b6dcc5b | |||
| a8d6795837 | |||
| 51e454b0f6 | |||
| 1522117eeb | |||
| a354811a6e | |||
| 3d7d754ba3 | |||
| 38e3e00fe9 | |||
| a1bf760a28 | |||
| c5d3d9d6c1 | |||
| e26a67176c |
@@ -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=单向
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 盘口档数
|
||||||
|
|
||||||
|
- 请求深度建议 **5~20 档**(实现时三所取各自 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. 决策摘要(已拍板)
|
||||||
|
|
||||||
|
- **要做**:按计划名义展示「覆盖该名义所需」的对手盘摘要 + 预估均价/滑点.
|
||||||
|
- **做空看买单,做多看卖单**.
|
||||||
|
- **首版只展示 + 软提示,不挡单**.
|
||||||
|
- **不为小资金做整屏盘口墙**;大名义时深度预览才有关键决策价值.
|
||||||
@@ -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:实例手机壳(下单/持仓/期权)、著作权声明、托管合同(一用户一机)、服务说明与报价说明 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 配置**(同步三所) |
|
||||||
|
|
||||||
### 操作流程
|
### 操作流程
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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__);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
||||||
|
|||||||
@@ -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: "持有至到期",
|
||||||
|
|||||||
Vendored
+18
@@ -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", "保险模式"),
|
||||||
|
|||||||
Vendored
+124
-12
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "双腿权利金全部损失",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"]),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# 兼容旧名
|
# 兼容旧名
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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, S−K) / max(0, K−S)
|
||||||
|
- 币本位 ETH-USD / BTC-USD: 币报价 max(0, S−K)/S / max(0, K−S)/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:BE−K, Put:K−BE)."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">
|
||||||
余额 > 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
|
余额 > 单笔预算(<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 %}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -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>";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 + ")");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 心跳间隔).
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user