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:
@@ -115,6 +115,13 @@ async def sim_open_group(
|
||||
if not rs.ok:
|
||||
raise HTTPException(status_code=409, detail=rs.detail)
|
||||
|
||||
try:
|
||||
from ..strategy.auto_usdc import ensure_okx_trading_usdc
|
||||
|
||||
ensure_okx_trading_usdc(db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from ..strategy.open_capacity import assess_open_capacity
|
||||
|
||||
|
||||
@@ -362,6 +362,15 @@ class BinanceLiveExecutor(Matcher):
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
try:
|
||||
from ..strategy.exits import lock_trade_exit_target
|
||||
|
||||
lock_trade_exit_target(
|
||||
self.db, group_id=group_id, initial_premium=initial_premium
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("lock exit target failed group=%s", group_id)
|
||||
|
||||
return OpenResult(
|
||||
ok=True,
|
||||
group_id=group_id,
|
||||
@@ -480,6 +489,14 @@ class BinanceLiveExecutor(Matcher):
|
||||
),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
try:
|
||||
from ..strategy.exits import lock_trade_exit_target
|
||||
|
||||
lock_trade_exit_target(
|
||||
self.db, group_id=group_id, initial_premium=initial_premium
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("lock exit target failed half_open group=%s", group_id)
|
||||
|
||||
def repair_half_open(self) -> CloseResult:
|
||||
"""卖出 half_open 残留期权,清本地状态。"""
|
||||
@@ -557,7 +574,8 @@ class BinanceLiveExecutor(Matcher):
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
@@ -668,6 +686,14 @@ class BinanceLiveExecutor(Matcher):
|
||||
),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
try:
|
||||
from ..strategy.exits import lock_trade_exit_target
|
||||
|
||||
lock_trade_exit_target(
|
||||
self.db, group_id=group_id, initial_premium=initial_premium
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("lock exit target failed recover group=%s", group_id)
|
||||
return CloseResult(
|
||||
ok=True,
|
||||
detail="recover_opening: 已提升为 open",
|
||||
@@ -1053,7 +1079,8 @@ class BinanceLiveExecutor(Matcher):
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
@@ -1270,7 +1297,8 @@ class BinanceLiveExecutor(Matcher):
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
@@ -380,6 +380,15 @@ class OkxLiveExecutor(Matcher):
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
try:
|
||||
from ..strategy.exits import lock_trade_exit_target
|
||||
|
||||
lock_trade_exit_target(
|
||||
self.db, group_id=group_id, initial_premium=initial_premium
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("lock exit target failed group=%s", group_id)
|
||||
|
||||
return OpenResult(
|
||||
ok=True,
|
||||
group_id=group_id,
|
||||
@@ -498,6 +507,14 @@ class OkxLiveExecutor(Matcher):
|
||||
),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
try:
|
||||
from ..strategy.exits import lock_trade_exit_target
|
||||
|
||||
lock_trade_exit_target(
|
||||
self.db, group_id=group_id, initial_premium=initial_premium
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("lock exit target failed half_open group=%s", group_id)
|
||||
|
||||
def repair_half_open(self) -> CloseResult:
|
||||
"""卖出 half_open 残留期权,清本地状态。"""
|
||||
@@ -576,7 +593,8 @@ class OkxLiveExecutor(Matcher):
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
@@ -696,6 +714,14 @@ class OkxLiveExecutor(Matcher):
|
||||
),
|
||||
)
|
||||
self.db._conn.commit()
|
||||
try:
|
||||
from ..strategy.exits import lock_trade_exit_target
|
||||
|
||||
lock_trade_exit_target(
|
||||
self.db, group_id=group_id, initial_premium=initial_premium
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("lock exit target failed recover group=%s", group_id)
|
||||
return CloseResult(
|
||||
ok=True,
|
||||
detail="recover_opening: 已提升为 open",
|
||||
@@ -1091,7 +1117,8 @@ class OkxLiveExecutor(Matcher):
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
@@ -1293,7 +1320,8 @@ class OkxLiveExecutor(Matcher):
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS groups (
|
||||
expiry_ymd TEXT,
|
||||
entry_index_px REAL,
|
||||
initial_premium REAL DEFAULT 0,
|
||||
exit_target_usdt REAL,
|
||||
open_at_ms INTEGER,
|
||||
close_at_ms INTEGER,
|
||||
close_reason TEXT,
|
||||
@@ -79,6 +80,7 @@ CREATE TABLE IF NOT EXISTS positions (
|
||||
option_entry_px REAL,
|
||||
entry_index_px REAL,
|
||||
initial_premium REAL DEFAULT 0,
|
||||
exit_target_usdt REAL,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
@@ -166,6 +168,8 @@ class Database:
|
||||
("groups", "funding_usdt", "REAL"),
|
||||
("groups", "settle_index_px", "REAL"),
|
||||
("groups", "perp_margin_mode", "TEXT"),
|
||||
("groups", "exit_target_usdt", "REAL"),
|
||||
("positions", "exit_target_usdt", "REAL"),
|
||||
("fills", "exec_mode", "TEXT"),
|
||||
("fills", "fee_ccy", "TEXT"),
|
||||
):
|
||||
|
||||
@@ -133,28 +133,38 @@ class SimFundsWallets:
|
||||
direction: str,
|
||||
amount: float,
|
||||
rate: float = 1.0,
|
||||
account: str = "funding",
|
||||
) -> dict[str, Any]:
|
||||
"""资金账户内 USDT↔USDC 兑换。"""
|
||||
"""USDT↔USDC 兑换。默认资金账户;account=trading 时在交易账户内兑(对齐 OKX 现货)。"""
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "detail": "数量须大于 0"}
|
||||
r = float(rate) if rate and rate > 0 else 1.0
|
||||
d = (direction or "").strip().lower()
|
||||
acct = (account or "funding").strip().lower()
|
||||
if acct not in ("funding", "trading"):
|
||||
return {"ok": False, "detail": "account 须为 funding / trading"}
|
||||
snap = self.snapshot()
|
||||
if acct == "funding":
|
||||
usdt_key, usdc_key = "funding_usdt", "options_funding_usdc"
|
||||
label = "资金账户"
|
||||
else:
|
||||
usdt_key, usdc_key = "trading_usdt", "options_trading_usdc"
|
||||
label = "交易账户"
|
||||
if d == "usdt_to_usdc":
|
||||
src = float(snap["funding_usdt"])
|
||||
src = float(snap[usdt_key])
|
||||
if amt > src + 1e-9:
|
||||
return {"ok": False, "detail": f"资金账户 USDT 不足(可用 {src:.4f})"}
|
||||
return {"ok": False, "detail": f"{label} USDT 不足(可用 {src:.4f})"}
|
||||
usdc = amt / r
|
||||
snap["funding_usdt"] = src - amt
|
||||
snap["options_funding_usdc"] = float(snap["options_funding_usdc"]) + usdc
|
||||
snap[usdt_key] = src - amt
|
||||
snap[usdc_key] = float(snap[usdc_key]) + usdc
|
||||
elif d == "usdc_to_usdt":
|
||||
src = float(snap["options_funding_usdc"])
|
||||
src = float(snap[usdc_key])
|
||||
if amt > src + 1e-9:
|
||||
return {"ok": False, "detail": f"资金账户 USDC 不足(可用 {src:.4f})"}
|
||||
return {"ok": False, "detail": f"{label} USDC 不足(可用 {src:.4f})"}
|
||||
usdt = amt * r
|
||||
snap["options_funding_usdc"] = src - amt
|
||||
snap["funding_usdt"] = float(snap["funding_usdt"]) + usdt
|
||||
snap[usdc_key] = src - amt
|
||||
snap[usdt_key] = float(snap[usdt_key]) + usdt
|
||||
else:
|
||||
return {"ok": False, "detail": "direction 须为 usdt_to_usdc 或 usdc_to_usdt"}
|
||||
self._set(**snap)
|
||||
@@ -165,6 +175,7 @@ class SimFundsWallets:
|
||||
"direction": d,
|
||||
"amount": amt,
|
||||
"rate": r,
|
||||
"account": acct,
|
||||
"wallets": self.view(),
|
||||
"total_usdt_equiv": total,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -21,6 +22,8 @@ from .pricing import (
|
||||
resolve_option_close_bid,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 禁止新开仓的本地仓位状态(实盘防卡)
|
||||
BLOCKING_STATUSES = frozenset(
|
||||
{"open", "half_open", "option_closed_perp_pending", "opening"}
|
||||
@@ -332,6 +335,15 @@ class Matcher:
|
||||
)
|
||||
self.db._conn.commit()
|
||||
|
||||
try:
|
||||
from ..strategy.exits import lock_trade_exit_target
|
||||
|
||||
lock_trade_exit_target(
|
||||
self.db, group_id=group_id, initial_premium=initial_premium
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("lock exit target failed group=%s", group_id)
|
||||
|
||||
return OpenResult(
|
||||
ok=True,
|
||||
group_id=group_id,
|
||||
@@ -585,7 +597,8 @@ class Matcher:
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
@@ -756,7 +769,8 @@ class Matcher:
|
||||
"""UPDATE positions SET
|
||||
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
|
||||
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
|
||||
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0,
|
||||
exit_target_usdt=NULL, status='flat'
|
||||
WHERE id=1"""
|
||||
)
|
||||
self.db._conn.commit()
|
||||
@@ -1098,6 +1112,11 @@ class Matcher:
|
||||
"move_points": move,
|
||||
"move_pct": move_pct,
|
||||
"initial_premium": initial_premium,
|
||||
"exit_target_usdt": (
|
||||
float(pos["exit_target_usdt"])
|
||||
if pos.get("exit_target_usdt") is not None
|
||||
else None
|
||||
),
|
||||
"premium_gap": premium_gap,
|
||||
"status": pos.get("status"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""OKX 交易账户自动兑 USDC 测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.strategy.auto_usdc import ensure_okx_trading_usdc
|
||||
|
||||
|
||||
def test_skip_when_usdc_enough() -> None:
|
||||
cap = {
|
||||
"option_need_usdc": 100.0,
|
||||
"option_have_usdc": 100.0,
|
||||
"perp_need_usdt": 50.0,
|
||||
"perp_have_usdt": 500.0,
|
||||
"option_can_open": True,
|
||||
}
|
||||
with (
|
||||
patch("app.strategy.auto_usdc._is_okx", return_value=True),
|
||||
patch("app.strategy.auto_usdc.assess_open_capacity", return_value=cap),
|
||||
):
|
||||
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap)
|
||||
assert r["skipped"] is True
|
||||
assert r["acted"] is False
|
||||
assert "已够开仓" in r["detail"]
|
||||
|
||||
|
||||
def test_skip_non_okx() -> None:
|
||||
with patch("app.strategy.auto_usdc._is_okx", return_value=False):
|
||||
r = ensure_okx_trading_usdc(db=MagicMock())
|
||||
assert r["skipped"] is True
|
||||
assert "非 OKX" in r["detail"]
|
||||
|
||||
|
||||
def test_sim_convert_to_2x_need() -> None:
|
||||
"""USDC 不足 → 交易账户兑到 需×2(预留永续保证金)。"""
|
||||
cap = {
|
||||
"option_need_usdc": 100.0,
|
||||
"option_have_usdc": 20.0,
|
||||
"perp_need_usdt": 50.0,
|
||||
"perp_have_usdt": 500.0,
|
||||
"option_can_open": False,
|
||||
}
|
||||
cap_after = {
|
||||
**cap,
|
||||
"option_have_usdc": 200.0,
|
||||
"option_can_open": True,
|
||||
"perp_have_usdt": 320.0,
|
||||
}
|
||||
wallets = MagicMock()
|
||||
wallets.convert.return_value = {"ok": True, "detail": "converted"}
|
||||
|
||||
with (
|
||||
patch("app.strategy.auto_usdc._is_okx", return_value=True),
|
||||
patch("app.strategy.auto_usdc.assess_open_capacity", side_effect=[cap, cap_after]),
|
||||
patch("app.strategy.auto_usdc.get_settings") as gs,
|
||||
patch("app.live.okx_funds.usdc_usdt_mid_rate", return_value=1.0),
|
||||
patch("app.sim.funds_wallets.SimFundsWallets", return_value=wallets),
|
||||
patch("app.strategy.auto_usdc.invalidate_live_balance_cache"),
|
||||
):
|
||||
gs.return_value.is_sim = True
|
||||
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap)
|
||||
|
||||
assert r["acted"] is True
|
||||
assert r["ok"] is True
|
||||
# target=200, have=20 → gap=180 USDT
|
||||
wallets.convert.assert_called_once()
|
||||
kwargs = wallets.convert.call_args.kwargs
|
||||
assert kwargs["direction"] == "usdt_to_usdc"
|
||||
assert kwargs["account"] == "trading"
|
||||
assert kwargs["amount"] == 180.0
|
||||
|
||||
|
||||
def test_reserve_perp_margin() -> None:
|
||||
"""可兑 USDT = 交易 USDT − 永续所需。"""
|
||||
cap = {
|
||||
"option_need_usdc": 100.0,
|
||||
"option_have_usdc": 0.0,
|
||||
"perp_need_usdt": 400.0,
|
||||
"perp_have_usdt": 450.0, # 可兑仅 50
|
||||
"option_can_open": False,
|
||||
}
|
||||
wallets = MagicMock()
|
||||
wallets.convert.return_value = {"ok": True, "detail": "converted"}
|
||||
cap_after = {**cap, "option_have_usdc": 50.0}
|
||||
|
||||
with (
|
||||
patch("app.strategy.auto_usdc._is_okx", return_value=True),
|
||||
patch("app.strategy.auto_usdc.assess_open_capacity", side_effect=[cap, cap_after]),
|
||||
patch("app.strategy.auto_usdc.get_settings") as gs,
|
||||
patch("app.live.okx_funds.usdc_usdt_mid_rate", return_value=1.0),
|
||||
patch("app.sim.funds_wallets.SimFundsWallets", return_value=wallets),
|
||||
patch("app.strategy.auto_usdc.invalidate_live_balance_cache"),
|
||||
):
|
||||
gs.return_value.is_sim = True
|
||||
r = ensure_okx_trading_usdc(db=MagicMock(), cap=cap)
|
||||
|
||||
assert r["acted"] is True
|
||||
assert wallets.convert.call_args.kwargs["amount"] == 50.0
|
||||
@@ -0,0 +1,42 @@
|
||||
"""持仓锁定出场目标。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.strategy.exits import check_exits, resolve_exit_target
|
||||
|
||||
|
||||
def test_locked_target_ignores_setting_drift() -> None:
|
||||
d = check_exits(
|
||||
net_pnl=20.0,
|
||||
exit_mode="fixed_usdt",
|
||||
net_profit_target=50.0, # 设置已被改大
|
||||
premium_exit_multiple=1.0,
|
||||
initial_premium=10.0,
|
||||
locked_exit_target=15.0, # 开仓锁定
|
||||
)
|
||||
assert d.should_close is True
|
||||
assert d.target == 15.0
|
||||
|
||||
|
||||
def test_locked_target_not_yet() -> None:
|
||||
d = check_exits(
|
||||
net_pnl=10.0,
|
||||
exit_mode="fixed_usdt",
|
||||
net_profit_target=5.0,
|
||||
premium_exit_multiple=1.0,
|
||||
initial_premium=10.0,
|
||||
locked_exit_target=15.0,
|
||||
)
|
||||
assert d.should_close is False
|
||||
assert d.target == 15.0
|
||||
|
||||
|
||||
def test_resolve_premium_still_works_without_lock() -> None:
|
||||
t, mode = resolve_exit_target(
|
||||
exit_mode="premium_multiple",
|
||||
net_profit_target=15.0,
|
||||
premium_exit_multiple=2.0,
|
||||
initial_premium=10.0,
|
||||
)
|
||||
assert t == 20.0
|
||||
assert mode == "premium_multiple"
|
||||
@@ -5,6 +5,31 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-30 — 持仓锁定出场目标 / 暂停以损定仓重算
|
||||
|
||||
### 变更
|
||||
|
||||
1. 开仓成交后将出场目标写入 `groups`/`positions.exit_target_usdt`,盯盘优先用锁定值。
|
||||
2. 持仓期间计划页不再用盘口实时以损定仓覆盖名义 / k / 目标;平仓后再自动计算。
|
||||
3. `apply_risk_sizing_to_ledger` 在有活跃仓时拒绝改写。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-30 — OKX 交易账户自动兑 USDC(开仓前)
|
||||
|
||||
### 变更
|
||||
|
||||
1. 仅 **OKX**(SIM + LIVE):开仓前若交易账户 USDC **不够**当前期权开仓所需,则在交易账户内市价 `USDT→USDC`。
|
||||
2. 目标持仓 = 期权所需 × **2**;已够开仓则不兑换;预留永续保证金,避免兑光 USDT。
|
||||
3. 不走资金账户、不做 funding↔trading 划转;币安不生效。
|
||||
|
||||
### 审计
|
||||
|
||||
- 钩子:以损定仓写名义之后、资金门之前(自动 + 手动开一组)。
|
||||
- SIM `convert(account=trading)`;LIVE 沿用现货 `USDC-USDT` cash 市价单。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-29 — 以损定仓:预览两位小数 / 比例与出场可配 / 计划页标识
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -147,7 +147,12 @@ export default function PlanPage() {
|
||||
const exitMode = plan?.exit_mode ?? "fixed_usdt";
|
||||
const riskBased =
|
||||
plan?.risk_based === true || plan?.sizing_mode === "risk_based";
|
||||
const riskLiveOk = !riskBased || plan?.risk_sizing_preview?.ok === true;
|
||||
const riskLocked =
|
||||
open &&
|
||||
(plan?.risk_sizing_locked === true ||
|
||||
plan?.risk_sizing_preview?.locked === true);
|
||||
const riskLiveOk =
|
||||
!riskBased || riskLocked || plan?.risk_sizing_preview?.ok === true;
|
||||
const displayPerpQty = riskLiveOk ? (plan?.perp_qty_eth ?? 1) : null;
|
||||
const displayOptQty = riskLiveOk ? (plan?.option_qty_eth ?? 2) : null;
|
||||
const exitTarget = riskLiveOk
|
||||
@@ -160,7 +165,7 @@ export default function PlanPage() {
|
||||
? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`
|
||||
: riskBased
|
||||
? exitTarget != null
|
||||
? `固定 ${fmt(exitTarget)} U(基数${fmt(plan?.risk_exit_unit ?? 15)})`
|
||||
? `${riskLocked ? "锁定 " : "固定 "}${fmt(exitTarget)} U(基数${fmt(plan?.risk_exit_unit ?? 15)})`
|
||||
: `待估算(基数${fmt(plan?.risk_exit_unit ?? 15)})`
|
||||
: `固定 ${fmt(plan?.net_profit_target ?? 15)} U`;
|
||||
const riskRatioLabel = `比例${Number(plan?.risk_perp_unit ?? 1)}:${Number(plan?.risk_option_unit ?? 2)}`;
|
||||
@@ -169,10 +174,14 @@ export default function PlanPage() {
|
||||
"以损定仓",
|
||||
riskRatioLabel,
|
||||
plan?.risk_last_k != null ? `k=${fmt(plan.risk_last_k, 1)}` : null,
|
||||
plan?.risk_sizing_preview?.budget != null
|
||||
? `预算${fmt(plan.risk_sizing_preview.budget, 2)}U`
|
||||
: null,
|
||||
plan?.risk_sizing_preview?.ok === false
|
||||
riskLocked
|
||||
? exitTarget != null
|
||||
? `目标锁定${fmt(exitTarget, 2)}U`
|
||||
: "目标已锁定"
|
||||
: plan?.risk_sizing_preview?.budget != null
|
||||
? `预算${fmt(plan.risk_sizing_preview.budget, 2)}U`
|
||||
: null,
|
||||
!riskLocked && plan?.risk_sizing_preview?.ok === false
|
||||
? String(plan.risk_sizing_preview.detail || "预览失败")
|
||||
: null,
|
||||
]
|
||||
|
||||
@@ -1474,14 +1474,18 @@ export default function SettingsPage() {
|
||||
onToggle={() => setFundsRulesOpen((v) => !v)}
|
||||
>
|
||||
<p>
|
||||
币种兑换:对齐 OKX 现货市价 USDC-USDT,在<strong>资金账户</strong>
|
||||
内完成 USDT↔USDC。参考价{" "}
|
||||
币种兑换:对齐 OKX 现货市价 USDC-USDT,手动兑换在<strong>资金账户</strong>
|
||||
内完成。参考价{" "}
|
||||
{usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}。
|
||||
</p>
|
||||
<p>
|
||||
账户划转:仅<strong>资金账户 ↔ 交易账户</strong>
|
||||
(USDT/USDC)。OKX 永续与期权均在交易账户,无单独期权资金/期权交易账户。
|
||||
</p>
|
||||
<p>
|
||||
自动兑 USDC(仅 OKX SIM/LIVE):开仓前若<strong>交易账户</strong> USDC
|
||||
不够当前期权所需,则在交易账户内市价 USDT→USDC,目标约「所需×2」;已够开仓则不兑;并预留永续保证金。币安不生效。
|
||||
</p>
|
||||
</RulesFold>
|
||||
|
||||
<section className="settings-section">
|
||||
|
||||
Reference in New Issue
Block a user