from __future__ import annotations from decimal import Decimal from typing import Any import pytest from llm_trade_bot.binance_gateway import BinanceError, BinanceGateway from llm_trade_bot.config import BinanceAccountConfig, BinanceConfig, ScannerConfig from llm_trade_bot.models import ( Category, OpenTradeRequest, OrderType, Side, ValidatedOrder, ) class FakeBinanceClient: def __init__(self, exchange_info_path: str, mutation_results: list[dict[str, Any]]): self.exchange_info_path = exchange_info_path self.mutation_results = mutation_results self.requests: list[tuple[str, str, dict[str, Any] | None, dict[str, Any]]] = [] self.mutations: list[tuple[str, str, dict[str, Any]]] = [] async def request(self, method: str, path: str, parameters=None, **kwargs): self.requests.append((method, path, parameters, kwargs)) if path == self.exchange_info_path: return { "symbols": [ { "symbol": "BTCUSDT", "status": "TRADING", "otoAllowed": True, "ocoAllowed": True, "filters": [ {"filterType": "PRICE_FILTER", "tickSize": "0.1"}, {"filterType": "LOT_SIZE", "minQty": "0.001", "stepSize": "0.001"}, {"filterType": "MIN_NOTIONAL", "minNotional": "5"}, ], } ] } if path == "/fapi/v1/positionSide/dual": return {"dualSidePosition": False} return {"ok": True} async def mutate_with_recovery(self, method: str, path: str, parameters: dict[str, Any], **kwargs): self.mutations.append((method, path, parameters)) return self.mutation_results.pop(0) async def close(self) -> None: return None def gateway(spot: FakeBinanceClient, usdm: FakeBinanceClient) -> BinanceGateway: return BinanceGateway( BinanceConfig(BinanceAccountConfig("spot-key", "spot-secret"), BinanceAccountConfig("future-key", "future-secret")), ScannerConfig(), spot_client=spot, # type: ignore[arg-type] usdm_client=usdm, # type: ignore[arg-type] ) def request(category: Category, **changes: Any) -> OpenTradeRequest: values = { "category": category, "symbol": "BTCUSDT", "side": Side.BUY, "order_type": OrderType.LIMIT if category is Category.SPOT else OrderType.MARKET, "reference_price": Decimal(100), "limit_price": Decimal(100) if category is Category.SPOT else None, "stop_loss": Decimal(90), "take_profit": Decimal(120), "trailing_distance": None, "risk_pct": Decimal(2), "leverage": Decimal(3), "rationale": "test", } values.update(changes) return OpenTradeRequest(**values) def order(**changes: Any) -> ValidatedOrder: values = { "entry_price": Decimal(100), "quantity": Decimal(2), "notional": Decimal(200), "stop_loss": Decimal(90), "take_profit": Decimal(120), "trailing_distance": None, "leverage": Decimal(3), } values.update(changes) return ValidatedOrder(**values) async def test_spot_uses_single_atomic_otoco_with_market_tp_and_sl(): spot = FakeBinanceClient( "/api/v3/exchangeInfo", [ { "orderListId": 7, "listOrderStatus": "EXECUTING", "orderReports": [{"orderId": 11, "clientOrderId": "ltb-1-entry", "status": "NEW"}], } ], ) usdm = FakeBinanceClient("/fapi/v1/exchangeInfo", []) result = await gateway(spot, usdm).open_trade(request(Category.SPOT), order(), "ltb-1") assert result["provider"] == "binance" assert result["category"] == "spot" assert result["order_id"] == "11" assert len(spot.mutations) == 1 method, path, parameters = spot.mutations[0] assert (method, path) == ("POST", "/api/v3/orderList/otoco") assert parameters["workingType"] == "LIMIT" assert parameters["workingSide"] == "BUY" assert parameters["pendingSide"] == "SELL" assert parameters["pendingAboveType"] == "TAKE_PROFIT" assert parameters["pendingBelowType"] == "STOP_LOSS" async def test_futures_installs_stop_and_take_profit_after_market_fill(): spot = FakeBinanceClient("/api/v3/exchangeInfo", []) usdm = FakeBinanceClient( "/fapi/v1/exchangeInfo", [ {"orderId": 44, "status": "FILLED", "executedQty": "2", "avgPrice": "100"}, {"algoId": 45}, {"algoId": 46}, ], ) result = await gateway(spot, usdm).open_trade(request(Category.LINEAR), order(), "ltb-2") assert result["status"] == "Filled" assert result["protection"]["status"] == "active" assert [path for _, path, _ in usdm.mutations] == [ "/fapi/v1/order", "/fapi/v1/algoOrder", "/fapi/v1/algoOrder", ] stop = usdm.mutations[1][2] take_profit = usdm.mutations[2][2] assert stop["type"] == "STOP_MARKET" assert stop["closePosition"] is True assert take_profit["type"] == "TAKE_PROFIT_MARKET" assert take_profit["closePosition"] is True assert any(path == "/fapi/v1/marginType" for _, path, _, _ in usdm.requests) assert any(path == "/fapi/v1/leverage" for _, path, _, _ in usdm.requests) async def test_trailing_rate_outside_binance_range_rejects_before_opening(): spot = FakeBinanceClient("/api/v3/exchangeInfo", []) usdm = FakeBinanceClient("/fapi/v1/exchangeInfo", []) with pytest.raises(BinanceError, match="callbackRate"): await gateway(spot, usdm).open_trade( request(Category.LINEAR, take_profit=None, trailing_distance=Decimal("0.05")), order(take_profit=None, trailing_distance=Decimal("0.05")), "ltb-3", ) assert not usdm.requests assert not usdm.mutations