test_okx_client.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  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. account_config_response(pos_mode="long_short_mode"),
  375. instrument_response(symbol="ETH-USDT-SWAP"),
  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. def test_build_contract_size_rejects_boolean_inputs():
  414. metadata = InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)
  415. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  416. build_contract_size(notional=True, price=25_000, metadata=metadata)
  417. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  418. build_contract_size(notional=100, price=False, metadata=metadata)
  419. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  420. build_contract_size(notional=100, price=25_000, metadata=InstrumentMeta(ct_val=True, lot_sz=1, min_sz=1))
  421. @pytest.mark.parametrize(
  422. ("price", "metadata"),
  423. [
  424. (0, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  425. (-1, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  426. (25_000, InstrumentMeta(ct_val=0, lot_sz=1, min_sz=1)),
  427. (25_000, InstrumentMeta(ct_val=-0.01, lot_sz=1, min_sz=1)),
  428. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=0, min_sz=1)),
  429. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=-1, min_sz=1)),
  430. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=0)),
  431. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=-1)),
  432. ],
  433. )
  434. def test_build_contract_size_rejects_non_positive_inputs(price, metadata):
  435. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  436. build_contract_size(notional=250, price=price, metadata=metadata)
  437. @pytest.mark.parametrize(
  438. ("price", "metadata"),
  439. [
  440. (float("nan"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  441. (float("inf"), InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=1)),
  442. (25_000, InstrumentMeta(ct_val=float("nan"), lot_sz=1, min_sz=1)),
  443. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=float("inf"), min_sz=1)),
  444. (25_000, InstrumentMeta(ct_val=0.01, lot_sz=1, min_sz=float("-inf"))),
  445. ],
  446. )
  447. def test_build_contract_size_rejects_non_finite_inputs(price, metadata):
  448. with pytest.raises(ValueError, match="contract sizing inputs are invalid"):
  449. build_contract_size(notional=250, price=price, metadata=metadata)
  450. def test_market_order_fetches_latest_price_before_sizing():
  451. session = DummySession(
  452. [
  453. account_config_response(pos_mode="long_short_mode"),
  454. instrument_response(),
  455. ticker_response(last="25000"),
  456. leverage_response(),
  457. place_order_response(),
  458. ]
  459. )
  460. client = OkxClient(config=sample_config(), session=session)
  461. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  462. assert session.request_paths == [
  463. "/api/v5/account/config",
  464. "/api/v5/public/instruments",
  465. "/api/v5/market/ticker",
  466. "/api/v5/account/set-leverage",
  467. "/api/v5/trade/order",
  468. ]
  469. def test_place_demo_order_fails_when_not_hedge_mode():
  470. session = DummySession(
  471. [
  472. account_config_response(pos_mode="net_mode"),
  473. ]
  474. )
  475. client = OkxClient(config=sample_config(), session=session)
  476. with pytest.raises(ValueError):
  477. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  478. assert session.request_paths == ["/api/v5/account/config"]
  479. def test_ensure_hedge_mode_rejects_malformed_config_payload():
  480. session = DummySession([malformed_account_config_response(pos_mode=None)])
  481. client = OkxClient(config=sample_config(), session=session)
  482. with pytest.raises(ValueError, match="okx response payload is invalid"):
  483. client.ensure_hedge_mode()
  484. def test_place_demo_order_validates_size_before_setting_leverage():
  485. session = DummySession(
  486. [
  487. account_config_response(pos_mode="long_short_mode"),
  488. large_min_size_instrument_response(),
  489. ticker_response(last="25000"),
  490. ]
  491. )
  492. client = OkxClient(config=sample_config(), session=session)
  493. with pytest.raises(ValueError, match="contract size below minimum"):
  494. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  495. assert session.request_paths == [
  496. "/api/v5/account/config",
  497. "/api/v5/public/instruments",
  498. "/api/v5/market/ticker",
  499. ]
  500. def test_limit_short_order_uses_sell_and_short_pos_side():
  501. session = DummySession(
  502. [
  503. account_config_response(pos_mode="long_short_mode"),
  504. instrument_response(symbol="ETH-USDT-SWAP"),
  505. leverage_response(),
  506. place_order_response(),
  507. ]
  508. )
  509. client = OkxClient(config=sample_config(), session=session)
  510. client.place_demo_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100)
  511. order_request = session.last_json_body
  512. assert order_request is not None
  513. assert order_request["ordType"] == "limit"
  514. assert order_request["side"] == "sell"
  515. assert order_request["posSide"] == "short"
  516. assert order_request["px"] == "25000"
  517. assert session.request_bodies[2]["lever"] == "2"
  518. assert session.request_bodies[2]["mgnMode"] == "isolated"
  519. def test_flat_signal_returns_noop_without_order_submission():
  520. session = DummySession([])
  521. client = OkxClient(config=sample_config(), session=session)
  522. result = client.place_demo_order(symbol="BTC-USDT-SWAP", signal=flat_signal(), margin_usdt=100)
  523. assert result.status == "noop"
  524. assert session.request_paths == []
  525. def test_place_demo_order_sends_computed_sz_and_ignores_tp_sl_fields():
  526. session = DummySession(
  527. [
  528. account_config_response(pos_mode="long_short_mode"),
  529. instrument_response(),
  530. ticker_response(last="25000"),
  531. leverage_response(),
  532. place_order_response(),
  533. ]
  534. )
  535. client = OkxClient(config=sample_config(), session=session)
  536. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  537. order_request = session.last_json_body
  538. assert order_request is not None
  539. assert order_request["sz"] == "8"
  540. assert "tpTriggerPx" not in order_request
  541. assert "slTriggerPx" not in order_request
  542. def test_okx_error_payload_raises_value_error():
  543. session = DummySession([error_response(code="51000", msg="parameter error")])
  544. client = OkxClient(config=sample_config(), session=session)
  545. with pytest.raises(ValueError):
  546. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  547. def test_get_candles_rejects_non_finite_numeric_fields():
  548. session = DummySession([candles_with_non_finite_numeric_response()])
  549. client = OkxClient(config=sample_config(), session=session)
  550. with pytest.raises(ValueError, match="okx response payload is invalid"):
  551. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  552. def test_transport_failure_raises_stable_value_error():
  553. session = DummySession([RuntimeError("socket closed")])
  554. client = OkxClient(config=sample_config(), session=session)
  555. with pytest.raises(ValueError, match="okx transport error"):
  556. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  557. def test_invalid_json_raises_stable_value_error():
  558. session = DummySession([DummyResponse({}, json_error=ValueError("bad json"))])
  559. client = OkxClient(config=sample_config(), session=session)
  560. with pytest.raises(ValueError, match="okx response payload is invalid"):
  561. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  562. def test_empty_positions_data_returns_empty_list():
  563. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": []})])
  564. client = OkxClient(config=sample_config(), session=session)
  565. assert client.get_positions(symbol="BTC-USDT-SWAP") == []
  566. def test_malformed_numeric_field_raises_stable_value_error():
  567. session = DummySession(
  568. [
  569. DummyResponse(
  570. {
  571. "code": "0",
  572. "msg": "",
  573. "data": [
  574. {
  575. "instId": "BTC-USDT-SWAP",
  576. "posSide": "long",
  577. "pos": "bad",
  578. "avgPx": "25000",
  579. }
  580. ],
  581. }
  582. )
  583. ]
  584. )
  585. client = OkxClient(config=sample_config(), session=session)
  586. with pytest.raises(ValueError, match="okx response payload is invalid"):
  587. client.get_positions(symbol="BTC-USDT-SWAP")
  588. def test_non_list_okx_data_raises_stable_value_error():
  589. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": {}})])
  590. client = OkxClient(config=sample_config(), session=session)
  591. with pytest.raises(ValueError, match="okx response payload is invalid"):
  592. client.get_positions(symbol="BTC-USDT-SWAP")
  593. def test_get_instrument_meta_rejects_non_finite_numeric_fields():
  594. session = DummySession([instrument_with_non_finite_numeric_response()])
  595. client = OkxClient(config=sample_config(), session=session)
  596. with pytest.raises(ValueError, match="okx response payload is invalid"):
  597. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  598. def test_get_last_price_rejects_non_finite_numeric_field():
  599. session = DummySession([ticker_with_non_finite_numeric_response()])
  600. client = OkxClient(config=sample_config(), session=session)
  601. with pytest.raises(ValueError, match="okx response payload is invalid"):
  602. client.get_last_price(symbol="BTC-USDT-SWAP")
  603. def test_get_last_price_rejects_mismatched_symbol():
  604. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": [{"instId": "ETH-USDT-SWAP", "last": "25000"}]})])
  605. client = OkxClient(config=sample_config(), session=session)
  606. with pytest.raises(ValueError, match="okx response payload is invalid"):
  607. client.get_last_price(symbol="BTC-USDT-SWAP")
  608. def test_get_instrument_meta_rejects_mismatched_symbol():
  609. session = DummySession([instrument_with_wrong_symbol_response()])
  610. client = OkxClient(config=sample_config(), session=session)
  611. with pytest.raises(ValueError, match="okx response payload is invalid"):
  612. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  613. def test_get_instrument_meta_rejects_non_swap_type():
  614. session = DummySession([instrument_with_wrong_type_response()])
  615. client = OkxClient(config=sample_config(), session=session)
  616. with pytest.raises(ValueError, match="okx response payload is invalid"):
  617. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  618. def test_place_demo_order_raises_when_order_id_is_missing():
  619. session = DummySession(
  620. [
  621. instrument_response(),
  622. ticker_response(last="25000"),
  623. account_config_response(pos_mode="long_short_mode"),
  624. leverage_response(),
  625. place_order_response_without_order_id(),
  626. ]
  627. )
  628. client = OkxClient(config=sample_config(), session=session)
  629. with pytest.raises(ValueError, match="okx response payload is invalid"):
  630. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  631. def test_place_demo_order_rejects_invalid_leverage_before_okx():
  632. session = DummySession([])
  633. signal = TradeSignal(
  634. action="long",
  635. confidence=0.9,
  636. leverage=4,
  637. entry_price=None,
  638. take_profit_price=None,
  639. stop_loss_price=None,
  640. reason="x",
  641. )
  642. client = OkxClient(config=sample_config(), session=session)
  643. with pytest.raises(ValueError, match="leverage is invalid"):
  644. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  645. assert session.request_paths == []
  646. def test_place_demo_order_rejects_boolean_leverage_before_okx():
  647. session = DummySession([])
  648. signal = TradeSignal(
  649. action="long",
  650. confidence=0.9,
  651. leverage=True,
  652. entry_price=None,
  653. take_profit_price=None,
  654. stop_loss_price=None,
  655. reason="x",
  656. )
  657. client = OkxClient(config=sample_config(), session=session)
  658. with pytest.raises(ValueError, match="leverage is invalid"):
  659. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  660. assert session.request_paths == []
  661. @pytest.mark.parametrize("margin_usdt", [0, -1, float("nan"), float("inf")])
  662. def test_place_demo_order_rejects_invalid_margin_before_okx(margin_usdt):
  663. session = DummySession([])
  664. client = OkxClient(config=sample_config(), session=session)
  665. with pytest.raises(ValueError, match="margin_usdt is invalid"):
  666. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=margin_usdt)
  667. assert session.request_paths == []
  668. def test_place_demo_order_rejects_boolean_margin_before_okx():
  669. session = DummySession([])
  670. client = OkxClient(config=sample_config(), session=session)
  671. with pytest.raises(ValueError, match="margin_usdt is invalid"):
  672. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=True)
  673. assert session.request_paths == []
  674. @pytest.mark.parametrize(
  675. ("symbol", "leverage", "pos_side", "expected_message"),
  676. [
  677. ("BTC-USDT", 2, "long", "swap instrument is required"),
  678. ("BTC-USDT-SWAP", 4, "long", "leverage is invalid"),
  679. ("BTC-USDT-SWAP", 2, "net", "pos_side is invalid"),
  680. ],
  681. )
  682. def test_set_leverage_validates_public_boundary_inputs(symbol, leverage, pos_side, expected_message):
  683. session = DummySession([])
  684. client = OkxClient(config=sample_config(), session=session)
  685. with pytest.raises(ValueError, match=expected_message):
  686. client.set_leverage(symbol=symbol, leverage=leverage, pos_side=pos_side)
  687. assert session.request_paths == []
  688. def test_place_demo_order_rejects_unknown_action_before_okx():
  689. session = DummySession([])
  690. signal = TradeSignal(
  691. action="hold",
  692. confidence=0.9,
  693. leverage=2,
  694. entry_price=None,
  695. take_profit_price=None,
  696. stop_loss_price=None,
  697. reason="x",
  698. )
  699. client = OkxClient(config=sample_config(), session=session)
  700. with pytest.raises(ValueError, match="action is invalid"):
  701. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  702. assert session.request_paths == []
  703. def test_get_positions_returns_normalized_positions():
  704. session = DummySession([positions_response()])
  705. client = OkxClient(config=sample_config(), session=session)
  706. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  707. assert positions[0].symbol == "BTC-USDT-SWAP"
  708. assert positions[0].pos_side == "long"
  709. assert positions[0].size == 8.0
  710. assert positions[0].avg_price == 25000.0
  711. def test_get_positions_filters_zero_size_rows():
  712. session = DummySession([positions_with_zero_size_response()])
  713. client = OkxClient(config=sample_config(), session=session)
  714. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  715. assert len(positions) == 1
  716. assert positions[0].pos_side == "short"
  717. assert positions[0].size == 3.0
  718. def test_get_positions_ignores_malformed_fields_on_zero_size_rows():
  719. session = DummySession([positions_with_zero_size_malformed_avg_price_response()])
  720. client = OkxClient(config=sample_config(), session=session)
  721. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  722. assert len(positions) == 1
  723. assert positions[0].pos_side == "short"
  724. assert positions[0].avg_price == 24900.0
  725. def test_get_positions_rejects_non_string_inst_id_and_pos_side():
  726. session = DummySession([positions_with_non_string_identity_response()])
  727. client = OkxClient(config=sample_config(), session=session)
  728. with pytest.raises(ValueError, match="okx response payload is invalid"):
  729. client.get_positions(symbol="BTC-USDT-SWAP")
  730. def test_get_positions_rejects_non_finite_numeric_fields():
  731. session = DummySession([positions_with_non_finite_numeric_response()])
  732. client = OkxClient(config=sample_config(), session=session)
  733. with pytest.raises(ValueError, match="okx response payload is invalid"):
  734. client.get_positions(symbol="BTC-USDT-SWAP")
  735. def test_get_positions_rejects_mismatched_symbol():
  736. session = DummySession([positions_with_wrong_symbol_response()])
  737. client = OkxClient(config=sample_config(), session=session)
  738. with pytest.raises(ValueError, match="okx response payload is invalid"):
  739. client.get_positions(symbol="BTC-USDT-SWAP")
  740. def test_get_positions_rejects_invalid_pos_side():
  741. session = DummySession([positions_with_invalid_pos_side_response()])
  742. client = OkxClient(config=sample_config(), session=session)
  743. with pytest.raises(ValueError, match="okx response payload is invalid"):
  744. client.get_positions(symbol="BTC-USDT-SWAP")