diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index e4137b6..de1bf32 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -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 diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index f4821af..270aec8 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -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() diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 355502e..4994f82 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -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() diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 621e650..2892439 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -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"), ): diff --git a/backend/app/sim/funds_wallets.py b/backend/app/sim/funds_wallets.py index 108d1c0..e9db021 100644 --- a/backend/app/sim/funds_wallets.py +++ b/backend/app/sim/funds_wallets.py @@ -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, } diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index e9b6281..1f8be67 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -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"), } diff --git a/backend/app/strategy/auto_usdc.py b/backend/app/strategy/auto_usdc.py new file mode 100644 index 0000000..608ce44 --- /dev/null +++ b/backend/app/strategy/auto_usdc.py @@ -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 diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 780feb8..be31e4b 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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: diff --git a/backend/app/strategy/exits.py b/backend/app/strategy/exits.py index d496efc..be5e2e8 100644 --- a/backend/app/strategy/exits.py +++ b/backend/app/strategy/exits.py @@ -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) diff --git a/backend/app/strategy/open_capacity.py b/backend/app/strategy/open_capacity.py index 1c8d7d8..dc15fdb 100644 --- a/backend/app/strategy/open_capacity.py +++ b/backend/app/strategy/open_capacity.py @@ -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;仍不足请检查交易账户余额。", ], ) ) diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index 9da10d1..8040708 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -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 diff --git a/backend/tests/test_auto_usdc.py b/backend/tests/test_auto_usdc.py new file mode 100644 index 0000000..f5835c7 --- /dev/null +++ b/backend/tests/test_auto_usdc.py @@ -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 diff --git a/backend/tests/test_exit_lock.py b/backend/tests/test_exit_lock.py new file mode 100644 index 0000000..361d55e --- /dev/null +++ b/backend/tests/test_exit_lock.py @@ -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" diff --git a/docs/更新说明.md b/docs/更新说明.md index 6758460..4272994 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -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 — 以损定仓:预览两位小数 / 比例与出场可配 / 计划页标识 ### 变更 diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index ae1a308..74b9526 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -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, ] diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2bf3f08..2c262ad 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1474,14 +1474,18 @@ export default function SettingsPage() { onToggle={() => setFundsRulesOpen((v) => !v)} >

- 币种兑换:对齐 OKX 现货市价 USDC-USDT,在资金账户 - 内完成 USDT↔USDC。参考价{" "} + 币种兑换:对齐 OKX 现货市价 USDC-USDT,手动兑换在资金账户 + 内完成。参考价{" "} {usdcRate != null ? `1 USDC ≈ ${usdcRate.toFixed(4)} USDT` : "—"}。

账户划转:仅资金账户 ↔ 交易账户 (USDT/USDC)。OKX 永续与期权均在交易账户,无单独期权资金/期权交易账户。

+

+ 自动兑 USDC(仅 OKX SIM/LIVE):开仓前若交易账户 USDC + 不够当前期权所需,则在交易账户内市价 USDT→USDC,目标约「所需×2」;已够开仓则不兑;并预留永续保证金。币安不生效。 +