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:
@@ -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_usdc:amount = 花费的 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;仍不足请检查交易账户余额。",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user