import base64 import hashlib import hmac import json as json_module from dataclasses import dataclass from urllib.parse import urlencode, urlparse import pytest from okx_codex_trader.config import Config from okx_codex_trader.models import InstrumentMeta, TradeSignal from okx_codex_trader.okx_client import OkxClient, build_contract_size @dataclass class DummyResponse: payload: dict[str, object] status_code: int = 200 json_error: Exception | None = None def json(self) -> dict[str, object]: if self.json_error is not None: raise self.json_error return self.payload @dataclass class RecordedRequest: method: str url: str headers: dict[str, str] params: dict[str, object] | None json_body: dict[str, object] | None body: str | None timeout: float | None class DummySession: def __init__(self, responses: list[DummyResponse | Exception] | None = None): self._responses = list(responses or []) self.last_request: RecordedRequest | None = None self.request_paths: list[str] = [] self.request_bodies: list[dict[str, object] | None] = [] @property def last_json_body(self) -> dict[str, object] | None: return self.last_request.json_body if self.last_request else None @property def last_body(self) -> str | None: return self.last_request.body if self.last_request else None def request( self, method: str, url: str, *, headers: dict[str, str] | None = None, params: dict[str, object] | None = None, json: dict[str, object] | None = None, data: str | None = None, timeout: float | None = None, ) -> DummyResponse: parsed_json = json if parsed_json is None and data is not None: parsed_json = json_module.loads(data) self.last_request = RecordedRequest( method=method, url=url, headers=headers or {}, params=params, json_body=parsed_json, body=data, timeout=timeout, ) self.request_paths.append(urlparse(url).path) self.request_bodies.append(parsed_json) if self._responses: response = self._responses.pop(0) if isinstance(response, Exception): raise response return response return candles_response() def sample_config() -> Config: return Config(api_key="key", api_secret="secret", api_passphrase="passphrase") def live_config() -> Config: return Config(api_key="key", api_secret="secret", api_passphrase="passphrase", trading_env="live") def candles_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ ["1710000000000", "25000", "25100", "24900", "25050", "100", "1000", "1000", "1"], ], } ) def descending_candles_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ ["1710000001000", "25100", "25200", "25000", "25150", "110", "1100", "1100", "1"], ["1710000000000", "25000", "25100", "24900", "25050", "100", "1000", "1000", "1"], ], } ) def older_candles_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ ["1709999701000", "24699", "24799", "24649", "24749", "90", "900", "900", "1"], ["1709999700000", "24698", "24798", "24648", "24748", "80", "800", "800", "1"], ], } ) def full_page_candles_response() -> DummyResponse: data = [] for offset in range(100): ts = 1710000001000 - (offset * 1000) close = 25050 - offset data.append([str(ts), str(close - 50), str(close + 50), str(close - 100), str(close), "100", "1000", "1000", "1"]) return DummyResponse({"code": "0", "msg": "", "data": data}) def instrument_response(symbol: str = "BTC-USDT-SWAP") -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": symbol, "instType": "SWAP", "ctVal": "0.001", "lotSz": "1", "minSz": "1", } ], } ) def large_min_size_instrument_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "instType": "SWAP", "ctVal": "0.01", "lotSz": "1", "minSz": "100", } ], } ) def ticker_response(last: str) -> DummyResponse: return DummyResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "last": last}]}) def balance_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "totalEq": "101.5", "details": [ { "ccy": "USDT", "eq": "100.25", "availEq": "98.75", "cashBal": "100.0", } ], } ], } ) def account_config_response(pos_mode: str) -> DummyResponse: return DummyResponse({"code": "0", "msg": "", "data": [{"posMode": pos_mode}]}) def malformed_account_config_response(pos_mode: object) -> DummyResponse: return DummyResponse({"code": "0", "msg": "", "data": [{"posMode": pos_mode}]}) def leverage_response() -> DummyResponse: return DummyResponse({"code": "0", "msg": "", "data": [{"lever": "2"}]}) def place_order_response() -> DummyResponse: return DummyResponse({"code": "0", "msg": "", "data": [{"ordId": "123"}]}) def place_order_response_without_order_id() -> DummyResponse: return DummyResponse({"code": "0", "msg": "", "data": [{}]}) def error_response(code: str, msg: str) -> DummyResponse: return DummyResponse({"code": code, "msg": msg, "data": []}) def positions_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "8", "avgPx": "25000", } ], } ) def positions_with_zero_size_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "0", "avgPx": "25000", }, { "instId": "BTC-USDT-SWAP", "posSide": "short", "pos": "3", "avgPx": "24900", }, ], } ) def positions_with_zero_size_malformed_avg_price_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "0", "avgPx": "bad", }, { "instId": "BTC-USDT-SWAP", "posSide": "short", "pos": "3", "avgPx": "24900", }, ], } ) def positions_with_non_string_identity_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": None, "posSide": ["long"], "pos": "3", "avgPx": "24900", } ], } ) def candles_with_non_finite_numeric_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ ["1710000000000", "NaN", "25100", "24900", "25050", "100", "1000", "1000", "1"], ], } ) def instrument_with_non_finite_numeric_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "instType": "SWAP", "ctVal": "NaN", "lotSz": "1", "minSz": "1", } ], } ) def instrument_with_wrong_symbol_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "ETH-USDT-SWAP", "instType": "SWAP", "ctVal": "0.001", "lotSz": "1", "minSz": "1", } ], } ) def instrument_with_wrong_type_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "instType": "FUTURES", "ctVal": "0.001", "lotSz": "1", "minSz": "1", } ], } ) def ticker_with_non_finite_numeric_response() -> DummyResponse: return DummyResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "last": "Infinity"}]}) def positions_with_non_finite_numeric_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "1", "avgPx": "NaN", } ], } ) def positions_with_wrong_symbol_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "ETH-USDT-SWAP", "posSide": "long", "pos": "1", "avgPx": "25000", } ], } ) def positions_with_invalid_pos_side_response() -> DummyResponse: return DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "posSide": "net", "pos": "1", "avgPx": "25000", } ], } ) def market_long_signal() -> TradeSignal: return TradeSignal( action="long", confidence=0.9, leverage=2, entry_price=None, take_profit_price=26000.0, stop_loss_price=24000.0, reason="trend", ) def limit_short_signal() -> TradeSignal: return TradeSignal( action="short", confidence=0.8, leverage=2, entry_price=25000.0, take_profit_price=24000.0, stop_loss_price=25500.0, reason="mean reversion", ) def flat_signal() -> TradeSignal: return TradeSignal( action="flat", confidence=0.7, leverage=2, entry_price=None, take_profit_price=None, stop_loss_price=None, reason="exit", ) def test_signed_demo_request_attaches_headers(): session = DummySession() client = OkxClient(config=sample_config(), session=session) client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) request = session.last_request assert request is not None assert request.headers["x-simulated-trading"] == "1" assert request.headers["OK-ACCESS-KEY"] == "key" assert request.headers["OK-ACCESS-PASSPHRASE"] == "passphrase" timestamp = request.headers["OK-ACCESS-TIMESTAMP"] path = urlparse(request.url).path query = urlencode(request.params or {}) path_with_query = path if not query else f"{path}?{query}" expected_signature = base64.b64encode( hmac.new( b"secret", f"{timestamp}{request.method}{path_with_query}".encode(), hashlib.sha256, ).digest() ).decode() assert request.headers["OK-ACCESS-SIGN"] == expected_signature assert request.timeout is not None assert request.timeout > 0 def test_signed_live_request_attaches_live_header(): session = DummySession() client = OkxClient(config=live_config(), session=session) client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) request = session.last_request assert request is not None assert request.headers["x-simulated-trading"] == "0" def test_signed_post_request_uses_actual_serialized_body_bytes(): session = DummySession( [ account_config_response(pos_mode="long_short_mode"), instrument_response(symbol="ETH-USDT-SWAP"), leverage_response(), place_order_response(), ] ) client = OkxClient(config=sample_config(), session=session) client.place_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100) request = session.last_request assert request is not None assert request.method == "POST" assert request.body is not None timestamp = request.headers["OK-ACCESS-TIMESTAMP"] path = urlparse(request.url).path expected_signature = base64.b64encode( hmac.new( b"secret", f"{timestamp}{request.method}{path}{request.body}".encode(), hashlib.sha256, ).digest() ).decode() assert request.headers["OK-ACCESS-SIGN"] == expected_signature def test_get_candles_returns_chronological_ascending_order(): session = DummySession([descending_candles_response()]) client = OkxClient(config=sample_config(), session=session) candles = client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) assert [candle.ts for candle in candles] == [1710000000000, 1710000001000] def test_get_candles_ignores_unconfirmed_history_rows(): session = DummySession( [ DummyResponse( { "code": "0", "msg": "", "data": [ ["1710000001000", "25100", "25200", "25000", "25150", "110", "1100", "1100", "0"], ["1710000000000", "25000", "25100", "24900", "25050", "100", "1000", "1000", "1"], ], } ) ] ) client = OkxClient(config=sample_config(), session=session) candles = client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) assert [candle.ts for candle in candles] == [1710000000000] def test_get_candles_paginates_when_limit_exceeds_single_page(): session = DummySession([full_page_candles_response(), older_candles_response()]) client = OkxClient(config=sample_config(), session=session) candles = client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=102) assert len(candles) == 102 assert candles[0].ts == 1709999700000 assert candles[1].ts == 1709999701000 assert len(session.request_paths) == 2 def test_get_account_balance_returns_usdt_equity_fields(): session = DummySession([balance_response()]) client = OkxClient(config=sample_config(), session=session) balance = client.get_account_balance("USDT") assert session.request_paths == ["/api/v5/account/balance"] assert balance == { "total_equity_usd": 101.5, "equity": 100.25, "available_equity": 98.75, "cash_balance": 100.0, } def test_get_account_balance_returns_zero_when_currency_detail_is_absent(): session = DummySession( [ DummyResponse( { "code": "0", "msg": "", "data": [{"totalEq": "0", "details": []}], } ) ] ) client = OkxClient(config=sample_config(), session=session) balance = client.get_account_balance("USDT") assert balance == { "total_equity_usd": 0.0, "equity": 0.0, "available_equity": 0.0, "cash_balance": 0.0, } def test_build_contract_size_rounds_down_to_lot_size(): metadata = InstrumentMeta(ct_val=0.01, lot_sz=0.1, min_sz=0.1) assert build_contract_size(notional=251, price=25_000, metadata=metadata) == 1.0 def test_build_contract_size_fails_below_min_size(): metadata = InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=5) with pytest.raises(ValueError): build_contract_size(notional=250, price=25_100, metadata=metadata) @pytest.mark.parametrize("notional", [0, -1, float("nan"), float("inf")]) def test_build_contract_size_rejects_invalid_notional(notional): metadata = InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1) with pytest.raises(ValueError, match="contract sizing inputs are invalid"): build_contract_size(notional=notional, price=25_000, metadata=metadata) def test_build_contract_size_rejects_boolean_inputs(): metadata = InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1) with pytest.raises(ValueError, match="contract sizing inputs are invalid"): build_contract_size(notional=True, price=25_000, metadata=metadata) with pytest.raises(ValueError, match="contract sizing inputs are invalid"): build_contract_size(notional=100, price=False, metadata=metadata) with pytest.raises(ValueError, match="contract sizing inputs are invalid"): build_contract_size(notional=100, price=25_000, metadata=InstrumentMeta(ct_val=True, lot_sz=1, min_sz=1)) def test_post_only_limit_order_body_is_exact_okx_payload(): body = OkxClient.build_post_only_limit_order_body( symbol="ETH-USDT-SWAP", action="long", price="2991.0000", size="2.000", client_order_id="eth-twap-1", ) assert body == { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "buy", "posSide": "long", "ordType": "post_only", "px": "2991", "sz": "2", "clOrdId": "eth-twap-1", } def test_short_post_only_limit_order_body_uses_sell_short(): body = OkxClient.build_post_only_limit_order_body( symbol="ETH-USDT-SWAP", action="short", price="3018", size="1.5", client_order_id="eth-twap-short-2", ) assert body["side"] == "sell" assert body["posSide"] == "short" assert body["ordType"] == "post_only" def test_entry_batch_order_body_uses_three_independent_post_only_levels(): metadata = InstrumentMeta(ct_val=0.01, lot_sz=0.1, min_sz=0.1) body = OkxClient.build_entry_batch_order_body( symbol="ETH-USDT-SWAP", action="long", reference_price="3000", margin_usdt="90", leverage="2", metadata=metadata, client_order_id_prefix="eth-twap-20260430T000000Z", ) assert body == [ { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "buy", "posSide": "long", "ordType": "post_only", "px": "2991", "sz": "2", "clOrdId": "eth-twap-20260430T000000Z-1", }, { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "buy", "posSide": "long", "ordType": "post_only", "px": "2982", "sz": "2", "clOrdId": "eth-twap-20260430T000000Z-2", }, { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "buy", "posSide": "long", "ordType": "post_only", "px": "2973", "sz": "2", "clOrdId": "eth-twap-20260430T000000Z-3", }, ] def test_cancel_order_body_uses_exactly_one_identifier(): assert OkxClient.build_cancel_order_body(symbol="ETH-USDT-SWAP", order_id="123") == { "instId": "ETH-USDT-SWAP", "ordId": "123", } assert OkxClient.build_cancel_order_body(symbol="ETH-USDT-SWAP", client_order_id="eth-twap-1") == { "instId": "ETH-USDT-SWAP", "clOrdId": "eth-twap-1", } def test_pending_orders_query_params_are_minimal_params(): assert OkxClient.build_pending_orders_params(symbol="ETH-USDT-SWAP") == { "instType": "SWAP", "instId": "ETH-USDT-SWAP", } def test_market_order_body_supports_reduce_only_close(): assert OkxClient.build_market_order_body( symbol="ETH-USDT-SWAP", side="sell", pos_side="long", size=2, client_order_id="eth-close-1", reduce_only=True, ) == { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "sell", "posSide": "long", "ordType": "market", "sz": "2", "clOrdId": "eth-close-1", "reduceOnly": "true", } def test_market_order_body_attaches_stop_loss_for_open_order(): assert OkxClient.build_market_order_body( symbol="ETH-USDT-SWAP", side="sell", pos_side="short", size=1.38, client_order_id="eth-open-1", reduce_only=False, stop_loss_trigger_price=2140.25, ) == { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "sell", "posSide": "short", "ordType": "market", "sz": "1.38", "clOrdId": "eth-open-1", "attachAlgoOrds": [{"slTriggerPx": "2140.25", "slOrdPx": "-1"}], } def test_market_order_body_attaches_stop_loss_and_take_profit_for_open_order(): assert OkxClient.build_market_order_body( symbol="ETH-USDT-SWAP", side="buy", pos_side="long", size=1.38, client_order_id="eth-open-1", reduce_only=False, stop_loss_trigger_price=2140.25, take_profit_trigger_price=2225.5, ) == { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "buy", "posSide": "long", "ordType": "market", "sz": "1.38", "clOrdId": "eth-open-1", "attachAlgoOrds": [{"slTriggerPx": "2140.25", "slOrdPx": "-1", "tpTriggerPx": "2225.5", "tpOrdPx": "-1"}], } def test_market_order_body_rejects_stop_loss_on_reduce_only_order(): with pytest.raises(ValueError, match="attached TP/SL is invalid"): OkxClient.build_market_order_body( symbol="ETH-USDT-SWAP", side="sell", pos_side="long", size=1.0, client_order_id="eth-close-1", reduce_only=True, stop_loss_trigger_price=2000.0, ) def test_submit_market_order_body_posts_body_and_returns_order_result(): session = DummySession([place_order_response()]) client = OkxClient(sample_config(), session=session) body = OkxClient.build_market_order_body( symbol="ETH-USDT-SWAP", side="sell", pos_side="long", size=2, client_order_id="eth-close-1", reduce_only=True, ) result = client.submit_market_order_body(body) assert session.request_paths == ["/api/v5/trade/order"] assert session.last_json_body == body assert result.status == "placed" assert result.order_id == "123" assert result.symbol == "ETH-USDT-SWAP" assert result.side == "sell" assert result.pos_side == "long" assert result.order_type == "market" assert result.size == 2.0 def test_submit_market_order_body_rejects_invalid_body_before_okx(): session = DummySession([place_order_response()]) client = OkxClient(sample_config(), session=session) with pytest.raises(ValueError, match="market order body is invalid"): client.submit_market_order_body({"instId": "ETH-USDT-SWAP", "ordType": "limit"}) assert session.request_paths == [] def test_fills_query_params_are_minimal_params(): assert OkxClient.build_fills_params(symbol="ETH-USDT-SWAP") == { "instType": "SWAP", "instId": "ETH-USDT-SWAP", } @pytest.mark.parametrize( ("price", "metadata"), [ (0, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)), (-1, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)), (25_000, InstrumentMeta(ct_val=0, lot_sz=1, min_sz=1)), (25_000, InstrumentMeta(ct_val=-0.01, lot_sz=1, min_sz=1)), (25_000, InstrumentMeta(ct_val=0.01, lot_sz=0, min_sz=1)), (25_000, InstrumentMeta(ct_val=0.01, lot_sz=-1, min_sz=1)), (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=0)), (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=-1)), ], ) def test_build_contract_size_rejects_non_positive_inputs(price, metadata): with pytest.raises(ValueError, match="contract sizing inputs are invalid"): build_contract_size(notional=250, price=price, metadata=metadata) @pytest.mark.parametrize( ("price", "metadata"), [ (float("nan"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)), (float("inf"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)), (25_000, InstrumentMeta(ct_val=float("nan"), lot_sz=1, min_sz=1)), (25_000, InstrumentMeta(ct_val=0.01, lot_sz=float("inf"), min_sz=1)), (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=float("-inf"))), ], ) def test_build_contract_size_rejects_non_finite_inputs(price, metadata): with pytest.raises(ValueError, match="contract sizing inputs are invalid"): build_contract_size(notional=250, price=price, metadata=metadata) def test_market_order_fetches_latest_price_before_sizing(): session = DummySession( [ account_config_response(pos_mode="long_short_mode"), instrument_response(), ticker_response(last="25000"), leverage_response(), place_order_response(), ] ) client = OkxClient(config=sample_config(), session=session) client.place_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100) assert session.request_paths == [ "/api/v5/account/config", "/api/v5/public/instruments", "/api/v5/market/ticker", "/api/v5/account/set-leverage", "/api/v5/trade/order", ] def test_fractional_margin_sizing_keeps_decimal_precision(): session = DummySession( [ account_config_response(pos_mode="long_short_mode"), instrument_response(), ticker_response(last="1"), leverage_response(), place_order_response(), ] ) signal = TradeSignal( action="long", confidence=0.9, leverage=3, entry_price=None, take_profit_price=None, stop_loss_price=None, reason="x", ) client = OkxClient(config=sample_config(), session=session) client.place_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=0.009) assert session.last_json_body is not None assert session.last_json_body["sz"] == "27" def test_place_order_fails_when_not_hedge_mode(): session = DummySession( [ account_config_response(pos_mode="net_mode"), ] ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError): client.place_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100) assert session.request_paths == ["/api/v5/account/config"] def test_ensure_hedge_mode_rejects_malformed_config_payload(): session = DummySession([malformed_account_config_response(pos_mode=None)]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.ensure_hedge_mode() def test_place_order_validates_size_before_setting_leverage(): session = DummySession( [ account_config_response(pos_mode="long_short_mode"), large_min_size_instrument_response(), ticker_response(last="25000"), ] ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="contract size below minimum"): client.place_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100) assert session.request_paths == [ "/api/v5/account/config", "/api/v5/public/instruments", "/api/v5/market/ticker", ] def test_limit_short_order_uses_sell_and_short_pos_side(): session = DummySession( [ account_config_response(pos_mode="long_short_mode"), instrument_response(symbol="ETH-USDT-SWAP"), leverage_response(), place_order_response(), ] ) client = OkxClient(config=sample_config(), session=session) client.place_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100) order_request = session.last_json_body assert order_request is not None assert order_request["ordType"] == "limit" assert order_request["side"] == "sell" assert order_request["posSide"] == "short" assert order_request["px"] == "25000" assert session.request_bodies[2]["lever"] == "2" assert session.request_bodies[2]["mgnMode"] == "isolated" def test_flat_signal_returns_noop_without_order_submission(): session = DummySession([]) client = OkxClient(config=sample_config(), session=session) result = client.place_order(symbol="BTC-USDT-SWAP", signal=flat_signal(), margin_usdt=100) assert result.status == "noop" assert session.request_paths == [] def test_place_order_sends_computed_sz_and_ignores_tp_sl_fields(): session = DummySession( [ account_config_response(pos_mode="long_short_mode"), instrument_response(), ticker_response(last="25000"), leverage_response(), place_order_response(), ] ) client = OkxClient(config=sample_config(), session=session) client.place_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100) order_request = session.last_json_body assert order_request is not None assert order_request["sz"] == "8" assert "tpTriggerPx" not in order_request assert "slTriggerPx" not in order_request def test_okx_error_payload_raises_value_error(): session = DummySession([error_response(code="51000", msg="parameter error")]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="parameter error"): client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) def test_okx_error_payload_includes_operation_details(): session = DummySession( [ DummyResponse( { "code": "1", "msg": "All operations failed", "data": [{"sCode": "51000", "sMsg": "Parameter posSide error"}], } ) ] ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="All operations failed; 51000: Parameter posSide error"): client.submit_market_order_body( { "instId": "ETH-USDT-SWAP", "tdMode": "isolated", "side": "sell", "posSide": "short", "ordType": "market", "sz": "1.29", "clOrdId": "test", } ) def test_get_candles_rejects_non_finite_numeric_fields(): session = DummySession([candles_with_non_finite_numeric_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) def test_transport_failure_raises_stable_value_error(): session = DummySession([RuntimeError("socket closed")]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx transport error"): client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) def test_invalid_json_raises_stable_value_error(): session = DummySession([DummyResponse({}, json_error=ValueError("bad json"))]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20) def test_empty_positions_data_returns_empty_list(): session = DummySession([DummyResponse({"code": "0", "msg": "", "data": []})]) client = OkxClient(config=sample_config(), session=session) assert client.get_positions(symbol="BTC-USDT-SWAP") == [] def test_malformed_numeric_field_raises_stable_value_error(): session = DummySession( [ DummyResponse( { "code": "0", "msg": "", "data": [ { "instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "bad", "avgPx": "25000", } ], } ) ] ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_positions(symbol="BTC-USDT-SWAP") def test_non_list_okx_data_raises_stable_value_error(): session = DummySession([DummyResponse({"code": "0", "msg": "", "data": {}})]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_positions(symbol="BTC-USDT-SWAP") def test_get_instrument_meta_rejects_non_finite_numeric_fields(): session = DummySession([instrument_with_non_finite_numeric_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_instrument_meta(symbol="BTC-USDT-SWAP") def test_get_last_price_rejects_non_finite_numeric_field(): session = DummySession([ticker_with_non_finite_numeric_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_last_price(symbol="BTC-USDT-SWAP") def test_get_last_price_rejects_mismatched_symbol(): session = DummySession([DummyResponse({"code": "0", "msg": "", "data": [{"instId": "ETH-USDT-SWAP", "last": "25000"}]})]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_last_price(symbol="BTC-USDT-SWAP") def test_get_instrument_meta_rejects_mismatched_symbol(): session = DummySession([instrument_with_wrong_symbol_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_instrument_meta(symbol="BTC-USDT-SWAP") def test_get_instrument_meta_rejects_non_swap_type(): session = DummySession([instrument_with_wrong_type_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_instrument_meta(symbol="BTC-USDT-SWAP") def test_place_order_raises_when_order_id_is_missing(): session = DummySession( [ account_config_response(pos_mode="long_short_mode"), instrument_response(), ticker_response(last="25000"), leverage_response(), place_order_response_without_order_id(), ] ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.place_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100) assert session.request_paths[-1] == "/api/v5/trade/order" def test_place_order_rejects_invalid_leverage_before_okx(): session = DummySession([]) signal = TradeSignal( action="long", confidence=0.9, leverage=4, entry_price=None, take_profit_price=None, stop_loss_price=None, reason="x", ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="leverage is invalid"): client.place_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100) assert session.request_paths == [] def test_place_order_rejects_fractional_leverage_before_okx(): session = DummySession([]) signal = TradeSignal( action="long", confidence=0.9, leverage=2.5, entry_price=None, take_profit_price=None, stop_loss_price=None, reason="x", ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="leverage is invalid"): client.place_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100) assert session.request_paths == [] def test_place_order_rejects_boolean_leverage_before_okx(): session = DummySession([]) signal = TradeSignal( action="long", confidence=0.9, leverage=True, entry_price=None, take_profit_price=None, stop_loss_price=None, reason="x", ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="leverage is invalid"): client.place_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100) assert session.request_paths == [] @pytest.mark.parametrize("margin_usdt", [0, -1, float("nan"), float("inf")]) def test_place_order_rejects_invalid_margin_before_okx(margin_usdt): session = DummySession([]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="margin_usdt is invalid"): client.place_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=margin_usdt) assert session.request_paths == [] def test_place_order_rejects_boolean_margin_before_okx(): session = DummySession([]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="margin_usdt is invalid"): client.place_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=True) assert session.request_paths == [] @pytest.mark.parametrize( ("symbol", "leverage", "pos_side", "expected_message"), [ ("BTC-USDT", 2, "long", "swap instrument is required"), ("BTC-USDT-SWAP", 4, "long", "leverage is invalid"), ("BTC-USDT-SWAP", 2.5, "long", "leverage is invalid"), ("BTC-USDT-SWAP", 2, "net", "pos_side is invalid"), ], ) def test_set_leverage_validates_public_boundary_inputs(symbol, leverage, pos_side, expected_message): session = DummySession([]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match=expected_message): client.set_leverage(symbol=symbol, leverage=leverage, pos_side=pos_side) assert session.request_paths == [] def test_place_order_rejects_unknown_action_before_okx(): session = DummySession([]) signal = TradeSignal( action="hold", confidence=0.9, leverage=2, entry_price=None, take_profit_price=None, stop_loss_price=None, reason="x", ) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="action is invalid"): client.place_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100) assert session.request_paths == [] def test_get_positions_returns_normalized_positions(): session = DummySession([positions_response()]) client = OkxClient(config=sample_config(), session=session) positions = client.get_positions(symbol="BTC-USDT-SWAP") assert positions[0].symbol == "BTC-USDT-SWAP" assert positions[0].pos_side == "long" assert positions[0].size == 8.0 assert positions[0].avg_price == 25000.0 def test_get_positions_filters_zero_size_rows(): session = DummySession([positions_with_zero_size_response()]) client = OkxClient(config=sample_config(), session=session) positions = client.get_positions(symbol="BTC-USDT-SWAP") assert len(positions) == 1 assert positions[0].pos_side == "short" assert positions[0].size == 3.0 def test_get_positions_ignores_malformed_fields_on_zero_size_rows(): session = DummySession([positions_with_zero_size_malformed_avg_price_response()]) client = OkxClient(config=sample_config(), session=session) positions = client.get_positions(symbol="BTC-USDT-SWAP") assert len(positions) == 1 assert positions[0].pos_side == "short" assert positions[0].avg_price == 24900.0 def test_get_positions_rejects_non_string_inst_id_and_pos_side(): session = DummySession([positions_with_non_string_identity_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_positions(symbol="BTC-USDT-SWAP") def test_get_positions_rejects_non_finite_numeric_fields(): session = DummySession([positions_with_non_finite_numeric_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_positions(symbol="BTC-USDT-SWAP") def test_get_positions_rejects_mismatched_symbol(): session = DummySession([positions_with_wrong_symbol_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_positions(symbol="BTC-USDT-SWAP") def test_get_positions_rejects_invalid_pos_side(): session = DummySession([positions_with_invalid_pos_side_response()]) client = OkxClient(config=sample_config(), session=session) with pytest.raises(ValueError, match="okx response payload is invalid"): client.get_positions(symbol="BTC-USDT-SWAP")