163 lines
5.9 KiB
Python
163 lines
5.9 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
|
|
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
|