From 641351a0029ae98b03b8960d2b42c64a989ac203 Mon Sep 17 00:00:00 2001 From: eezd Date: Mon, 26 Oct 2026 14:23:51 +0800 Subject: [PATCH] Cancel remaining entries before fetching break-even fees After the first take-profit fills, cancel outstanding entry orders before calculating remaining-position protection. Missing fee records still fail explicitly and leave existing stops in place without allowing the old entry to remain open. Cover complete and incomplete fees on Binance and Bybit, including cancellation before the recovery fetch and no duplicate cancellation. Verify the staged snapshot with 61 trading safety and plan tests. --- backend/src/alphapulse/trading/executor.py | 5 +++ backend/tests/test_trading_safety.py | 39 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/backend/src/alphapulse/trading/executor.py b/backend/src/alphapulse/trading/executor.py index 775433f..5694805 100644 --- a/backend/src/alphapulse/trading/executor.py +++ b/backend/src/alphapulse/trading/executor.py @@ -574,6 +574,11 @@ class Executor: for j in range(len(TAKE_PROFIT_STEPS[sizing["direction"]]))] stop = Decimal(sizing["entry"]["stop"]) if tp_filled[0] > 0 and position is not None: + # 撤销剩余入场不依赖手续费;先撤,避免保本计算失败后仍可能继续加仓。 + for row in active: + if row.purpose == "entry": + await self.cancel(client, row) + active = [r for r in active if r.purpose != "entry"] fee_per_unit = await self.entry_fee_per_unit(session, client, plan.symbol, entry_rows) break_even = remaining_break_even(sizing["direction"], average, fee_per_unit, ESTIMATED_CLOSE_FEE_RATES[client.exchange], rules.tick_size) diff --git a/backend/tests/test_trading_safety.py b/backend/tests/test_trading_safety.py index 5c511c4..d5cce02 100644 --- a/backend/tests/test_trading_safety.py +++ b/backend/tests/test_trading_safety.py @@ -172,6 +172,7 @@ def test_missing_entry_fees_leave_existing_protection_untouched(trading, monkeyp engine, client = trading.Executor(), Client() p, entry, old = plan(trading), order(trading, "entry"), order(trading, "stop") entry.exchange_id, entry.filled_qty, entry.avg_price = "entry-id", Decimal(1), Decimal(100) + entry.status = "filled" entry.created_at = datetime.now(UTC) tp = trading.TradingOrder(purpose="tp", leg=0, filled_qty=Decimal("0.6"), status="filled") monkeypatch.setattr(client, "trades", AsyncMock(return_value=[]), raising=False) @@ -187,6 +188,44 @@ def test_missing_entry_fees_leave_existing_protection_untouched(trading, monkeyp asyncio.run(check()) +@pytest.mark.parametrize("exchange", ["binance", "bybit"]) +@pytest.mark.parametrize("fees_complete", [False, True]) +def test_tp1_cancels_remaining_entry_before_fee_lookup(trading, monkeypatch, exchange, fees_complete): + async def check(): + engine, client = trading.Executor(), Client() + client.exchange = engine.exchange = exchange + p, entry, old = plan(trading), order(trading, "entry"), order(trading, "stop") + p.plan_status = "open" + entry.exchange_id, entry.filled_qty, entry.avg_price = "entry-id", Decimal("0.5"), Decimal(100) + entry.status, entry.created_at = "partially_filled", datetime.now(UTC) + old.trigger_price = Decimal(90) + tp = trading.TradingOrder(purpose="tp", leg=0, filled_qty=Decimal("0.3"), status="filled") + fill = SimpleNamespace(order_id="entry-id", qty=entry.filled_qty, commission_asset="USDT", commission=Decimal("0.01")) + session = Session(groups=[[fill]] if fees_complete else [[], []]) + position = trading.Position("long", Decimal("0.2"), Decimal(100), Decimal(101)) + + async def trades(*args): + # 补取仍缺手续费时,撤入场单也必须先完成。 + assert client.canceled == ["ap2"] + return [] + + monkeypatch.setattr(client, "trades", AsyncMock(side_effect=trades), raising=False) + monkeypatch.setattr(trading, "save_fills", AsyncMock()) + try: + if fees_complete: + await engine.sync_orders(session, client, p, [entry, old, tp], position, True, RULES, False) + else: + with pytest.raises(ExchangeError, match="手续费尚未完整同步"): + await engine.sync_orders(session, client, p, [entry, old, tp], position, True, RULES, False) + assert client.created == [] + assert client.canceled == ["ap2"] + assert client.canceled.count("ap2") == 1 + assert all(spec.purpose != "entry" for spec in client.created) + finally: + await engine.market.http.aclose() + asyncio.run(check()) + + @pytest.mark.parametrize("asset", ["USDT", "BNB"]) def test_entry_fees_recover_complete_partial_fills_without_using_realized_profit(trading, monkeypatch, asset): async def check():