Files
llm-trade-bot/tests/test_binance_gateway.py
2026-09-19 17:41:42 +02:00

196 lines
7.4 KiB
Python

from __future__ import annotations
from decimal import Decimal
from typing import Any
import pytest
from llm_trade_bot.binance_gateway import BinanceError, BinanceGateway, _active_ideas
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
async def test_linear_market_context_uses_a_valid_binance_depth_limit():
class MarketClient(FakeBinanceClient):
async def request(self, method: str, path: str, parameters=None, **kwargs):
self.requests.append((method, path, parameters, kwargs))
if path.endswith("/ticker/24hr"):
return {"lastPrice": "100"}
if path.endswith("/depth"):
return {"bids": [], "asks": []}
if path.endswith("/klines"):
return [[0, "1", "101", "99", "100", "10"] for _ in range(20)]
return await super().request(method, path, parameters, **kwargs)
spot = MarketClient("/api/v3/exchangeInfo", [])
usdm = MarketClient("/fapi/v1/exchangeInfo", [])
context = await gateway(spot, usdm).market_context("linear", "BTCUSDT")
depth = next(parameters for _, path, parameters, _ in usdm.requests if path == "/fapi/v1/depth")
assert context["ticker"]["lastPrice"] == "100"
assert depth == {"symbol": "BTCUSDT", "limit": 20}
def test_seeded_spot_assets_are_exposure_but_not_active_ideas():
state = {
"wallets": {"spot": {"balances": [{"asset": "SOL", "free": "4", "locked": "0"}]}},
"positions": [],
"open_orders": [],
}
assert _active_ideas(state) == set()
state["open_orders"] = [
{"category": "spot", "symbol": "SOLUSDT", "protective": True, "client_id": "ltb-1-tp"}
]
assert _active_ideas(state) == {"spot:SOLUSDT"}