Fix target-exit mismatch: correct close reason and executable PnL gate.

Locked premium exits no longer label as fixed_usdt; mark/book optimism no longer triggers close into a realized loss.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-07 22:05:34 +08:00
parent a88d708ea3
commit bf3441537e
6 changed files with 74 additions and 31 deletions
+15 -7
View File
@@ -73,20 +73,28 @@ def enrich_live_unrealized(
option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地) option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地)
option2_upl = float(base.get("option2_upl") or 0.0) # 期期 Put 腿 option2_upl = float(base.get("option2_upl") or 0.0) # 期期 Put 腿
# 盯盘/达标:离场费 ≈ 入场费 → 合计扣 已付×2 # 盯盘/达标:离场费已在 base.net_pnl(盘口可成交价)计入;这里只叠加资金费。
est_close = float(fees_paid) # 勿用交易所永续标记 UPL 覆盖净利,否则会虚高触发达标、成交后变亏。
# 期期无永续:perp_upl 一般为 0;仍加 option2 book_net = base.get("net_pnl")
net_pnl = perp_upl + option_upl + option2_upl - fees_paid * 2.0 + funding if book_net is not None:
net_pnl = float(book_net) + funding
else:
fees_est = float(fees_paid) * 2.0
net_pnl = perp_upl + option_upl + option2_upl - fees_est + funding
out = dict(base) out = dict(base)
out["perp_upl"] = perp_upl out["perp_upl"] = float(base.get("perp_upl") or perp_upl)
out["perp_upl_exchange"] = perp_upl
out["option_upl"] = option_upl out["option_upl"] = option_upl
out["option2_upl"] = option2_upl out["option2_upl"] = option2_upl
out["fees_paid"] = fees_paid out["fees_paid"] = fees_paid
out["funding_usdt"] = funding out["funding_usdt"] = funding
out["est_close_fees"] = est_close out["est_close_fees"] = float(base.get("est_close_fees") or fees_paid)
out["net_pnl"] = net_pnl out["net_pnl"] = net_pnl
out["pnl_source"] = "live_exchange" out["net_pnl_exchange"] = (
perp_upl + option_upl + option2_upl - float(fees_paid) * 2.0 + funding
)
out["pnl_source"] = "live_book_plus_funding"
return out return out
+16 -8
View File
@@ -2233,12 +2233,18 @@ class Matcher:
est_opt_close_fee = of.fee est_opt_close_fee = of.fee
opt_mark = bid opt_mark = bid
opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None
# 浮盈亏:买一×数量 − 初始权利金(对齐可市价卖出 # 与 close_group 一致:用含费率调整的可卖出价(勿用裸买一虚高触发达标
option_upl = bid * opt_qty - initial_premium if opt_entry > 0:
option_upl = (float(of.fill_px) - float(opt_entry)) * opt_qty
else:
option_upl = float(of.fill_px) * opt_qty - initial_premium
elif oq: elif oq:
opt_mark = oq.bid or oq.mark_px opt_mark = oq.bid or oq.mark_px
opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None
if opt_mark is not None: if opt_mark is not None:
if opt_entry > 0:
option_upl = (float(opt_mark) - float(opt_entry)) * opt_qty
else:
option_upl = float(opt_mark) * opt_qty - initial_premium option_upl = float(opt_mark) * opt_qty - initial_premium
book_close_fees = est_perp_close_fee + est_opt_close_fee book_close_fees = est_perp_close_fee + est_opt_close_fee
@@ -2266,13 +2272,15 @@ class Matcher:
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
open_at_ms = int(g["open_at_ms"]) if g and g["open_at_ms"] else None open_at_ms = int(g["open_at_ms"]) if g and g["open_at_ms"] else None
open_fees = abs(float(g["fees"] or 0)) if g else 0.0 open_fees = abs(float(g["fees"] or 0)) if g else 0.0
# 净盈利:浮盈 入场手续费×2(离场费按入场估算);无入场费时退回盘口估平仓费 # 净盈利:浮盈 已付开仓费 − 预估平仓费(与 close_group 入账口径对齐)
if open_fees > 1e-12: if book_close_fees > 1e-12 or open_fees > 1e-12:
est_close_fees = open_fees est_close_fees = (
net_pnl = perp_upl + option_upl - open_fees * 2.0 book_close_fees if book_close_fees > 1e-12 else open_fees
)
net_pnl = perp_upl + option_upl - open_fees - est_close_fees
else: else:
est_close_fees = book_close_fees est_close_fees = 0.0
net_pnl = perp_upl + option_upl - est_close_fees net_pnl = perp_upl + option_upl
expiry_ms = None expiry_ms = None
if expiry_ymd and len(expiry_ymd) == 6: if expiry_ymd and len(expiry_ymd) == 6:
try: try:
+11
View File
@@ -882,6 +882,17 @@ class StrategyEngine:
locked_exit_target=locked_exit, locked_exit_target=locked_exit,
) )
pending_close = st["phase"] in ("liquidity_wait", "closing") pending_close = st["phase"] in ("liquidity_wait", "closing")
# 曾因达标进入流动性等待,但当前估价已跌破目标:取消挂起,避免硬平出亏损却仍记「达标」
if (
pending_close
and not expired.should_close
and not decision.should_close
):
self._set_state(
phase="open",
last_error="达标后流动性等待期间浮盈回落,已取消平仓挂起",
)
return
if expired.should_close or decision.should_close or pending_close: if expired.should_close or decision.should_close or pending_close:
is_oo = ( is_oo = (
str(upl.get("hedge_mode") or "") == "option_option" str(upl.get("hedge_mode") or "") == "option_option"
+6 -5
View File
@@ -110,16 +110,17 @@ def check_exits(
locked_exit_target: float | None = None, locked_exit_target: float | None = None,
) -> ExitDecision: ) -> ExitDecision:
"""净盈利(预估全平后)≥ 所选模式目标则全平。持仓锁定目标优先。""" """净盈利(预估全平后)≥ 所选模式目标则全平。持仓锁定目标优先。"""
if locked_exit_target is not None and float(locked_exit_target) > 0: # 模式始终按设置解析(权利金倍数 vs 固定),勿因锁定目标就改成 fixed_usdt
target = float(locked_exit_target) _resolved_target, mode = resolve_exit_target(
mode = EXIT_MODE_FIXED
else:
target, mode = resolve_exit_target(
exit_mode=exit_mode, exit_mode=exit_mode,
net_profit_target=net_profit_target, net_profit_target=net_profit_target,
premium_exit_multiple=premium_exit_multiple, premium_exit_multiple=premium_exit_multiple,
initial_premium=initial_premium, initial_premium=initial_premium,
) )
if locked_exit_target is not None and float(locked_exit_target) > 0:
target = float(locked_exit_target)
else:
target = float(_resolved_target)
if target > 0 and net_pnl + 1e-9 >= target: if target > 0 and net_pnl + 1e-9 >= target:
reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt" reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt"
return ExitDecision(True, reason, target) return ExitDecision(True, reason, target)
+15
View File
@@ -5,6 +5,21 @@ from __future__ import annotations
from app.strategy.exits import check_exits, resolve_exit_target from app.strategy.exits import check_exits, resolve_exit_target
def test_locked_premium_mode_keeps_premium_reason() -> None:
"""锁定目标金额后,平仓原因仍应按权利金倍数模式标记,而非写死 fixed_usdt。"""
d = check_exits(
net_pnl=25.0,
exit_mode="premium_multiple",
net_profit_target=15.0,
premium_exit_multiple=1.0,
initial_premium=20.0,
locked_exit_target=20.0,
)
assert d.should_close is True
assert d.target == 20.0
assert d.reason == "premium_multiple"
def test_locked_target_ignores_setting_drift() -> None: def test_locked_target_ignores_setting_drift() -> None:
d = check_exits( d = check_exits(
net_pnl=20.0, net_pnl=20.0,
+8 -8
View File
@@ -45,14 +45,14 @@ class _FakeClient:
return -1.5 return -1.5
def test_enrich_live_unrealized_entry_fee_times_two() -> None: def test_enrich_live_unrealized_keeps_book_net_plus_funding() -> None:
base = { base = {
"has_position": True, "has_position": True,
"group_id": "G1", "group_id": "G1",
"perp_side": "short", "perp_side": "short",
"perp_upl": 1.0, "perp_upl": 1.0,
"option_upl": 5.0, "option_upl": 5.0,
"est_close_fees": 9.9, "est_close_fees": 0.8,
"net_pnl": -3.9, "net_pnl": -3.9,
} }
@@ -69,12 +69,12 @@ def test_enrich_live_unrealized_entry_fee_times_two() -> None:
perp_side="short", perp_side="short",
open_at_ms=1, open_at_ms=1,
) )
assert out["perp_upl"] == 8.0 assert out["perp_upl"] == 1.0 # 盘口可平
assert out["perp_upl_exchange"] == 8.0
assert out["option_upl"] == 5.0 assert out["option_upl"] == 5.0
assert out["fees_paid"] == 0.5 assert out["fees_paid"] == 0.5
assert out["funding_usdt"] == -1.5 assert out["funding_usdt"] == -1.5
# 离场费按入场估算 assert out["est_close_fees"] == 0.8
assert out["est_close_fees"] == 0.5 # 盯盘净利 = 盘口净利 + 资金费,不用标记 UPL 覆盖
# 8 + 5 - 0.5*2 + (-1.5) = 10.5 assert abs(out["net_pnl"] - (-3.9 - 1.5)) < 1e-9
assert abs(out["net_pnl"] - 10.5) < 1e-9 assert out["pnl_source"] == "live_book_plus_funding"
assert out["pnl_source"] == "live_exchange"