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>
171 lines
6.5 KiB
Python
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)
|