Add OKX trading-account USDC auto-swap and lock exit target while in position.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 00:16:47 +08:00
parent f0b0b91f1b
commit 0837982714
16 changed files with 626 additions and 36 deletions
+186
View File
@@ -0,0 +1,186 @@
"""OKX 开仓前:交易账户 USDC 不足时市价 USDT→USDC(目标=期权所需×2)。
仅 OKX SIM/LIVE。资金账户不参与;期权已可开则跳过。
"""
from __future__ import annotations
import logging
import math
from typing import Any
from ..config import get_settings
from ..models.db import Database, get_db
from .open_capacity import assess_open_capacity, invalidate_live_balance_cache
logger = logging.getLogger(__name__)
# 目标持仓 = 期权开仓所需 USDC × 倍数
_TARGET_MULTIPLE = 2.0
# 过小不兑(避免粉尘单)
_MIN_CONVERT_USDT = 1.0
def _is_okx(exchange: str | None = None) -> bool:
from ..exchange.runtime import load_runtime_settings, normalize_exchange_name
ex = exchange
if not ex:
try:
ex = load_runtime_settings().exchange
except Exception:
ex = get_settings().exchange
return normalize_exchange_name(ex) == "okx"
def _round_down(n: float, nd: int = 2) -> float:
if n <= 0:
return 0.0
f = 10**nd
return math.floor(n * f + 1e-12) / f
def ensure_okx_trading_usdc(
db: Database | None = None,
*,
cap: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
开仓资金门前调用:
- 非 OKX → 跳过
- 期权可开(USDC≥需)→ 跳过
- 否则在**交易账户**市价 USDT→USDC,尽量补到 需×2(并预留永续保证金)
"""
db = db or get_db()
out: dict[str, Any] = {
"ok": True,
"acted": False,
"skipped": True,
"detail": "skip",
}
if not _is_okx():
out["detail"] = "非 OKX,跳过自动兑 USDC"
return out
cap = cap or assess_open_capacity(db)
need = cap.get("option_need_usdc")
have = cap.get("option_have_usdc")
if need is None or have is None:
out["detail"] = "期权所需/持有未知,跳过兑换"
out["capacity"] = cap
return out
need_f = float(need)
have_f = float(have)
if need_f <= 0:
out["detail"] = "期权所需为 0,跳过"
return out
# 可开仓:不兑换(即使低于 2 倍目标)
if have_f + 1e-9 >= need_f:
out["detail"] = (
f"交易账户 USDC 已够开仓(有 {have_f:.2f} ≥ 需 {need_f:.2f}),不兑换"
)
out["capacity"] = cap
return out
target = need_f * _TARGET_MULTIPLE
gap_usdc = target - have_f
if gap_usdc <= 1e-6:
out["detail"] = "无需补足"
return out
from ..live.okx_funds import usdc_usdt_mid_rate
rate = float(usdc_usdt_mid_rate() or 1.0)
if rate <= 0:
rate = 1.0
# usdt_to_usdcamount = 花费的 USDT(与 OkxFundsClient / SIM 一致)
want_usdt = gap_usdc * rate
perp_need = float(cap.get("perp_need_usdt") or 0)
trading_usdt = float(cap.get("perp_have_usdt") or 0)
# 预留永续保证金,避免兑光导致永续不可开
spendable = max(0.0, trading_usdt - max(0.0, perp_need))
spend_usdt = _round_down(min(want_usdt, spendable), 2)
out.update(
{
"need_usdc": round(need_f, 2),
"have_usdc": round(have_f, 2),
"target_usdc": round(target, 2),
"want_usdt": round(want_usdt, 2),
"spend_usdt": spend_usdt,
"rate": rate,
"spendable_usdt": round(spendable, 2),
}
)
if spend_usdt < _MIN_CONVERT_USDT:
out["ok"] = False
out["skipped"] = True
out["detail"] = (
f"交易账户可兑 USDT 不足(可兑 {spendable:.2f}"
f"目标补约 {want_usdt:.2f},门槛 {_MIN_CONVERT_USDT}"
)
out["capacity"] = cap
return out
s = get_settings()
try:
if s.is_sim:
from ..sim.funds_wallets import SimFundsWallets
r = SimFundsWallets(db).convert(
direction="usdt_to_usdc",
amount=spend_usdt,
rate=rate,
account="trading",
)
else:
from ..live.okx_funds import OkxFundsClient
client = OkxFundsClient()
try:
r = client.spot_swap_usdt_usdc(
direction="usdt_to_usdc",
amount=spend_usdt,
)
finally:
client.close()
invalidate_live_balance_cache()
except Exception as e:
logger.exception("auto USDC convert failed")
out["ok"] = False
out["skipped"] = False
out["acted"] = False
out["detail"] = f"自动兑换异常:{e}"
return out
if not r.get("ok"):
out["ok"] = False
out["skipped"] = False
out["acted"] = False
out["detail"] = f"自动兑换失败:{r.get('detail') or r}"
out["raw"] = r
return out
# 兑换后重评
invalidate_live_balance_cache()
cap2 = assess_open_capacity(db)
out.update(
{
"ok": True,
"acted": True,
"skipped": False,
"detail": (
f"交易账户市价兑 USDC:花 {spend_usdt:.2f} USDT"
f"(目标持仓≈{target:.2f}=需{need_f:.2f}×{_TARGET_MULTIPLE:g}"
),
"capacity_before": cap,
"capacity_after": cap2,
"raw": r,
}
)
logger.info("auto_usdc: %s", out["detail"])
return out
+52 -2
View File
@@ -129,8 +129,26 @@ class StrategyEngine:
risk_exit_unit = self.ledger.get_setting_float("risk_exit_unit", 15.0)
risk_last_k = self.ledger.get_setting_float("risk_last_k", 0.0)
risk_preview: dict[str, Any] | None = None
# 以损定仓:监控页展示「下一次开仓」实时估算,避免仍显示上次手填/过期名义
if sizing_mode == "risk_based":
pos_status = str(upl.get("status") or "flat")
trade_locked = pos_status in (
"open",
"half_open",
"option_closed_perp_pending",
"opening",
)
locked_exit = None
try:
from .exits import read_locked_exit_target
locked_exit = read_locked_exit_target(upl)
except Exception:
locked_exit = None
if trade_locked and locked_exit is not None:
# 持仓中:出场目标锁定,不再用盘口重算覆盖
net_target = float(locked_exit)
exit_amt = float(locked_exit)
# 以损定仓:仅空仓时用实时估算覆盖展示;持仓中保持开仓锁定名义/k/目标
if sizing_mode == "risk_based" and not trade_locked:
try:
from .risk_sizing import preview_risk_sizing
@@ -154,6 +172,16 @@ class StrategyEngine:
except Exception:
logger.exception("risk sizing preview for state() failed")
risk_preview = {"ok": False, "detail": "以损定仓预览失败"}
elif sizing_mode == "risk_based" and trade_locked:
risk_preview = {
"ok": True,
"locked": True,
"detail": "持仓中已锁定本组成交目标与名义,平仓后再自动计算",
"net_profit_target": net_target,
"k": risk_last_k if risk_last_k > 0 else None,
"perp_qty_eth": perp_qty,
"option_qty_eth": opt_qty,
}
rest_until = row["rest_until_ms"]
rest_left = 0
if rest_until:
@@ -199,6 +227,7 @@ class StrategyEngine:
"risk_exit_unit": risk_exit_unit,
"risk_last_k": risk_last_k if risk_last_k > 0 else None,
"risk_sizing_preview": risk_preview,
"risk_sizing_locked": bool(trade_locked and sizing_mode == "risk_based"),
"min_option_hours": min_hours,
"min_option_leverage": min_opt_lev,
"atm_open_offset_enabled": atm_off_on,
@@ -687,6 +716,18 @@ class StrategyEngine:
if st_pos == "open":
upl = self.matcher.unrealized()
from .exits import lock_trade_exit_target, read_locked_exit_target
locked_exit = read_locked_exit_target(upl)
if locked_exit is None and upl.get("group_id"):
try:
locked_exit = lock_trade_exit_target(
self.db,
group_id=str(upl["group_id"]),
initial_premium=float(upl.get("initial_premium") or 0),
)
except Exception:
logger.exception("backfill exit lock failed")
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
decision = check_exits(
net_pnl=float(upl.get("net_pnl") or 0),
@@ -694,6 +735,7 @@ class StrategyEngine:
net_profit_target=net_target,
premium_exit_multiple=prem_mult,
initial_premium=float(upl.get("initial_premium") or 0),
locked_exit_target=locked_exit,
)
pending_close = st["phase"] in ("liquidity_wait", "closing")
if expired.should_close or decision.should_close or pending_close:
@@ -800,6 +842,14 @@ class StrategyEngine:
self._set_state(phase="idle", last_error="以损定仓计算异常,暂不开仓")
return
# OKX:交易账户 USDC 不够开期权时,市价 USDT→USDC(目标=所需×2);够则跳过
try:
from .auto_usdc import ensure_okx_trading_usdc
ensure_okx_trading_usdc(self.db)
except Exception:
logger.exception("auto USDC top-up failed")
try:
cap = assess_open_capacity(self.db)
if cap.get("perp_can_open") is False or cap.get("option_can_open") is False:
+69 -7
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
EXIT_MODE_FIXED = "fixed_usdt"
EXIT_MODE_PREMIUM = "premium_multiple"
@@ -29,6 +30,62 @@ def resolve_exit_target(
return float(net_profit_target), EXIT_MODE_FIXED
def lock_trade_exit_target(
db: Any,
*,
group_id: str,
initial_premium: float,
) -> float:
"""
开仓成交后锁定本组成交出场目标到 groups/positions。
持仓期间盯盘与展示均用该值,不再跟随时价重算以损定仓/出场。
"""
from ..config import get_settings
from ..sim.ledger import Ledger
s = get_settings()
ledger = Ledger(db)
exit_mode = ledger.get_setting_str("exit_mode", s.exit_mode) or EXIT_MODE_FIXED
net_target = float(
ledger.get_setting_float("net_profit_target", s.net_profit_target)
or s.net_profit_target
)
prem_mult = float(
ledger.get_setting_float("premium_exit_multiple", s.premium_exit_multiple)
or s.premium_exit_multiple
)
target, _mode = resolve_exit_target(
exit_mode=str(exit_mode),
net_profit_target=net_target,
premium_exit_multiple=prem_mult,
initial_premium=float(initial_premium or 0),
)
target = float(target)
db.execute(
"UPDATE groups SET exit_target_usdt=? WHERE group_id=?",
(target, group_id),
)
db.execute(
"UPDATE positions SET exit_target_usdt=? WHERE id=1",
(target,),
)
return target
def read_locked_exit_target(pos: dict[str, Any] | None) -> float | None:
"""持仓行上的锁定目标;无则 None(旧仓回退设置值)。"""
if not pos:
return None
v = pos.get("exit_target_usdt")
if v is None or v == "":
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
return f if f > 0 else None
def check_expiry_close(
*,
expiry_ms: int | None,
@@ -50,14 +107,19 @@ def check_exits(
net_profit_target: float,
premium_exit_multiple: float,
initial_premium: float,
locked_exit_target: float | None = None,
) -> ExitDecision:
"""净盈利(预估全平后)≥ 所选模式目标则全平。"""
target, mode = resolve_exit_target(
exit_mode=exit_mode,
net_profit_target=net_profit_target,
premium_exit_multiple=premium_exit_multiple,
initial_premium=initial_premium,
)
"""净盈利(预估全平后)≥ 所选模式目标则全平。持仓锁定目标优先。"""
if locked_exit_target is not None and float(locked_exit_target) > 0:
target = float(locked_exit_target)
mode = EXIT_MODE_FIXED
else:
target, mode = resolve_exit_target(
exit_mode=exit_mode,
net_profit_target=net_profit_target,
premium_exit_multiple=premium_exit_multiple,
initial_premium=initial_premium,
)
if target > 0 and net_pnl + 1e-9 >= target:
reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt"
return ExitDecision(True, reason, target)
+7 -1
View File
@@ -20,6 +20,12 @@ _LIVE_BAL_TTL_SEC = 8.0
_notified_while_short: bool = False
def invalidate_live_balance_cache() -> None:
"""兑换/划转后强制下次重拉交易账户余额。"""
_live_bal_cache["ts"] = 0.0
_live_bal_cache["data"] = None
def _f(v: Any) -> float | None:
try:
if v is None or v == "":
@@ -211,7 +217,7 @@ def maybe_notify_funds_short(cap: dict[str, Any] | None = None) -> None:
f"**永续**: {cap.get('perp_label')}",
f"**期权**: {cap.get('option_label')}",
*[f"**详情**: {p}" for p in parts],
"请从资金账户划转到交易账户后重试",
"OKX 开仓前会尝试交易账户市价兑 USDC;仍不足请检查交易账户余额",
],
)
)
+11 -1
View File
@@ -320,12 +320,22 @@ def apply_risk_sizing_to_ledger(
option_ask: float,
db: Database | None = None,
) -> RiskSizingResult:
"""计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。"""
"""计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。持仓中拒绝改写。"""
database = db or get_db()
ledger = Ledger(database)
if not is_risk_based(ledger):
return RiskSizingResult(ok=True, detail="manual_sizing_skip")
# 有活跃仓:本组成场参数已锁定,禁止重算覆盖
pos = database.fetchone("SELECT status, group_id FROM positions WHERE id=1")
if pos is not None:
st = str(pos["status"] or "flat")
if st in ("open", "half_open", "option_closed_perp_pending", "opening"):
return RiskSizingResult(
ok=False,
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
)
r = compute_risk_sizing(index_px=index_px, option_ask=option_ask, db=database)
if not r.ok:
return r