test_okx_client.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. import base64
  2. import hashlib
  3. import hmac
  4. import json as json_module
  5. from dataclasses import dataclass
  6. from urllib.parse import urlencode, urlparse
  7. import pytest
  8. from okx_codex_trader.config import Config
  9. from okx_codex_trader.models import InstrumentMeta, TradeSignal
  10. from okx_codex_trader.okx_client import OkxClient, build_contract_size
  11. @dataclass
  12. class DummyResponse:
  13. payload: dict[str, object]
  14. status_code: int = 200
  15. json_error: Exception | None = None
  16. def json(self) -> dict[str, object]:
  17. if self.json_error is not None:
  18. raise self.json_error
  19. return self.payload
  20. @dataclass
  21. class RecordedRequest:
  22. method: str
  23. url: str
  24. headers: dict[str, str]
  25. params: dict[str, object] | None
  26. json_body: dict[str, object] | None
  27. body: str | None
  28. class DummySession:
  29. def __init__(self, responses: list[DummyResponse | Exception] | None = None):
  30. self._responses = list(responses or [])
  31. self.last_request: RecordedRequest | None = None
  32. self.request_paths: list[str] = []
  33. self.request_bodies: list[dict[str, object] | None] = []
  34. @property
  35. def last_json_body(self) -> dict[str, object] | None:
  36. return self.last_request.json_body if self.last_request else None
  37. @property
  38. def last_body(self) -> str | None:
  39. return self.last_request.body if self.last_request else None
  40. def request(
  41. self,
  42. method: str,
  43. url: str,
  44. *,
  45. headers: dict[str, str] | None = None,
  46. params: dict[str, object] | None = None,
  47. json: dict[str, object] | None = None,
  48. data: str | None = None,
  49. ) -> DummyResponse:
  50. parsed_json = json
  51. if parsed_json is None and data is not None:
  52. parsed_json = json_module.loads(data)
  53. self.last_request = RecordedRequest(
  54. method=method,
  55. url=url,
  56. headers=headers or {},
  57. params=params,
  58. json_body=parsed_json,
  59. body=data,
  60. )
  61. self.request_paths.append(urlparse(url).path)
  62. self.request_bodies.append(parsed_json)
  63. if self._responses:
  64. response = self._responses.pop(0)
  65. if isinstance(response, Exception):
  66. raise response
  67. return response
  68. return candles_response()
  69. def sample_config() -> Config:
  70. return Config(api_key="key", api_secret="secret", api_passphrase="passphrase")
  71. def candles_response() -> DummyResponse:
  72. return DummyResponse(
  73. {
  74. "code": "0",
  75. "msg": "",
  76. "data": [
  77. ["1710000000000", "25000", "25100", "24900", "25050", "100", "1000", "1000", "1"],
  78. ],
  79. }
  80. )
  81. def descending_candles_response() -> DummyResponse:
  82. return DummyResponse(
  83. {
  84. "code": "0",
  85. "msg": "",
  86. "data": [
  87. ["1710000001000", "25100", "25200", "25000", "25150", "110", "1100", "1100", "1"],
  88. ["1710000000000", "25000", "25100", "24900", "25050", "100", "1000", "1000", "1"],
  89. ],
  90. }
  91. )
  92. def instrument_response() -> DummyResponse:
  93. return DummyResponse(
  94. {
  95. "code": "0",
  96. "msg": "",
  97. "data": [
  98. {
  99. "instId": "BTC-USDT-SWAP",
  100. "instType": "SWAP",
  101. "ctVal": "0.001",
  102. "lotSz": "1",
  103. "minSz": "1",
  104. }
  105. ],
  106. }
  107. )
  108. def large_min_size_instrument_response() -> DummyResponse:
  109. return DummyResponse(
  110. {
  111. "code": "0",
  112. "msg": "",
  113. "data": [
  114. {
  115. "instId": "BTC-USDT-SWAP",
  116. "instType": "SWAP",
  117. "ctVal": "0.01",
  118. "lotSz": "1",
  119. "minSz": "100",
  120. }
  121. ],
  122. }
  123. )
  124. def ticker_response(last: str) -> DummyResponse:
  125. return DummyResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "last": last}]})
  126. def account_config_response(pos_mode: str) -> DummyResponse:
  127. return DummyResponse({"code": "0", "msg": "", "data": [{"posMode": pos_mode}]})
  128. def leverage_response() -> DummyResponse:
  129. return DummyResponse({"code": "0", "msg": "", "data": [{"lever": "2"}]})
  130. def place_order_response() -> DummyResponse:
  131. return DummyResponse({"code": "0", "msg": "", "data": [{"ordId": "123"}]})
  132. def place_order_response_without_order_id() -> DummyResponse:
  133. return DummyResponse({"code": "0", "msg": "", "data": [{}]})
  134. def error_response(code: str, msg: str) -> DummyResponse:
  135. return DummyResponse({"code": code, "msg": msg, "data": []})
  136. def positions_response() -> DummyResponse:
  137. return DummyResponse(
  138. {
  139. "code": "0",
  140. "msg": "",
  141. "data": [
  142. {
  143. "instId": "BTC-USDT-SWAP",
  144. "posSide": "long",
  145. "pos": "8",
  146. "avgPx": "25000",
  147. }
  148. ],
  149. }
  150. )
  151. def positions_with_zero_size_response() -> DummyResponse:
  152. return DummyResponse(
  153. {
  154. "code": "0",
  155. "msg": "",
  156. "data": [
  157. {
  158. "instId": "BTC-USDT-SWAP",
  159. "posSide": "long",
  160. "pos": "0",
  161. "avgPx": "25000",
  162. },
  163. {
  164. "instId": "BTC-USDT-SWAP",
  165. "posSide": "short",
  166. "pos": "3",
  167. "avgPx": "24900",
  168. },
  169. ],
  170. }
  171. )
  172. def positions_with_zero_size_malformed_avg_price_response() -> DummyResponse:
  173. return DummyResponse(
  174. {
  175. "code": "0",
  176. "msg": "",
  177. "data": [
  178. {
  179. "instId": "BTC-USDT-SWAP",
  180. "posSide": "long",
  181. "pos": "0",
  182. "avgPx": "bad",
  183. },
  184. {
  185. "instId": "BTC-USDT-SWAP",
  186. "posSide": "short",
  187. "pos": "3",
  188. "avgPx": "24900",
  189. },
  190. ],
  191. }
  192. )
  193. def positions_with_non_string_identity_response() -> DummyResponse:
  194. return DummyResponse(
  195. {
  196. "code": "0",
  197. "msg": "",
  198. "data": [
  199. {
  200. "instId": None,
  201. "posSide": ["long"],
  202. "pos": "3",
  203. "avgPx": "24900",
  204. }
  205. ],
  206. }
  207. )
  208. def candles_with_non_finite_numeric_response() -> DummyResponse:
  209. return DummyResponse(
  210. {
  211. "code": "0",
  212. "msg": "",
  213. "data": [
  214. ["1710000000000", "NaN", "25100", "24900", "25050", "100", "1000", "1000", "1"],
  215. ],
  216. }
  217. )
  218. def instrument_with_non_finite_numeric_response() -> DummyResponse:
  219. return DummyResponse(
  220. {
  221. "code": "0",
  222. "msg": "",
  223. "data": [
  224. {
  225. "instId": "BTC-USDT-SWAP",
  226. "instType": "SWAP",
  227. "ctVal": "NaN",
  228. "lotSz": "1",
  229. "minSz": "1",
  230. }
  231. ],
  232. }
  233. )
  234. def ticker_with_non_finite_numeric_response() -> DummyResponse:
  235. return DummyResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "last": "Infinity"}]})
  236. def positions_with_non_finite_numeric_response() -> DummyResponse:
  237. return DummyResponse(
  238. {
  239. "code": "0",
  240. "msg": "",
  241. "data": [
  242. {
  243. "instId": "BTC-USDT-SWAP",
  244. "posSide": "long",
  245. "pos": "1",
  246. "avgPx": "NaN",
  247. }
  248. ],
  249. }
  250. )
  251. def market_long_signal() -> TradeSignal:
  252. return TradeSignal(
  253. action="long",
  254. confidence=0.9,
  255. leverage=2,
  256. entry_price=None,
  257. take_profit_price=26000.0,
  258. stop_loss_price=24000.0,
  259. reason="trend",
  260. )
  261. def limit_short_signal() -> TradeSignal:
  262. return TradeSignal(
  263. action="short",
  264. confidence=0.8,
  265. leverage=2,
  266. entry_price=25000.0,
  267. take_profit_price=24000.0,
  268. stop_loss_price=25500.0,
  269. reason="mean reversion",
  270. )
  271. def flat_signal() -> TradeSignal:
  272. return TradeSignal(
  273. action="flat",
  274. confidence=0.7,
  275. leverage=2,
  276. entry_price=None,
  277. take_profit_price=None,
  278. stop_loss_price=None,
  279. reason="exit",
  280. )
  281. def test_signed_demo_request_attaches_headers():
  282. session = DummySession()
  283. client = OkxClient(config=sample_config(), session=session)
  284. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  285. request = session.last_request
  286. assert request is not None
  287. assert request.headers["x-simulated-trading"] == "1"
  288. assert request.headers["OK-ACCESS-KEY"] == "key"
  289. assert request.headers["OK-ACCESS-PASSPHRASE"] == "passphrase"
  290. timestamp = request.headers["OK-ACCESS-TIMESTAMP"]
  291. path = urlparse(request.url).path
  292. query = urlencode(request.params or {})
  293. path_with_query = path if not query else f"{path}?{query}"
  294. expected_signature = base64.b64encode(
  295. hmac.new(
  296. b"secret",
  297. f"{timestamp}{request.method}{path_with_query}".encode(),
  298. hashlib.sha256,
  299. ).digest()
  300. ).decode()
  301. assert request.headers["OK-ACCESS-SIGN"] == expected_signature
  302. def test_signed_post_request_uses_actual_serialized_body_bytes():
  303. session = DummySession(
  304. [
  305. instrument_response(),
  306. account_config_response(pos_mode="long_short_mode"),
  307. leverage_response(),
  308. place_order_response(),
  309. ]
  310. )
  311. client = OkxClient(config=sample_config(), session=session)
  312. client.place_demo_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100)
  313. request = session.last_request
  314. assert request is not None
  315. assert request.method == "POST"
  316. assert request.body is not None
  317. timestamp = request.headers["OK-ACCESS-TIMESTAMP"]
  318. path = urlparse(request.url).path
  319. expected_signature = base64.b64encode(
  320. hmac.new(
  321. b"secret",
  322. f"{timestamp}{request.method}{path}{request.body}".encode(),
  323. hashlib.sha256,
  324. ).digest()
  325. ).decode()
  326. assert request.headers["OK-ACCESS-SIGN"] == expected_signature
  327. def test_get_candles_returns_chronological_ascending_order():
  328. session = DummySession([descending_candles_response()])
  329. client = OkxClient(config=sample_config(), session=session)
  330. candles = client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  331. assert [candle.ts for candle in candles] == [1710000000000, 1710000001000]
  332. def test_build_contract_size_rounds_down_to_lot_size():
  333. metadata = InstrumentMeta(ct_val=0.01, lot_sz=0.1, min_sz=0.1)
  334. assert build_contract_size(notional=251, price=25_000, metadata=metadata) == 1.0
  335. def test_build_contract_size_fails_below_min_size():
  336. metadata = InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=5)
  337. with pytest.raises(ValueError):
  338. build_contract_size(notional=250, price=25_100, metadata=metadata)
  339. @pytest.mark.parametrize(
  340. ("price", "metadata"),
  341. [
  342. (0, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  343. (-1, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  344. (25_000, InstrumentMeta(ct_val=0, lot_sz=1, min_sz=1)),
  345. (25_000, InstrumentMeta(ct_val=-0.01, lot_sz=1, min_sz=1)),
  346. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=0, min_sz=1)),
  347. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=-1, min_sz=1)),
  348. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=0)),
  349. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=-1)),
  350. ],
  351. )
  352. def test_build_contract_size_rejects_non_positive_inputs(price, metadata):
  353. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  354. build_contract_size(notional=250, price=price, metadata=metadata)
  355. @pytest.mark.parametrize(
  356. ("price", "metadata"),
  357. [
  358. (float("nan"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  359. (float("inf"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  360. (25_000, InstrumentMeta(ct_val=float("nan"), lot_sz=1, min_sz=1)),
  361. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=float("inf"), min_sz=1)),
  362. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=float("-inf"))),
  363. ],
  364. )
  365. def test_build_contract_size_rejects_non_finite_inputs(price, metadata):
  366. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  367. build_contract_size(notional=250, price=price, metadata=metadata)
  368. def test_market_order_fetches_latest_price_before_sizing():
  369. session = DummySession(
  370. [
  371. instrument_response(),
  372. ticker_response(last="25000"),
  373. account_config_response(pos_mode="long_short_mode"),
  374. leverage_response(),
  375. place_order_response(),
  376. ]
  377. )
  378. client = OkxClient(config=sample_config(), session=session)
  379. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  380. assert session.request_paths == [
  381. "/api/v5/public/instruments",
  382. "/api/v5/market/ticker",
  383. "/api/v5/account/config",
  384. "/api/v5/account/set-leverage",
  385. "/api/v5/trade/order",
  386. ]
  387. def test_place_demo_order_fails_when_not_hedge_mode():
  388. session = DummySession(
  389. [
  390. instrument_response(),
  391. ticker_response(last="25000"),
  392. account_config_response(pos_mode="net_mode"),
  393. ]
  394. )
  395. client = OkxClient(config=sample_config(), session=session)
  396. with pytest.raises(ValueError):
  397. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  398. def test_place_demo_order_validates_size_before_setting_leverage():
  399. session = DummySession(
  400. [
  401. large_min_size_instrument_response(),
  402. ticker_response(last="25000"),
  403. account_config_response(pos_mode="long_short_mode"),
  404. ]
  405. )
  406. client = OkxClient(config=sample_config(), session=session)
  407. with pytest.raises(ValueError, match="contract size below minimum"):
  408. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  409. assert session.request_paths == [
  410. "/api/v5/public/instruments",
  411. "/api/v5/market/ticker",
  412. "/api/v5/account/config",
  413. ]
  414. def test_limit_short_order_uses_sell_and_short_pos_side():
  415. session = DummySession(
  416. [
  417. instrument_response(),
  418. account_config_response(pos_mode="long_short_mode"),
  419. leverage_response(),
  420. place_order_response(),
  421. ]
  422. )
  423. client = OkxClient(config=sample_config(), session=session)
  424. client.place_demo_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100)
  425. order_request = session.last_json_body
  426. assert order_request is not None
  427. assert order_request["ordType"] == "limit"
  428. assert order_request["side"] == "sell"
  429. assert order_request["posSide"] == "short"
  430. assert order_request["px"] == "25000"
  431. assert session.request_bodies[2]["lever"] == "2"
  432. assert session.request_bodies[2]["mgnMode"] == "isolated"
  433. def test_flat_signal_returns_noop_without_order_submission():
  434. session = DummySession([])
  435. client = OkxClient(config=sample_config(), session=session)
  436. result = client.place_demo_order(symbol="BTC-USDT-SWAP", signal=flat_signal(), margin_usdt=100)
  437. assert result.status == "noop"
  438. assert session.request_paths == []
  439. def test_place_demo_order_sends_computed_sz_and_ignores_tp_sl_fields():
  440. session = DummySession(
  441. [
  442. instrument_response(),
  443. ticker_response(last="25000"),
  444. account_config_response(pos_mode="long_short_mode"),
  445. leverage_response(),
  446. place_order_response(),
  447. ]
  448. )
  449. client = OkxClient(config=sample_config(), session=session)
  450. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  451. order_request = session.last_json_body
  452. assert order_request is not None
  453. assert order_request["sz"] == "8"
  454. assert "tpTriggerPx" not in order_request
  455. assert "slTriggerPx" not in order_request
  456. def test_okx_error_payload_raises_value_error():
  457. session = DummySession([error_response(code="51000", msg="parameter error")])
  458. client = OkxClient(config=sample_config(), session=session)
  459. with pytest.raises(ValueError):
  460. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  461. def test_get_candles_rejects_non_finite_numeric_fields():
  462. session = DummySession([candles_with_non_finite_numeric_response()])
  463. client = OkxClient(config=sample_config(), session=session)
  464. with pytest.raises(ValueError, match="okx response payload is invalid"):
  465. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  466. def test_transport_failure_raises_stable_value_error():
  467. session = DummySession([RuntimeError("socket closed")])
  468. client = OkxClient(config=sample_config(), session=session)
  469. with pytest.raises(ValueError, match="okx transport error"):
  470. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  471. def test_invalid_json_raises_stable_value_error():
  472. session = DummySession([DummyResponse({}, json_error=ValueError("bad json"))])
  473. client = OkxClient(config=sample_config(), session=session)
  474. with pytest.raises(ValueError, match="okx response payload is invalid"):
  475. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  476. def test_empty_positions_data_returns_empty_list():
  477. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": []})])
  478. client = OkxClient(config=sample_config(), session=session)
  479. assert client.get_positions(symbol="BTC-USDT-SWAP") == []
  480. def test_malformed_numeric_field_raises_stable_value_error():
  481. session = DummySession(
  482. [
  483. DummyResponse(
  484. {
  485. "code": "0",
  486. "msg": "",
  487. "data": [
  488. {
  489. "instId": "BTC-USDT-SWAP",
  490. "posSide": "long",
  491. "pos": "bad",
  492. "avgPx": "25000",
  493. }
  494. ],
  495. }
  496. )
  497. ]
  498. )
  499. client = OkxClient(config=sample_config(), session=session)
  500. with pytest.raises(ValueError, match="okx response payload is invalid"):
  501. client.get_positions(symbol="BTC-USDT-SWAP")
  502. def test_non_list_okx_data_raises_stable_value_error():
  503. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": {}})])
  504. client = OkxClient(config=sample_config(), session=session)
  505. with pytest.raises(ValueError, match="okx response payload is invalid"):
  506. client.get_positions(symbol="BTC-USDT-SWAP")
  507. def test_get_instrument_meta_rejects_non_finite_numeric_fields():
  508. session = DummySession([instrument_with_non_finite_numeric_response()])
  509. client = OkxClient(config=sample_config(), session=session)
  510. with pytest.raises(ValueError, match="okx response payload is invalid"):
  511. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  512. def test_get_last_price_rejects_non_finite_numeric_field():
  513. session = DummySession([ticker_with_non_finite_numeric_response()])
  514. client = OkxClient(config=sample_config(), session=session)
  515. with pytest.raises(ValueError, match="okx response payload is invalid"):
  516. client.get_last_price(symbol="BTC-USDT-SWAP")
  517. def test_place_demo_order_raises_when_order_id_is_missing():
  518. session = DummySession(
  519. [
  520. instrument_response(),
  521. ticker_response(last="25000"),
  522. account_config_response(pos_mode="long_short_mode"),
  523. leverage_response(),
  524. place_order_response_without_order_id(),
  525. ]
  526. )
  527. client = OkxClient(config=sample_config(), session=session)
  528. with pytest.raises(ValueError, match="okx response payload is invalid"):
  529. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  530. def test_place_demo_order_rejects_invalid_leverage_before_okx():
  531. session = DummySession([])
  532. signal = TradeSignal(
  533. action="long",
  534. confidence=0.9,
  535. leverage=4,
  536. entry_price=None,
  537. take_profit_price=None,
  538. stop_loss_price=None,
  539. reason="x",
  540. )
  541. client = OkxClient(config=sample_config(), session=session)
  542. with pytest.raises(ValueError, match="leverage is invalid"):
  543. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  544. assert session.request_paths == []
  545. def test_place_demo_order_rejects_unknown_action_before_okx():
  546. session = DummySession([])
  547. signal = TradeSignal(
  548. action="hold",
  549. confidence=0.9,
  550. leverage=2,
  551. entry_price=None,
  552. take_profit_price=None,
  553. stop_loss_price=None,
  554. reason="x",
  555. )
  556. client = OkxClient(config=sample_config(), session=session)
  557. with pytest.raises(ValueError, match="action is invalid"):
  558. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  559. assert session.request_paths == []
  560. def test_get_positions_returns_normalized_positions():
  561. session = DummySession([positions_response()])
  562. client = OkxClient(config=sample_config(), session=session)
  563. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  564. assert positions[0].symbol == "BTC-USDT-SWAP"
  565. assert positions[0].pos_side == "long"
  566. assert positions[0].size == 8.0
  567. assert positions[0].avg_price == 25000.0
  568. def test_get_positions_filters_zero_size_rows():
  569. session = DummySession([positions_with_zero_size_response()])
  570. client = OkxClient(config=sample_config(), session=session)
  571. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  572. assert len(positions) == 1
  573. assert positions[0].pos_side == "short"
  574. assert positions[0].size == 3.0
  575. def test_get_positions_ignores_malformed_fields_on_zero_size_rows():
  576. session = DummySession([positions_with_zero_size_malformed_avg_price_response()])
  577. client = OkxClient(config=sample_config(), session=session)
  578. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  579. assert len(positions) == 1
  580. assert positions[0].pos_side == "short"
  581. assert positions[0].avg_price == 24900.0
  582. def test_get_positions_rejects_non_string_inst_id_and_pos_side():
  583. session = DummySession([positions_with_non_string_identity_response()])
  584. client = OkxClient(config=sample_config(), session=session)
  585. with pytest.raises(ValueError, match="okx response payload is invalid"):
  586. client.get_positions(symbol="BTC-USDT-SWAP")
  587. def test_get_positions_rejects_non_finite_numeric_fields():
  588. session = DummySession([positions_with_non_finite_numeric_response()])
  589. client = OkxClient(config=sample_config(), session=session)
  590. with pytest.raises(ValueError, match="okx response payload is invalid"):
  591. client.get_positions(symbol="BTC-USDT-SWAP")