test_okx_client.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  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(symbol: str = "BTC-USDT-SWAP") -> DummyResponse:
  93. return DummyResponse(
  94. {
  95. "code": "0",
  96. "msg": "",
  97. "data": [
  98. {
  99. "instId": symbol,
  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 instrument_with_wrong_symbol_response() -> DummyResponse:
  235. return DummyResponse(
  236. {
  237. "code": "0",
  238. "msg": "",
  239. "data": [
  240. {
  241. "instId": "ETH-USDT-SWAP",
  242. "instType": "SWAP",
  243. "ctVal": "0.001",
  244. "lotSz": "1",
  245. "minSz": "1",
  246. }
  247. ],
  248. }
  249. )
  250. def instrument_with_wrong_type_response() -> DummyResponse:
  251. return DummyResponse(
  252. {
  253. "code": "0",
  254. "msg": "",
  255. "data": [
  256. {
  257. "instId": "BTC-USDT-SWAP",
  258. "instType": "FUTURES",
  259. "ctVal": "0.001",
  260. "lotSz": "1",
  261. "minSz": "1",
  262. }
  263. ],
  264. }
  265. )
  266. def ticker_with_non_finite_numeric_response() -> DummyResponse:
  267. return DummyResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "last": "Infinity"}]})
  268. def positions_with_non_finite_numeric_response() -> DummyResponse:
  269. return DummyResponse(
  270. {
  271. "code": "0",
  272. "msg": "",
  273. "data": [
  274. {
  275. "instId": "BTC-USDT-SWAP",
  276. "posSide": "long",
  277. "pos": "1",
  278. "avgPx": "NaN",
  279. }
  280. ],
  281. }
  282. )
  283. def market_long_signal() -> TradeSignal:
  284. return TradeSignal(
  285. action="long",
  286. confidence=0.9,
  287. leverage=2,
  288. entry_price=None,
  289. take_profit_price=26000.0,
  290. stop_loss_price=24000.0,
  291. reason="trend",
  292. )
  293. def limit_short_signal() -> TradeSignal:
  294. return TradeSignal(
  295. action="short",
  296. confidence=0.8,
  297. leverage=2,
  298. entry_price=25000.0,
  299. take_profit_price=24000.0,
  300. stop_loss_price=25500.0,
  301. reason="mean reversion",
  302. )
  303. def flat_signal() -> TradeSignal:
  304. return TradeSignal(
  305. action="flat",
  306. confidence=0.7,
  307. leverage=2,
  308. entry_price=None,
  309. take_profit_price=None,
  310. stop_loss_price=None,
  311. reason="exit",
  312. )
  313. def test_signed_demo_request_attaches_headers():
  314. session = DummySession()
  315. client = OkxClient(config=sample_config(), session=session)
  316. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  317. request = session.last_request
  318. assert request is not None
  319. assert request.headers["x-simulated-trading"] == "1"
  320. assert request.headers["OK-ACCESS-KEY"] == "key"
  321. assert request.headers["OK-ACCESS-PASSPHRASE"] == "passphrase"
  322. timestamp = request.headers["OK-ACCESS-TIMESTAMP"]
  323. path = urlparse(request.url).path
  324. query = urlencode(request.params or {})
  325. path_with_query = path if not query else f"{path}?{query}"
  326. expected_signature = base64.b64encode(
  327. hmac.new(
  328. b"secret",
  329. f"{timestamp}{request.method}{path_with_query}".encode(),
  330. hashlib.sha256,
  331. ).digest()
  332. ).decode()
  333. assert request.headers["OK-ACCESS-SIGN"] == expected_signature
  334. def test_signed_post_request_uses_actual_serialized_body_bytes():
  335. session = DummySession(
  336. [
  337. instrument_response(symbol="ETH-USDT-SWAP"),
  338. account_config_response(pos_mode="long_short_mode"),
  339. leverage_response(),
  340. place_order_response(),
  341. ]
  342. )
  343. client = OkxClient(config=sample_config(), session=session)
  344. client.place_demo_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100)
  345. request = session.last_request
  346. assert request is not None
  347. assert request.method == "POST"
  348. assert request.body is not None
  349. timestamp = request.headers["OK-ACCESS-TIMESTAMP"]
  350. path = urlparse(request.url).path
  351. expected_signature = base64.b64encode(
  352. hmac.new(
  353. b"secret",
  354. f"{timestamp}{request.method}{path}{request.body}".encode(),
  355. hashlib.sha256,
  356. ).digest()
  357. ).decode()
  358. assert request.headers["OK-ACCESS-SIGN"] == expected_signature
  359. def test_get_candles_returns_chronological_ascending_order():
  360. session = DummySession([descending_candles_response()])
  361. client = OkxClient(config=sample_config(), session=session)
  362. candles = client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  363. assert [candle.ts for candle in candles] == [1710000000000, 1710000001000]
  364. def test_build_contract_size_rounds_down_to_lot_size():
  365. metadata = InstrumentMeta(ct_val=0.01, lot_sz=0.1, min_sz=0.1)
  366. assert build_contract_size(notional=251, price=25_000, metadata=metadata) == 1.0
  367. def test_build_contract_size_fails_below_min_size():
  368. metadata = InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=5)
  369. with pytest.raises(ValueError):
  370. build_contract_size(notional=250, price=25_100, metadata=metadata)
  371. @pytest.mark.parametrize(
  372. ("price", "metadata"),
  373. [
  374. (0, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  375. (-1, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  376. (25_000, InstrumentMeta(ct_val=0, lot_sz=1, min_sz=1)),
  377. (25_000, InstrumentMeta(ct_val=-0.01, lot_sz=1, min_sz=1)),
  378. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=0, min_sz=1)),
  379. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=-1, min_sz=1)),
  380. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=0)),
  381. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=-1)),
  382. ],
  383. )
  384. def test_build_contract_size_rejects_non_positive_inputs(price, metadata):
  385. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  386. build_contract_size(notional=250, price=price, metadata=metadata)
  387. @pytest.mark.parametrize(
  388. ("price", "metadata"),
  389. [
  390. (float("nan"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  391. (float("inf"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  392. (25_000, InstrumentMeta(ct_val=float("nan"), lot_sz=1, min_sz=1)),
  393. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=float("inf"), min_sz=1)),
  394. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=float("-inf"))),
  395. ],
  396. )
  397. def test_build_contract_size_rejects_non_finite_inputs(price, metadata):
  398. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  399. build_contract_size(notional=250, price=price, metadata=metadata)
  400. def test_market_order_fetches_latest_price_before_sizing():
  401. session = DummySession(
  402. [
  403. instrument_response(),
  404. ticker_response(last="25000"),
  405. account_config_response(pos_mode="long_short_mode"),
  406. leverage_response(),
  407. place_order_response(),
  408. ]
  409. )
  410. client = OkxClient(config=sample_config(), session=session)
  411. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  412. assert session.request_paths == [
  413. "/api/v5/public/instruments",
  414. "/api/v5/market/ticker",
  415. "/api/v5/account/config",
  416. "/api/v5/account/set-leverage",
  417. "/api/v5/trade/order",
  418. ]
  419. def test_place_demo_order_fails_when_not_hedge_mode():
  420. session = DummySession(
  421. [
  422. instrument_response(),
  423. ticker_response(last="25000"),
  424. account_config_response(pos_mode="net_mode"),
  425. ]
  426. )
  427. client = OkxClient(config=sample_config(), session=session)
  428. with pytest.raises(ValueError):
  429. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  430. def test_place_demo_order_validates_size_before_setting_leverage():
  431. session = DummySession(
  432. [
  433. large_min_size_instrument_response(),
  434. ticker_response(last="25000"),
  435. account_config_response(pos_mode="long_short_mode"),
  436. ]
  437. )
  438. client = OkxClient(config=sample_config(), session=session)
  439. with pytest.raises(ValueError, match="contract size below minimum"):
  440. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  441. assert session.request_paths == [
  442. "/api/v5/public/instruments",
  443. "/api/v5/market/ticker",
  444. "/api/v5/account/config",
  445. ]
  446. def test_limit_short_order_uses_sell_and_short_pos_side():
  447. session = DummySession(
  448. [
  449. instrument_response(symbol="ETH-USDT-SWAP"),
  450. account_config_response(pos_mode="long_short_mode"),
  451. leverage_response(),
  452. place_order_response(),
  453. ]
  454. )
  455. client = OkxClient(config=sample_config(), session=session)
  456. client.place_demo_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100)
  457. order_request = session.last_json_body
  458. assert order_request is not None
  459. assert order_request["ordType"] == "limit"
  460. assert order_request["side"] == "sell"
  461. assert order_request["posSide"] == "short"
  462. assert order_request["px"] == "25000"
  463. assert session.request_bodies[2]["lever"] == "2"
  464. assert session.request_bodies[2]["mgnMode"] == "isolated"
  465. def test_flat_signal_returns_noop_without_order_submission():
  466. session = DummySession([])
  467. client = OkxClient(config=sample_config(), session=session)
  468. result = client.place_demo_order(symbol="BTC-USDT-SWAP", signal=flat_signal(), margin_usdt=100)
  469. assert result.status == "noop"
  470. assert session.request_paths == []
  471. def test_place_demo_order_sends_computed_sz_and_ignores_tp_sl_fields():
  472. session = DummySession(
  473. [
  474. instrument_response(),
  475. ticker_response(last="25000"),
  476. account_config_response(pos_mode="long_short_mode"),
  477. leverage_response(),
  478. place_order_response(),
  479. ]
  480. )
  481. client = OkxClient(config=sample_config(), session=session)
  482. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  483. order_request = session.last_json_body
  484. assert order_request is not None
  485. assert order_request["sz"] == "8"
  486. assert "tpTriggerPx" not in order_request
  487. assert "slTriggerPx" not in order_request
  488. def test_okx_error_payload_raises_value_error():
  489. session = DummySession([error_response(code="51000", msg="parameter error")])
  490. client = OkxClient(config=sample_config(), session=session)
  491. with pytest.raises(ValueError):
  492. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  493. def test_get_candles_rejects_non_finite_numeric_fields():
  494. session = DummySession([candles_with_non_finite_numeric_response()])
  495. client = OkxClient(config=sample_config(), session=session)
  496. with pytest.raises(ValueError, match="okx response payload is invalid"):
  497. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  498. def test_transport_failure_raises_stable_value_error():
  499. session = DummySession([RuntimeError("socket closed")])
  500. client = OkxClient(config=sample_config(), session=session)
  501. with pytest.raises(ValueError, match="okx transport error"):
  502. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  503. def test_invalid_json_raises_stable_value_error():
  504. session = DummySession([DummyResponse({}, json_error=ValueError("bad json"))])
  505. client = OkxClient(config=sample_config(), session=session)
  506. with pytest.raises(ValueError, match="okx response payload is invalid"):
  507. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  508. def test_empty_positions_data_returns_empty_list():
  509. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": []})])
  510. client = OkxClient(config=sample_config(), session=session)
  511. assert client.get_positions(symbol="BTC-USDT-SWAP") == []
  512. def test_malformed_numeric_field_raises_stable_value_error():
  513. session = DummySession(
  514. [
  515. DummyResponse(
  516. {
  517. "code": "0",
  518. "msg": "",
  519. "data": [
  520. {
  521. "instId": "BTC-USDT-SWAP",
  522. "posSide": "long",
  523. "pos": "bad",
  524. "avgPx": "25000",
  525. }
  526. ],
  527. }
  528. )
  529. ]
  530. )
  531. client = OkxClient(config=sample_config(), session=session)
  532. with pytest.raises(ValueError, match="okx response payload is invalid"):
  533. client.get_positions(symbol="BTC-USDT-SWAP")
  534. def test_non_list_okx_data_raises_stable_value_error():
  535. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": {}})])
  536. client = OkxClient(config=sample_config(), session=session)
  537. with pytest.raises(ValueError, match="okx response payload is invalid"):
  538. client.get_positions(symbol="BTC-USDT-SWAP")
  539. def test_get_instrument_meta_rejects_non_finite_numeric_fields():
  540. session = DummySession([instrument_with_non_finite_numeric_response()])
  541. client = OkxClient(config=sample_config(), session=session)
  542. with pytest.raises(ValueError, match="okx response payload is invalid"):
  543. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  544. def test_get_last_price_rejects_non_finite_numeric_field():
  545. session = DummySession([ticker_with_non_finite_numeric_response()])
  546. client = OkxClient(config=sample_config(), session=session)
  547. with pytest.raises(ValueError, match="okx response payload is invalid"):
  548. client.get_last_price(symbol="BTC-USDT-SWAP")
  549. def test_get_instrument_meta_rejects_mismatched_symbol():
  550. session = DummySession([instrument_with_wrong_symbol_response()])
  551. client = OkxClient(config=sample_config(), session=session)
  552. with pytest.raises(ValueError, match="okx response payload is invalid"):
  553. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  554. def test_get_instrument_meta_rejects_non_swap_type():
  555. session = DummySession([instrument_with_wrong_type_response()])
  556. client = OkxClient(config=sample_config(), session=session)
  557. with pytest.raises(ValueError, match="okx response payload is invalid"):
  558. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  559. def test_place_demo_order_raises_when_order_id_is_missing():
  560. session = DummySession(
  561. [
  562. instrument_response(),
  563. ticker_response(last="25000"),
  564. account_config_response(pos_mode="long_short_mode"),
  565. leverage_response(),
  566. place_order_response_without_order_id(),
  567. ]
  568. )
  569. client = OkxClient(config=sample_config(), session=session)
  570. with pytest.raises(ValueError, match="okx response payload is invalid"):
  571. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  572. def test_place_demo_order_rejects_invalid_leverage_before_okx():
  573. session = DummySession([])
  574. signal = TradeSignal(
  575. action="long",
  576. confidence=0.9,
  577. leverage=4,
  578. entry_price=None,
  579. take_profit_price=None,
  580. stop_loss_price=None,
  581. reason="x",
  582. )
  583. client = OkxClient(config=sample_config(), session=session)
  584. with pytest.raises(ValueError, match="leverage is invalid"):
  585. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  586. assert session.request_paths == []
  587. @pytest.mark.parametrize(
  588. ("symbol", "leverage", "pos_side", "expected_message"),
  589. [
  590. ("BTC-USDT", 2, "long", "swap instrument is required"),
  591. ("BTC-USDT-SWAP", 4, "long", "leverage is invalid"),
  592. ("BTC-USDT-SWAP", 2, "net", "pos_side is invalid"),
  593. ],
  594. )
  595. def test_set_leverage_validates_public_boundary_inputs(symbol, leverage, pos_side, expected_message):
  596. session = DummySession([])
  597. client = OkxClient(config=sample_config(), session=session)
  598. with pytest.raises(ValueError, match=expected_message):
  599. client.set_leverage(symbol=symbol, leverage=leverage, pos_side=pos_side)
  600. assert session.request_paths == []
  601. def test_place_demo_order_rejects_unknown_action_before_okx():
  602. session = DummySession([])
  603. signal = TradeSignal(
  604. action="hold",
  605. confidence=0.9,
  606. leverage=2,
  607. entry_price=None,
  608. take_profit_price=None,
  609. stop_loss_price=None,
  610. reason="x",
  611. )
  612. client = OkxClient(config=sample_config(), session=session)
  613. with pytest.raises(ValueError, match="action is invalid"):
  614. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  615. assert session.request_paths == []
  616. def test_get_positions_returns_normalized_positions():
  617. session = DummySession([positions_response()])
  618. client = OkxClient(config=sample_config(), session=session)
  619. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  620. assert positions[0].symbol == "BTC-USDT-SWAP"
  621. assert positions[0].pos_side == "long"
  622. assert positions[0].size == 8.0
  623. assert positions[0].avg_price == 25000.0
  624. def test_get_positions_filters_zero_size_rows():
  625. session = DummySession([positions_with_zero_size_response()])
  626. client = OkxClient(config=sample_config(), session=session)
  627. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  628. assert len(positions) == 1
  629. assert positions[0].pos_side == "short"
  630. assert positions[0].size == 3.0
  631. def test_get_positions_ignores_malformed_fields_on_zero_size_rows():
  632. session = DummySession([positions_with_zero_size_malformed_avg_price_response()])
  633. client = OkxClient(config=sample_config(), session=session)
  634. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  635. assert len(positions) == 1
  636. assert positions[0].pos_side == "short"
  637. assert positions[0].avg_price == 24900.0
  638. def test_get_positions_rejects_non_string_inst_id_and_pos_side():
  639. session = DummySession([positions_with_non_string_identity_response()])
  640. client = OkxClient(config=sample_config(), session=session)
  641. with pytest.raises(ValueError, match="okx response payload is invalid"):
  642. client.get_positions(symbol="BTC-USDT-SWAP")
  643. def test_get_positions_rejects_non_finite_numeric_fields():
  644. session = DummySession([positions_with_non_finite_numeric_response()])
  645. client = OkxClient(config=sample_config(), session=session)
  646. with pytest.raises(ValueError, match="okx response payload is invalid"):
  647. client.get_positions(symbol="BTC-USDT-SWAP")