test_okx_client.py 28 KB

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