diff --git a/backend/app/live/live_pnl.py b/backend/app/live/live_pnl.py index 74181ec..537fb3a 100644 --- a/backend/app/live/live_pnl.py +++ b/backend/app/live/live_pnl.py @@ -73,20 +73,28 @@ def enrich_live_unrealized( option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地) option2_upl = float(base.get("option2_upl") or 0.0) # 期期 Put 腿 - # 盯盘/达标:离场费 ≈ 入场费 → 合计扣 已付×2 - est_close = float(fees_paid) - # 期期无永续:perp_upl 一般为 0;仍加 option2 - net_pnl = perp_upl + option_upl + option2_upl - fees_paid * 2.0 + funding + # 盯盘/达标:离场费已在 base.net_pnl(盘口可成交价)计入;这里只叠加资金费。 + # 勿用交易所永续标记 UPL 覆盖净利,否则会虚高触发达标、成交后变亏。 + book_net = base.get("net_pnl") + 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["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["option2_upl"] = option2_upl out["fees_paid"] = fees_paid 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["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 diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 0b8db03..e77e180 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -2233,13 +2233,19 @@ class Matcher: est_opt_close_fee = of.fee opt_mark = bid opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None - # 浮盈亏:买一×数量 − 初始权利金(对齐可市价卖出) - option_upl = bid * opt_qty - initial_premium + # 与 close_group 一致:用含费率调整的可卖出价(勿用裸买一虚高触发达标) + 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: opt_mark = oq.bid or oq.mark_px opt_bid_sz = float(oq.bid_sz) if oq.bid_sz is not None else None if opt_mark is not None: - option_upl = float(opt_mark) * opt_qty - initial_premium + if opt_entry > 0: + option_upl = (float(opt_mark) - float(opt_entry)) * opt_qty + else: + option_upl = float(opt_mark) * opt_qty - initial_premium 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 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 - # 净盈利:浮盈 − 入场手续费×2(离场费按入场估算);无入场费时退回盘口估平仓费 - if open_fees > 1e-12: - est_close_fees = open_fees - net_pnl = perp_upl + option_upl - open_fees * 2.0 + # 净盈利:浮盈 − 已付开仓费 − 预估平仓费(与 close_group 入账口径对齐) + if book_close_fees > 1e-12 or open_fees > 1e-12: + est_close_fees = ( + book_close_fees if book_close_fees > 1e-12 else open_fees + ) + net_pnl = perp_upl + option_upl - open_fees - est_close_fees else: - est_close_fees = book_close_fees - net_pnl = perp_upl + option_upl - est_close_fees + est_close_fees = 0.0 + net_pnl = perp_upl + option_upl expiry_ms = None if expiry_ymd and len(expiry_ymd) == 6: try: diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index a46cb1f..bc2eb33 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -882,6 +882,17 @@ class StrategyEngine: locked_exit_target=locked_exit, ) 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: is_oo = ( str(upl.get("hedge_mode") or "") == "option_option" diff --git a/backend/app/strategy/exits.py b/backend/app/strategy/exits.py index be5e2e8..8fd345c 100644 --- a/backend/app/strategy/exits.py +++ b/backend/app/strategy/exits.py @@ -110,16 +110,17 @@ def check_exits( locked_exit_target: float | None = None, ) -> ExitDecision: """净盈利(预估全平后)≥ 所选模式目标则全平。持仓锁定目标优先。""" + # 模式始终按设置解析(权利金倍数 vs 固定),勿因锁定目标就改成 fixed_usdt + _resolved_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, - ) + target = float(_resolved_target) 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/tests/test_exit_lock.py b/backend/tests/test_exit_lock.py index 361d55e..a8e756f 100644 --- a/backend/tests/test_exit_lock.py +++ b/backend/tests/test_exit_lock.py @@ -5,6 +5,21 @@ from __future__ import annotations 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: d = check_exits( net_pnl=20.0, diff --git a/backend/tests/test_live_pnl.py b/backend/tests/test_live_pnl.py index 8614a75..0922b2b 100644 --- a/backend/tests/test_live_pnl.py +++ b/backend/tests/test_live_pnl.py @@ -45,14 +45,14 @@ class _FakeClient: 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 = { "has_position": True, "group_id": "G1", "perp_side": "short", "perp_upl": 1.0, "option_upl": 5.0, - "est_close_fees": 9.9, + "est_close_fees": 0.8, "net_pnl": -3.9, } @@ -69,12 +69,12 @@ def test_enrich_live_unrealized_entry_fee_times_two() -> None: perp_side="short", 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["fees_paid"] == 0.5 assert out["funding_usdt"] == -1.5 - # 离场费按入场估算 - assert out["est_close_fees"] == 0.5 - # 8 + 5 - 0.5*2 + (-1.5) = 10.5 - assert abs(out["net_pnl"] - 10.5) < 1e-9 - assert out["pnl_source"] == "live_exchange" + assert out["est_close_fees"] == 0.8 + # 盯盘净利 = 盘口净利 + 资金费,不用标记 UPL 覆盖 + assert abs(out["net_pnl"] - (-3.9 - 1.5)) < 1e-9 + assert out["pnl_source"] == "live_book_plus_funding"