Add Binance Testnet exchange gateway

This commit is contained in:
Azimkin
2026-09-19 17:14:46 +02:00
parent 7272147270
commit 93cafe8160
18 changed files with 2505 additions and 230 deletions

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import hashlib
import hmac
import httpx
import pytest
from llm_trade_bot.binance_client import (
SPOT_REST_URL,
BinanceCredentials,
BinanceRestClient,
encode_parameters,
sign_parameters,
)
def test_hmac_signature_uses_sorted_urlencoded_parameters():
parameters = {
"symbol": "BTCUSDT",
"side": "BUY",
"quantity": "0.01000000",
"recvWindow": 100,
"timestamp": 1645423376532,
}
payload = encode_parameters(parameters)
assert payload == "quantity=0.01000000&recvWindow=100&side=BUY&symbol=BTCUSDT&timestamp=1645423376532"
assert sign_parameters(parameters, "secret") == hmac.new(
b"secret", payload.encode(), hashlib.sha256
).hexdigest()
async def test_signed_request_resynchronizes_time_and_resigns():
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
if request.url.path == "/api/v3/time":
return httpx.Response(200, json={"serverTime": 1_000_100}, request=request)
if len([item for item in seen if item.url.path == "/api/v3/account"]) == 1:
return httpx.Response(400, json={"code": -1021, "msg": "outside recvWindow"}, request=request)
query = dict(request.url.params)
unsigned = {key: value for key, value in query.items() if key != "signature"}
assert query["signature"] == sign_parameters(unsigned, "secret")
assert query["recvWindow"] == "5000"
return httpx.Response(200, json={"ok": True}, request=request)
client = BinanceRestClient(
SPOT_REST_URL,
BinanceCredentials("key", "secret"),
http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)),
clock=lambda: 1000.0,
)
assert await client.request("GET", "/api/v3/account", signed=True) == {"ok": True}
assert [request.url.path for request in seen] == ["/api/v3/account", "/api/v3/time", "/api/v3/account"]
async def test_rate_limit_honors_retry_after():
attempts = 0
pauses: list[float] = []
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts == 1:
return httpx.Response(429, headers={"Retry-After": "0.25"}, json={"code": -1003}, request=request)
return httpx.Response(200, json={"value": "ok"}, request=request)
async def sleep(delay: float) -> None:
pauses.append(delay)
client = BinanceRestClient(
SPOT_REST_URL,
BinanceCredentials("key", "secret"),
http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)),
sleep=sleep,
)
assert await client.request("GET", "/api/v3/ping") == {"value": "ok"}
assert pauses == [0.25]
async def test_ambiguous_mutation_queries_client_id_instead_of_retrying_post():
methods: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
methods.append(request.method)
if request.method == "POST":
return httpx.Response(500, json={"msg": "backend timeout"}, request=request)
return httpx.Response(200, json={"orderId": 42, "clientOrderId": "ltb-1"}, request=request)
client = BinanceRestClient(
SPOT_REST_URL,
BinanceCredentials("key", "secret"),
http_client=httpx.AsyncClient(base_url=SPOT_REST_URL, transport=httpx.MockTransport(handler)),
)
result = await client.mutate_with_recovery(
"POST",
"/api/v3/order",
{"symbol": "BTCUSDT", "newClientOrderId": "ltb-1"},
lookup_path="/api/v3/order",
lookup_parameters={"symbol": "BTCUSDT", "origClientOrderId": "ltb-1"},
)
assert result["orderId"] == 42
assert methods == ["POST", "GET"]
def test_production_url_is_not_accepted():
with pytest.raises(ValueError, match="Testnet"):
BinanceRestClient("https://api.binance.com", BinanceCredentials("key", "secret"))

View File

@@ -0,0 +1,162 @@
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

View File

@@ -75,3 +75,66 @@ def test_rejects_missing_prompts_file(tmp_path: Path):
path.write_text(CONFIG, encoding="utf-8")
with pytest.raises(ConfigError, match="Prompts file not found"):
load_config(path)
def test_legacy_bybit_config_selects_bybit(tmp_path: Path):
config = load_config(write_config(tmp_path))
assert config.exchange.provider == "bybit"
assert config.bybit is not None
assert config.binance is None
def test_binance_config_only_requires_binance_credentials(tmp_path: Path):
content = CONFIG.replace(
'[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n',
'''[exchange]
provider = "binance"
[binance.spot]
api_key = "spot-key"
api_secret = "spot-secret"
[binance.usdm]
api_key = "usdm-key"
api_secret = "usdm-secret"
''',
)
config = load_config(write_config(tmp_path, content))
assert config.exchange.provider == "binance"
assert config.bybit is None
assert config.binance is not None
assert config.binance.spot.api_key == "spot-key"
def test_binance_rejects_endpoint_override(tmp_path: Path):
content = CONFIG.replace(
'[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n',
'''[exchange]
provider = "binance"
[binance]
base_url = "https://api.binance.com"
[binance.spot]
api_key = "spot-key"
api_secret = "spot-secret"
[binance.usdm]
api_key = "usdm-key"
api_secret = "usdm-secret"
''',
)
with pytest.raises(ConfigError, match="fixed to Testnet"):
load_config(write_config(tmp_path, content))
def test_binance_requires_both_selected_wallet_credentials(tmp_path: Path):
content = CONFIG.replace(
'[bybit]\napi_key = "bybit-key"\napi_secret = "bybit-secret"\n',
'''[exchange]
provider = "binance"
[binance.spot]
api_key = "spot-key"
api_secret = "spot-secret"
[binance.usdm]
api_key = "replace-me"
api_secret = "replace-me"
''',
)
with pytest.raises(ConfigError, match="placeholder"):
load_config(write_config(tmp_path, content))

View File

@@ -1,3 +1,4 @@
import sqlite3
from pathlib import Path
from llm_trade_bot.database import Database
@@ -35,3 +36,44 @@ async def test_run_lifecycle(tmp_path: Path):
assert last["summary"] == "idle"
finally:
await database.close()
async def test_managed_orders_migrate_legacy_rows_to_bybit(tmp_path: Path):
path = tmp_path / "legacy.db"
connection = sqlite3.connect(path)
connection.execute(
"""
CREATE TABLE managed_orders (
order_link_id TEXT PRIMARY KEY, order_id TEXT, category TEXT NOT NULL,
symbol TEXT NOT NULL, side TEXT NOT NULL, status TEXT NOT NULL,
expires_at TEXT, trailing_distance TEXT, raw_json TEXT NOT NULL, updated_at TEXT NOT NULL
)
"""
)
connection.execute(
"""
INSERT INTO managed_orders VALUES (
'ltb-old', 'old-1', 'linear', 'BTCUSDT', 'Buy', 'Created',
'2000-01-01T00:00:00+00:00', NULL, '{}', '2000-01-01T00:00:00+00:00'
)
"""
)
connection.commit()
connection.close()
database = Database(path)
await database.open()
try:
rows = await database.expired_managed_orders(provider="bybit")
assert rows == [
{
"order_link_id": "ltb-old",
"order_id": "old-1",
"provider": "bybit",
"category": "linear",
"symbol": "BTCUSDT",
}
]
assert await database.expired_managed_orders(provider="binance") == []
finally:
await database.close()