Add Binance Testnet exchange gateway
This commit is contained in:
109
tests/test_binance_client.py
Normal file
109
tests/test_binance_client.py
Normal 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×tamp=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"))
|
||||
162
tests/test_binance_gateway.py
Normal file
162
tests/test_binance_gateway.py
Normal 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
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user