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
+7
View File
@@ -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
+31 -3
View File
@@ -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()
+31 -3
View File
@@ -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()
+4
View File
@@ -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"),
):
+20 -9
View File
@@ -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,
}
+21 -2
View File
@@ -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"),
}
+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
+99
View File
@@ -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
+42
View File
@@ -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"
+25
View File
@@ -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 — 以损定仓:预览两位小数 / 比例与出场可配 / 计划页标识
### 变更
+15 -6
View File
@@ -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,
]
+6 -2
View File
@@ -1474,14 +1474,18 @@ export default function SettingsPage() {
onToggle={() => setFundsRulesOpen((v) => !v)}
>
<p>
OKX USDC-USDT<strong></strong>
USDTUSDC{" "}
OKX USDC-USDT<strong></strong>
{" "}
{usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}
</p>
<p>
<strong> </strong>
USDT/USDCOKX /
</p>
<p>
USDC OKX SIM/LIVE<strong></strong> USDC
USDTUSDC×2
</p>
</RulesFold>
<section className="settings-section">