test_okx_client.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990
  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_fractional_margin_sizing_keeps_decimal_precision():
  470. session = DummySession(
  471. [
  472. account_config_response(pos_mode="long_short_mode"),
  473. instrument_response(),
  474. ticker_response(last="1"),
  475. leverage_response(),
  476. place_order_response(),
  477. ]
  478. )
  479. signal = TradeSignal(
  480. action="long",
  481. confidence=0.9,
  482. leverage=3,
  483. entry_price=None,
  484. take_profit_price=None,
  485. stop_loss_price=None,
  486. reason="x",
  487. )
  488. client = OkxClient(config=sample_config(), session=session)
  489. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=0.009)
  490. assert session.last_json_body is not None
  491. assert session.last_json_body["sz"] == "27"
  492. def test_place_demo_order_fails_when_not_hedge_mode():
  493. session = DummySession(
  494. [
  495. account_config_response(pos_mode="net_mode"),
  496. ]
  497. )
  498. client = OkxClient(config=sample_config(), session=session)
  499. with pytest.raises(ValueError):
  500. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  501. assert session.request_paths == ["/api/v5/account/config"]
  502. def test_ensure_hedge_mode_rejects_malformed_config_payload():
  503. session = DummySession([malformed_account_config_response(pos_mode=None)])
  504. client = OkxClient(config=sample_config(), session=session)
  505. with pytest.raises(ValueError, match="okx response payload is invalid"):
  506. client.ensure_hedge_mode()
  507. def test_place_demo_order_validates_size_before_setting_leverage():
  508. session = DummySession(
  509. [
  510. account_config_response(pos_mode="long_short_mode"),
  511. large_min_size_instrument_response(),
  512. ticker_response(last="25000"),
  513. ]
  514. )
  515. client = OkxClient(config=sample_config(), session=session)
  516. with pytest.raises(ValueError, match="contract size below minimum"):
  517. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  518. assert session.request_paths == [
  519. "/api/v5/account/config",
  520. "/api/v5/public/instruments",
  521. "/api/v5/market/ticker",
  522. ]
  523. def test_limit_short_order_uses_sell_and_short_pos_side():
  524. session = DummySession(
  525. [
  526. account_config_response(pos_mode="long_short_mode"),
  527. instrument_response(symbol="ETH-USDT-SWAP"),
  528. leverage_response(),
  529. place_order_response(),
  530. ]
  531. )
  532. client = OkxClient(config=sample_config(), session=session)
  533. client.place_demo_order(symbol="ETH-USDT-SWAP", signal=limit_short_signal(), margin_usdt=100)
  534. order_request = session.last_json_body
  535. assert order_request is not None
  536. assert order_request["ordType"] == "limit"
  537. assert order_request["side"] == "sell"
  538. assert order_request["posSide"] == "short"
  539. assert order_request["px"] == "25000"
  540. assert session.request_bodies[2]["lever"] == "2"
  541. assert session.request_bodies[2]["mgnMode"] == "isolated"
  542. def test_flat_signal_returns_noop_without_order_submission():
  543. session = DummySession([])
  544. client = OkxClient(config=sample_config(), session=session)
  545. result = client.place_demo_order(symbol="BTC-USDT-SWAP", signal=flat_signal(), margin_usdt=100)
  546. assert result.status == "noop"
  547. assert session.request_paths == []
  548. def test_place_demo_order_sends_computed_sz_and_ignores_tp_sl_fields():
  549. session = DummySession(
  550. [
  551. account_config_response(pos_mode="long_short_mode"),
  552. instrument_response(),
  553. ticker_response(last="25000"),
  554. leverage_response(),
  555. place_order_response(),
  556. ]
  557. )
  558. client = OkxClient(config=sample_config(), session=session)
  559. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  560. order_request = session.last_json_body
  561. assert order_request is not None
  562. assert order_request["sz"] == "8"
  563. assert "tpTriggerPx" not in order_request
  564. assert "slTriggerPx" not in order_request
  565. def test_okx_error_payload_raises_value_error():
  566. session = DummySession([error_response(code="51000", msg="parameter error")])
  567. client = OkxClient(config=sample_config(), session=session)
  568. with pytest.raises(ValueError):
  569. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  570. def test_get_candles_rejects_non_finite_numeric_fields():
  571. session = DummySession([candles_with_non_finite_numeric_response()])
  572. client = OkxClient(config=sample_config(), session=session)
  573. with pytest.raises(ValueError, match="okx response payload is invalid"):
  574. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  575. def test_transport_failure_raises_stable_value_error():
  576. session = DummySession([RuntimeError("socket closed")])
  577. client = OkxClient(config=sample_config(), session=session)
  578. with pytest.raises(ValueError, match="okx transport error"):
  579. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  580. def test_invalid_json_raises_stable_value_error():
  581. session = DummySession([DummyResponse({}, json_error=ValueError("bad json"))])
  582. client = OkxClient(config=sample_config(), session=session)
  583. with pytest.raises(ValueError, match="okx response payload is invalid"):
  584. client.get_candles(symbol="BTC-USDT-SWAP", bar="1H", limit=20)
  585. def test_empty_positions_data_returns_empty_list():
  586. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": []})])
  587. client = OkxClient(config=sample_config(), session=session)
  588. assert client.get_positions(symbol="BTC-USDT-SWAP") == []
  589. def test_malformed_numeric_field_raises_stable_value_error():
  590. session = DummySession(
  591. [
  592. DummyResponse(
  593. {
  594. "code": "0",
  595. "msg": "",
  596. "data": [
  597. {
  598. "instId": "BTC-USDT-SWAP",
  599. "posSide": "long",
  600. "pos": "bad",
  601. "avgPx": "25000",
  602. }
  603. ],
  604. }
  605. )
  606. ]
  607. )
  608. client = OkxClient(config=sample_config(), session=session)
  609. with pytest.raises(ValueError, match="okx response payload is invalid"):
  610. client.get_positions(symbol="BTC-USDT-SWAP")
  611. def test_non_list_okx_data_raises_stable_value_error():
  612. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": {}})])
  613. client = OkxClient(config=sample_config(), session=session)
  614. with pytest.raises(ValueError, match="okx response payload is invalid"):
  615. client.get_positions(symbol="BTC-USDT-SWAP")
  616. def test_get_instrument_meta_rejects_non_finite_numeric_fields():
  617. session = DummySession([instrument_with_non_finite_numeric_response()])
  618. client = OkxClient(config=sample_config(), session=session)
  619. with pytest.raises(ValueError, match="okx response payload is invalid"):
  620. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  621. def test_get_last_price_rejects_non_finite_numeric_field():
  622. session = DummySession([ticker_with_non_finite_numeric_response()])
  623. client = OkxClient(config=sample_config(), session=session)
  624. with pytest.raises(ValueError, match="okx response payload is invalid"):
  625. client.get_last_price(symbol="BTC-USDT-SWAP")
  626. def test_get_last_price_rejects_mismatched_symbol():
  627. session = DummySession([DummyResponse({"code": "0", "msg": "", "data": [{"instId": "ETH-USDT-SWAP", "last": "25000"}]})])
  628. client = OkxClient(config=sample_config(), session=session)
  629. with pytest.raises(ValueError, match="okx response payload is invalid"):
  630. client.get_last_price(symbol="BTC-USDT-SWAP")
  631. def test_get_instrument_meta_rejects_mismatched_symbol():
  632. session = DummySession([instrument_with_wrong_symbol_response()])
  633. client = OkxClient(config=sample_config(), session=session)
  634. with pytest.raises(ValueError, match="okx response payload is invalid"):
  635. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  636. def test_get_instrument_meta_rejects_non_swap_type():
  637. session = DummySession([instrument_with_wrong_type_response()])
  638. client = OkxClient(config=sample_config(), session=session)
  639. with pytest.raises(ValueError, match="okx response payload is invalid"):
  640. client.get_instrument_meta(symbol="BTC-USDT-SWAP")
  641. def test_place_demo_order_raises_when_order_id_is_missing():
  642. session = DummySession(
  643. [
  644. account_config_response(pos_mode="long_short_mode"),
  645. instrument_response(),
  646. ticker_response(last="25000"),
  647. leverage_response(),
  648. place_order_response_without_order_id(),
  649. ]
  650. )
  651. client = OkxClient(config=sample_config(), session=session)
  652. with pytest.raises(ValueError, match="okx response payload is invalid"):
  653. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=100)
  654. assert session.request_paths[-1] == "/api/v5/trade/order"
  655. def test_place_demo_order_rejects_invalid_leverage_before_okx():
  656. session = DummySession([])
  657. signal = TradeSignal(
  658. action="long",
  659. confidence=0.9,
  660. leverage=4,
  661. entry_price=None,
  662. take_profit_price=None,
  663. stop_loss_price=None,
  664. reason="x",
  665. )
  666. client = OkxClient(config=sample_config(), session=session)
  667. with pytest.raises(ValueError, match="leverage is invalid"):
  668. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  669. assert session.request_paths == []
  670. def test_place_demo_order_rejects_fractional_leverage_before_okx():
  671. session = DummySession([])
  672. signal = TradeSignal(
  673. action="long",
  674. confidence=0.9,
  675. leverage=2.5,
  676. entry_price=None,
  677. take_profit_price=None,
  678. stop_loss_price=None,
  679. reason="x",
  680. )
  681. client = OkxClient(config=sample_config(), session=session)
  682. with pytest.raises(ValueError, match="leverage is invalid"):
  683. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  684. assert session.request_paths == []
  685. def test_place_demo_order_rejects_boolean_leverage_before_okx():
  686. session = DummySession([])
  687. signal = TradeSignal(
  688. action="long",
  689. confidence=0.9,
  690. leverage=True,
  691. entry_price=None,
  692. take_profit_price=None,
  693. stop_loss_price=None,
  694. reason="x",
  695. )
  696. client = OkxClient(config=sample_config(), session=session)
  697. with pytest.raises(ValueError, match="leverage is invalid"):
  698. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  699. assert session.request_paths == []
  700. @pytest.mark.parametrize("margin_usdt", [0, -1, float("nan"), float("inf")])
  701. def test_place_demo_order_rejects_invalid_margin_before_okx(margin_usdt):
  702. session = DummySession([])
  703. client = OkxClient(config=sample_config(), session=session)
  704. with pytest.raises(ValueError, match="margin_usdt is invalid"):
  705. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=margin_usdt)
  706. assert session.request_paths == []
  707. def test_place_demo_order_rejects_boolean_margin_before_okx():
  708. session = DummySession([])
  709. client = OkxClient(config=sample_config(), session=session)
  710. with pytest.raises(ValueError, match="margin_usdt is invalid"):
  711. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=market_long_signal(), margin_usdt=True)
  712. assert session.request_paths == []
  713. @pytest.mark.parametrize(
  714. ("symbol", "leverage", "pos_side", "expected_message"),
  715. [
  716. ("BTC-USDT", 2, "long", "swap instrument is required"),
  717. ("BTC-USDT-SWAP", 4, "long", "leverage is invalid"),
  718. ("BTC-USDT-SWAP", 2.5, "long", "leverage is invalid"),
  719. ("BTC-USDT-SWAP", 2, "net", "pos_side is invalid"),
  720. ],
  721. )
  722. def test_set_leverage_validates_public_boundary_inputs(symbol, leverage, pos_side, expected_message):
  723. session = DummySession([])
  724. client = OkxClient(config=sample_config(), session=session)
  725. with pytest.raises(ValueError, match=expected_message):
  726. client.set_leverage(symbol=symbol, leverage=leverage, pos_side=pos_side)
  727. assert session.request_paths == []
  728. def test_place_demo_order_rejects_unknown_action_before_okx():
  729. session = DummySession([])
  730. signal = TradeSignal(
  731. action="hold",
  732. confidence=0.9,
  733. leverage=2,
  734. entry_price=None,
  735. take_profit_price=None,
  736. stop_loss_price=None,
  737. reason="x",
  738. )
  739. client = OkxClient(config=sample_config(), session=session)
  740. with pytest.raises(ValueError, match="action is invalid"):
  741. client.place_demo_order(symbol="BTC-USDT-SWAP", signal=signal, margin_usdt=100)
  742. assert session.request_paths == []
  743. def test_get_positions_returns_normalized_positions():
  744. session = DummySession([positions_response()])
  745. client = OkxClient(config=sample_config(), session=session)
  746. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  747. assert positions[0].symbol == "BTC-USDT-SWAP"
  748. assert positions[0].pos_side == "long"
  749. assert positions[0].size == 8.0
  750. assert positions[0].avg_price == 25000.0
  751. def test_get_positions_filters_zero_size_rows():
  752. session = DummySession([positions_with_zero_size_response()])
  753. client = OkxClient(config=sample_config(), session=session)
  754. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  755. assert len(positions) == 1
  756. assert positions[0].pos_side == "short"
  757. assert positions[0].size == 3.0
  758. def test_get_positions_ignores_malformed_fields_on_zero_size_rows():
  759. session = DummySession([positions_with_zero_size_malformed_avg_price_response()])
  760. client = OkxClient(config=sample_config(), session=session)
  761. positions = client.get_positions(symbol="BTC-USDT-SWAP")
  762. assert len(positions) == 1
  763. assert positions[0].pos_side == "short"
  764. assert positions[0].avg_price == 24900.0
  765. def test_get_positions_rejects_non_string_inst_id_and_pos_side():
  766. session = DummySession([positions_with_non_string_identity_response()])
  767. client = OkxClient(config=sample_config(), session=session)
  768. with pytest.raises(ValueError, match="okx response payload is invalid"):
  769. client.get_positions(symbol="BTC-USDT-SWAP")
  770. def test_get_positions_rejects_non_finite_numeric_fields():
  771. session = DummySession([positions_with_non_finite_numeric_response()])
  772. client = OkxClient(config=sample_config(), session=session)
  773. with pytest.raises(ValueError, match="okx response payload is invalid"):
  774. client.get_positions(symbol="BTC-USDT-SWAP")
  775. def test_get_positions_rejects_mismatched_symbol():
  776. session = DummySession([positions_with_wrong_symbol_response()])
  777. client = OkxClient(config=sample_config(), session=session)
  778. with pytest.raises(ValueError, match="okx response payload is invalid"):
  779. client.get_positions(symbol="BTC-USDT-SWAP")
  780. def test_get_positions_rejects_invalid_pos_side():
  781. session = DummySession([positions_with_invalid_pos_side_response()])
  782. client = OkxClient(config=sample_config(), session=session)
  783. with pytest.raises(ValueError, match="okx response payload is invalid"):
  784. client.get_positions(symbol="BTC-USDT-SWAP")