Add dual exit modes: fixed USDT or premium multiple.

Net PnL (after estimated close fees) drives auto close; Plan/Settings expose the choice.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 09:17:50 +08:00
parent 9fd2a842af
commit cce26e87b5
13 changed files with 282 additions and 72 deletions
+18
View File
@@ -15,6 +15,9 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
KEYS = (
"fee_rate",
"exit_move_pct",
"exit_mode",
"net_profit_target",
"premium_exit_multiple",
"rest_seconds",
"initial_equity",
"leverage",
@@ -29,6 +32,9 @@ KEYS = (
class StrategySettingsBody(BaseModel):
fee_rate: float | None = Field(default=None, ge=0, le=0.05)
exit_move_pct: float | None = Field(default=None, ge=0.1, le=50)
exit_mode: str | None = Field(default=None, pattern="^(fixed_usdt|premium_multiple)$")
net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000)
premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100)
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
initial_equity: float | None = Field(default=None, ge=1000)
leverage: float | None = Field(default=None, ge=1, le=125)
@@ -42,11 +48,23 @@ class StrategySettingsBody(BaseModel):
def _read_settings() -> dict:
db = get_db()
s = get_settings()
mode = str(db.get_setting("exit_mode", s.exit_mode) or s.exit_mode)
if mode not in ("fixed_usdt", "premium_multiple"):
mode = "fixed_usdt"
return {
"fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate),
"exit_move_pct": float(
db.get_setting("exit_move_pct", str(s.exit_move_pct)) or s.exit_move_pct
),
"exit_mode": mode,
"net_profit_target": float(
db.get_setting("net_profit_target", str(s.net_profit_target))
or s.net_profit_target
),
"premium_exit_multiple": float(
db.get_setting("premium_exit_multiple", str(s.premium_exit_multiple))
or s.premium_exit_multiple
),
"rest_seconds": int(
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
),
+5 -2
View File
@@ -42,8 +42,11 @@ class Settings(BaseSettings):
max_rounds: int = 3 # 已不再强管控,仅兼容旧字段
open_hhmm: str = "16:00" # 已废弃开仓窗
stop_open_hhmm: str = "08:00" # 已废弃开仓窗
exit_move_points: float = 30.0 # 旧字段,改用 exit_move_pct
exit_move_pct: float = 2.0 # 相对开仓指数波动 % 全平
exit_move_points: float = 30.0 # 旧字段,已废弃
exit_move_pct: float = 2.0 # 旧字段,已废弃(改用净盈利出场)
exit_mode: str = "fixed_usdt" # fixed_usdt | premium_multiple
net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT
premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数
rest_seconds: int = 300
leverage: float = 3.0 # 永续杠杆
min_option_hours: float = 12.0 # 期权最小剩余小时
+3
View File
@@ -150,6 +150,9 @@ class Database:
"initial_equity": str(s.initial_equity),
"exit_move_points": str(s.exit_move_points),
"exit_move_pct": str(s.exit_move_pct),
"exit_mode": str(s.exit_mode),
"net_profit_target": str(s.net_profit_target),
"premium_exit_multiple": str(s.premium_exit_multiple),
"rest_seconds": str(s.rest_seconds),
"max_rounds": str(s.max_rounds),
"leverage": str(s.leverage),
+6
View File
@@ -59,3 +59,9 @@ class Ledger:
def get_setting_int(self, key: str, default: int) -> int:
return int(self.get_setting_float(key, float(default)))
def get_setting_str(self, key: str, default: str) -> str:
v = self.db.get_setting(key)
if v is None or v == "":
return default
return str(v)
+56 -20
View File
@@ -412,6 +412,8 @@ class Matcher:
"has_position": False,
"perp_upl": 0.0,
"option_upl": 0.0,
"net_pnl": 0.0,
"est_close_fees": 0.0,
"index_px": None,
"move_points": 0.0,
"move_pct": 0.0,
@@ -420,41 +422,73 @@ class Matcher:
sess = get_session()
snap = sess.snapshot()
s = get_settings()
fee_rate = self._fee_rate()
index_px = snap.index_px
if index_px is None and snap.perp:
index_px = snap.perp.mark_px
perp_side = str(pos["perp_side"])
perp_entry = float(pos["perp_entry_px"])
perp_qty = float(pos["perp_qty_eth"])
mark = None
if snap.perp:
# 浮盈用对手方可平价粗估
if perp_side == "long":
mark = snap.perp.bid
else:
mark = snap.perp.ask
mark = mark or snap.perp.mark_px
opt_qty = float(pos["option_qty_eth"] or 0)
opt_entry = float(pos["option_entry_px"] or 0)
# 与平仓一致:用对手价估算可平盈亏 + 手续费
perp_upl = 0.0
if mark is not None:
est_perp_close_fee = 0.0
mark = None
if snap.perp and snap.perp.bid is not None and snap.perp.ask is not None:
pf = perp_fill(
side=perp_side,
action="close",
bid=float(snap.perp.bid),
ask=float(snap.perp.ask),
qty_eth=perp_qty,
fee_rate=fee_rate,
)
if perp_side == "long":
perp_upl = (float(mark) - perp_entry) * perp_qty
perp_upl = (pf.fill_px - perp_entry) * perp_qty
else:
perp_upl = (perp_entry - float(mark)) * perp_qty
perp_upl = (perp_entry - pf.fill_px) * perp_qty
est_perp_close_fee = pf.fee
mark = pf.fill_px
elif snap.perp:
if perp_side == "long":
mark = snap.perp.bid or snap.perp.mark_px
else:
mark = snap.perp.ask or snap.perp.mark_px
if mark is not None:
if perp_side == "long":
perp_upl = (float(mark) - perp_entry) * perp_qty
else:
perp_upl = (perp_entry - float(mark)) * perp_qty
option_side = str(pos["option_side"])
# 优先用持仓合约盘口,避免 ATM 切换后盯错合约
opt_inst = str(pos.get("option_inst_id") or "")
oq = get_exchange().quote(opt_inst) if opt_inst else None
if oq is None:
oq = snap.call if option_side == "call" else snap.put
opt_mark = None
if oq:
opt_mark = oq.bid or oq.mark_px
option_upl = 0.0
if opt_mark is not None:
option_upl = (float(opt_mark) - float(pos["option_entry_px"])) * float(
pos["option_qty_eth"]
est_opt_close_fee = 0.0
opt_mark = None
if oq and oq.bid is not None:
of = option_fill(
action="close",
bid=float(oq.bid),
ask=float(oq.ask or oq.bid),
qty_eth=opt_qty,
fee_rate=fee_rate,
)
option_upl = (of.fill_px - opt_entry) * opt_qty
est_opt_close_fee = of.fee
opt_mark = of.fill_px
elif oq:
opt_mark = oq.bid or oq.mark_px
if opt_mark is not None:
option_upl = (float(opt_mark) - opt_entry) * opt_qty
est_close_fees = est_perp_close_fee + est_opt_close_fee
# 净盈利口径与平仓结算一致:双腿盈亏 − 预估平仓手续费
net_pnl = perp_upl + option_upl - est_close_fees
entry_idx = float(pos["entry_index_px"] or 0)
move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0
@@ -492,14 +526,16 @@ class Matcher:
"perp_margin": margin,
"leverage": leverage,
"option_inst_id": pos.get("option_inst_id"),
"option_entry_px": float(pos["option_entry_px"] or 0),
"option_qty_eth": float(pos["option_qty_eth"] or 0),
"option_entry_px": opt_entry,
"option_qty_eth": opt_qty,
"option_qty_contracts": float(pos["option_qty_contracts"] or 0),
"option_mark_px": float(opt_mark) if opt_mark is not None else None,
"strike": strike,
"expiry_ymd": expiry_ymd,
"perp_upl": perp_upl,
"option_upl": option_upl,
"est_close_fees": est_close_fees,
"net_pnl": net_pnl,
"index_px": index_px,
"entry_index_px": entry_idx,
"move_points": move,
+30 -8
View File
@@ -13,7 +13,7 @@ from ..models.db import get_db
from ..sim.ledger import Ledger
from ..sim.matcher import Matcher
from .clock import window_key
from .exits import check_exits
from .exits import check_exits, resolve_exit_target
from .group import next_group_id
logger = logging.getLogger(__name__)
@@ -32,7 +32,19 @@ class StrategyEngine:
assert row is not None
upl = self.matcher.unrealized()
s = get_settings()
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
net_target = self.ledger.get_setting_float(
"net_profit_target", s.net_profit_target
)
prem_mult = self.ledger.get_setting_float(
"premium_exit_multiple", s.premium_exit_multiple
)
exit_amt, _ = resolve_exit_target(
exit_mode=exit_mode,
net_profit_target=net_target,
premium_exit_multiple=prem_mult,
initial_premium=float(upl.get("initial_premium") or 0),
)
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
leverage = self.ledger.get_setting_float("leverage", s.leverage)
min_hours = self.ledger.get_setting_float("min_option_hours", s.min_option_hours)
@@ -55,7 +67,10 @@ class StrategyEngine:
"rest_until_ms": rest_until,
"rest_left_sec": rest_left,
"rest_seconds": rest_sec,
"exit_move_pct": exit_pct,
"exit_mode": exit_mode,
"net_profit_target": net_target,
"premium_exit_multiple": prem_mult,
"exit_target_usdt": exit_amt,
"leverage": leverage,
"min_option_hours": min_hours,
"min_option_leverage": min_opt_lev,
@@ -153,17 +168,24 @@ class StrategyEngine:
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert st is not None
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
net_target = self.ledger.get_setting_float(
"net_profit_target", s.net_profit_target
)
prem_mult = self.ledger.get_setting_float(
"premium_exit_multiple", s.premium_exit_multiple
)
pos = self.matcher.current_position()
# 有未平仓:只盯平仓,绝不开下一组
if pos.get("status") == "open":
upl = self.matcher.unrealized()
decision = check_exits(
perp_upl=float(upl["perp_upl"]),
initial_premium=float(upl["initial_premium"] or 0),
move_pct=float(upl.get("move_pct") or 0),
exit_move_pct=exit_pct,
net_pnl=float(upl.get("net_pnl") or 0),
exit_mode=exit_mode,
net_profit_target=net_target,
premium_exit_multiple=prem_mult,
initial_premium=float(upl.get("initial_premium") or 0),
)
pending_close = st["phase"] in ("liquidity_wait", "closing")
if decision.should_close or pending_close:
+34 -8
View File
@@ -2,22 +2,48 @@ from __future__ import annotations
from dataclasses import dataclass
EXIT_MODE_FIXED = "fixed_usdt"
EXIT_MODE_PREMIUM = "premium_multiple"
@dataclass(slots=True)
class ExitDecision:
should_close: bool
reason: str = ""
target: float = 0.0
def resolve_exit_target(
*,
exit_mode: str,
net_profit_target: float,
premium_exit_multiple: float,
initial_premium: float,
) -> tuple[float, str]:
"""返回 (出场目标金额 USDT, 模式标记)。"""
mode = (exit_mode or EXIT_MODE_FIXED).strip().lower()
if mode == EXIT_MODE_PREMIUM:
mult = max(0.0, float(premium_exit_multiple))
return float(initial_premium) * mult, EXIT_MODE_PREMIUM
return float(net_profit_target), EXIT_MODE_FIXED
def check_exits(
*,
perp_upl: float,
net_pnl: float,
exit_mode: str,
net_profit_target: float,
premium_exit_multiple: float,
initial_premium: float,
move_pct: float,
exit_move_pct: float,
) -> ExitDecision:
if initial_premium > 0 and perp_upl + 1e-9 >= initial_premium:
return ExitDecision(True, "premium_cover")
if exit_move_pct > 0 and move_pct + 1e-9 >= exit_move_pct:
return ExitDecision(True, "move_pct")
return ExitDecision(False, "")
"""净盈利(预估全平后)≥ 所选模式目标则全平。"""
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)
return ExitDecision(False, "", target)
+37 -9
View File
@@ -28,19 +28,47 @@ def test_signal_equal() -> None:
assert decide(10.0, 10.0) is None
def test_exit_premium_and_move_pct() -> None:
def test_exit_fixed_and_premium_multiple() -> None:
fixed = check_exits(
net_pnl=15.0,
exit_mode="fixed_usdt",
net_profit_target=15,
premium_exit_multiple=1,
initial_premium=40,
)
assert fixed.reason == "fixed_usdt"
assert fixed.target == 15
assert (
check_exits(
perp_upl=50, initial_premium=40, move_pct=0.1, exit_move_pct=2
).reason
== "premium_cover"
net_pnl=14.9,
exit_mode="fixed_usdt",
net_profit_target=15,
premium_exit_multiple=1,
initial_premium=40,
).should_close
is False
)
assert (
check_exits(
perp_upl=1, initial_premium=40, move_pct=2.0, exit_move_pct=2
).reason
== "move_pct"
prem = check_exits(
net_pnl=40.0,
exit_mode="premium_multiple",
net_profit_target=15,
premium_exit_multiple=1,
initial_premium=40,
)
assert prem.reason == "premium_multiple"
assert prem.target == 40
half = check_exits(
net_pnl=20.0,
exit_mode="premium_multiple",
net_profit_target=15,
premium_exit_multiple=0.5,
initial_premium=40,
)
assert half.should_close is True
assert half.target == 20
def test_perp_pricing() -> None: