Files
fxhnt/tests/unit/test_bybit_execution_adapter.py
jgrusewski 78c625a99b feat(broker): add BybitExecution.open_orders for testnet idempotency guard
Wraps ccxt fetch_open_orders so the testnet reconcile can check for resting
orders before placing (design §4.3 pre-place guard). Adds adapter tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:17:41 +02:00

171 lines
6.5 KiB
Python

"""BybitExecution ccxt adapter (design §4.1) against a FAKE ccxt exchange (no network).
The exchange factory is injected so these tests never touch ccxt or the wire. Covers: sandbox set when
testnet; place_market issues the right create_order call + reduceOnly param; mid midpoint + None on an
empty/one-sided book (the thin-testnet sentinel feeding low_confidence); instrument_limits parsing;
positions/equity parsing; BrokerError wrapping; the api_secret never appears in repr.
"""
from __future__ import annotations
from typing import Any
import pytest
from fxhnt.adapters.broker.bybit import BrokerError, BybitExecution, Fill
from fxhnt.application.bybit_testnet_execution import InstrumentLimit
class FakeExchange:
"""A minimal stand-in for ccxt.bybit recording every call the adapter makes."""
def __init__(self, config: dict[str, Any]) -> None:
self.config = config
self.sandbox: bool | None = None
self.created_orders: list[dict[str, Any]] = []
self.order_books: dict[str, dict[str, list[list[float]]]] = {}
self.balance: dict[str, Any] = {"total": {"USDT": 0.0}}
self.positions_data: list[dict[str, Any]] = []
self.markets: dict[str, Any] = {}
self.raise_on_create: Exception | None = None
self.open_orders_data: list[dict[str, Any]] = []
self.open_orders_calls: list[str | None] = []
def set_sandbox_mode(self, flag: bool) -> None:
self.sandbox = flag
def fetch_balance(self) -> dict[str, Any]:
return self.balance
def fetch_positions(self) -> list[dict[str, Any]]:
return self.positions_data
def load_markets(self) -> dict[str, Any]:
return self.markets
def fetch_order_book(self, symbol: str, limit: int | None = None) -> dict[str, list[list[float]]]:
return self.order_books.get(symbol, {"bids": [], "asks": []})
def fetch_open_orders(self, symbol: str | None = None) -> list[dict[str, Any]]:
self.open_orders_calls.append(symbol)
return self.open_orders_data
def create_order(self, symbol: str, type: str, side: str, amount: float, # noqa: A002 - ccxt sig
price: float | None = None, params: dict[str, Any] | None = None) -> dict[str, Any]:
if self.raise_on_create is not None:
raise self.raise_on_create
self.created_orders.append(
{"symbol": symbol, "type": type, "side": side, "amount": amount, "params": params or {}})
return {"id": "ord-1", "filled": amount, "average": 100.5,
"fee": {"cost": 0.07}, "timestamp": 1_700_000_000_000}
def _adapter(fake: FakeExchange, *, testnet: bool = True) -> BybitExecution:
return BybitExecution("KEY", "SECRET", testnet=testnet, exchange_factory=lambda cfg: fake)
def test_sandbox_set_when_testnet() -> None:
fake = FakeExchange({})
_adapter(fake, testnet=True)
assert fake.sandbox is True
def test_sandbox_off_when_not_testnet() -> None:
fake = FakeExchange({})
_adapter(fake, testnet=False)
assert fake.sandbox is False
def test_place_market_buy_calls_create_order() -> None:
fake = FakeExchange({})
adapter = _adapter(fake)
fill = adapter.place_market("BTCUSDT", 0.5, reduce_only=False)
assert fake.created_orders == [
{"symbol": "BTCUSDT", "type": "market", "side": "buy", "amount": 0.5,
"params": {"reduceOnly": False}}]
assert isinstance(fill, Fill)
assert fill.symbol == "BTCUSDT" and fill.side == "BUY"
assert fill.qty == 0.5 and fill.avg_price == 100.5
assert fill.fee == 0.07 and fill.order_id == "ord-1"
def test_place_market_sell_uses_abs_qty_and_sell_side() -> None:
fake = FakeExchange({})
fill = _adapter(fake).place_market("ETHUSDT", -2.0, reduce_only=True)
assert fake.created_orders[0]["side"] == "sell"
assert fake.created_orders[0]["amount"] == 2.0
assert fake.created_orders[0]["params"] == {"reduceOnly": True}
assert fill.side == "SELL" and fill.qty == 2.0
def test_mid_returns_midpoint() -> None:
fake = FakeExchange({})
fake.order_books["BTCUSDT"] = {"bids": [[100.0, 1.0]], "asks": [[102.0, 1.0]]}
assert _adapter(fake).mid("BTCUSDT") == 101.0
def test_mid_none_on_empty_book() -> None:
fake = FakeExchange({})
fake.order_books["BTCUSDT"] = {"bids": [], "asks": []}
assert _adapter(fake).mid("BTCUSDT") is None
def test_mid_none_on_one_sided_book() -> None:
fake = FakeExchange({})
fake.order_books["BTCUSDT"] = {"bids": [[100.0, 1.0]], "asks": []}
assert _adapter(fake).mid("BTCUSDT") is None
def test_instrument_limits_parses() -> None:
fake = FakeExchange({})
fake.markets = {
"BTC/USDT:USDT": {"id": "BTCUSDT", "swap": True, "quote": "USDT", "active": True,
"limits": {"amount": {"min": 0.001}, "cost": {"min": 5.0}},
"precision": {"amount": 0.001, "price": 0.1}},
}
limits = _adapter(fake).instrument_limits()
assert limits["BTCUSDT"] == InstrumentLimit(min_order_qty=0.001, qty_step=0.001, min_notional=5.0)
def test_positions_signed() -> None:
fake = FakeExchange({})
fake.positions_data = [
{"symbol": "BTC/USDT:USDT", "info": {"symbol": "BTCUSDT"}, "contracts": 0.5, "side": "long"},
{"symbol": "ETH/USDT:USDT", "info": {"symbol": "ETHUSDT"}, "contracts": 2.0, "side": "short"},
{"symbol": "XRP/USDT:USDT", "info": {"symbol": "XRPUSDT"}, "contracts": 0.0, "side": None},
]
pos = _adapter(fake).positions()
assert pos == {"BTCUSDT": 0.5, "ETHUSDT": -2.0}
def test_equity_parses_total_usdt() -> None:
fake = FakeExchange({})
fake.balance = {"total": {"USDT": 12_345.6}}
assert _adapter(fake).equity() == 12_345.6
def test_broker_error_wraps_ccxt_error() -> None:
fake = FakeExchange({})
fake.raise_on_create = RuntimeError("ccxt boom")
with pytest.raises(BrokerError):
_adapter(fake).place_market("BTCUSDT", 0.5)
def test_open_orders_returns_resting_orders() -> None:
fake = FakeExchange({})
fake.open_orders_data = [{"id": "o1", "symbol": "BTC/USDT:USDT", "info": {"symbol": "BTCUSDT"}}]
orders = _adapter(fake).open_orders()
assert orders == fake.open_orders_data
assert fake.open_orders_calls == [None]
def test_open_orders_passes_symbol_and_empty_default() -> None:
fake = FakeExchange({})
assert _adapter(fake).open_orders("BTCUSDT") == []
assert fake.open_orders_calls == ["BTCUSDT"]
def test_secret_never_in_repr() -> None:
fake = FakeExchange({})
adapter = _adapter(fake)
assert "SECRET" not in repr(adapter)