test_okx_client.py 28 KB

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