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():