1use std::{
37 collections::HashMap,
38 fmt::Debug,
39 num::NonZeroU32,
40 str::FromStr,
41 sync::{
42 Arc, LazyLock, Mutex,
43 atomic::{AtomicBool, Ordering},
44 },
45 time::Duration,
46};
47
48use ahash::{AHashMap, AHashSet};
49use anyhow::Context;
50use jiff::Timestamp;
51use nautilus_common::{cache::InstrumentLookupError, live::dst::time};
52use nautilus_core::{
53 AtomicMap, AtomicTime, UnixNanos, datetime::NANOSECONDS_IN_MILLISECOND, env::get_or_env_var,
54 string::secret::REDACTED, time::get_atomic_clock_realtime,
55};
56use nautilus_model::{
57 data::{
58 Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
59 OrderBookDelta, OrderBookDeltas, TradeTick,
60 bar::{get_bar_interval_ns, get_time_bar_bounds},
61 },
62 enums::{
63 AccountType, AggregationSource, BarAggregation, BookAction, BookType, OrderSide,
64 OrderStatus, OrderType, PositionSide, RecordFlag, TimeInForce, TriggerType,
65 },
66 events::AccountState,
67 identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
68 instruments::{Instrument, InstrumentAny},
69 orderbook::OrderBook,
70 reports::{FillReport, OrderStatusReport, PositionStatusReport},
71 types::{Price, Quantity},
72};
73use nautilus_network::{
74 http::{
75 HttpClient, HttpRedirectPolicy, Method, StatusCode, create_standard_nautilus_headers,
76 parse_retry_after,
77 },
78 ratelimiter::quota::Quota,
79 retry::{RetryConfig, RetryError, RetryManager},
80};
81use rust_decimal::Decimal;
82use serde::{Deserialize, Serialize, de::DeserializeOwned};
83use thiserror::Error;
84use tokio_util::sync::CancellationToken;
85use ustr::Ustr;
86
87use super::{
88 error::OKXHttpError,
89 models::{
90 OKXAccount, OKXAccountBill, OKXAccountConfiguration, OKXAmendAlgoOrderRequest,
91 OKXAmendAlgoOrderResponse, OKXAmendOrderRequest, OKXAttachAlgoOrdRequest,
92 OKXCancelAlgoOrderRequest, OKXCancelAlgoOrderResponse, OKXCancelAllSpreadOrdersRequest,
93 OKXCancelOrderRequest, OKXCancelOrderResponse, OKXCancelSpreadOrderRequest,
94 OKXEventContractEvent, OKXEventContractMarket, OKXEventContractSeries, OKXFeeRate,
95 OKXFundingRateHistory, OKXIndexTicker, OKXMarkPrice, OKXOptionSummary, OKXOrderAlgo,
96 OKXOrderBookSnapshot, OKXOrderHistory, OKXPlaceAlgoOrderRequest, OKXPlaceAlgoOrderResponse,
97 OKXPlaceOrderRequest, OKXPlaceOrderResponse, OKXPlaceSpreadOrderRequest, OKXPosition,
98 OKXPositionHistory, OKXPositionTier, OKXPriceLimit, OKXRpiOrderBookSnapshot, OKXServerTime,
99 OKXSpread, OKXSpreadOrder, OKXSpreadTrade, OKXTransactionDetail,
100 },
101 query::{
102 ActivateFeatureParams, GetAccountBillsParams, GetAlgoOrderParams,
103 GetAlgoOrderParamsBuilder, GetAlgoOrdersParams, GetAlgoOrdersParamsBuilder,
104 GetCandlesticksParams, GetCandlesticksParamsBuilder, GetEventContractEventsParams,
105 GetEventContractMarketsParams, GetEventContractSeriesParams, GetFundingRateHistoryParams,
106 GetIndexTickerParams, GetIndexTickerParamsBuilder, GetInstrumentsParams,
107 GetInstrumentsParamsBuilder, GetMarkPriceParams, GetMarkPriceParamsBuilder,
108 GetOptionSummaryParams, GetOrderBookParams, GetOrderHistoryParams,
109 GetOrderHistoryParamsBuilder, GetOrderListParams, GetOrderListParamsBuilder,
110 GetOrderParams, GetOrderParamsBuilder, GetPositionTiersParams, GetPositionsHistoryParams,
111 GetPositionsParams, GetPositionsParamsBuilder, GetPriceLimitParams,
112 GetPriceLimitParamsBuilder, GetRpiOrderBookParams, GetSpreadOrderParams,
113 GetSpreadOrderParamsBuilder, GetSpreadOrdersParams, GetSpreadOrdersParamsBuilder,
114 GetSpreadTradesParams, GetSpreadTradesParamsBuilder, GetSpreadsParams, GetTradeFeeParams,
115 GetTradesParams, GetTradesParamsBuilder, GetTransactionDetailsParams,
116 GetTransactionDetailsParamsBuilder, SetPositionModeParams, SetPositionModeParamsBuilder,
117 },
118};
119use crate::{
120 common::{
121 consts::{
122 OKX_FIELD_SCODE, OKX_FIELD_SMSG, OKX_HTTP_URL, OKX_NAUTILUS_BROKER_ID,
123 OKX_POST_ONLY_CANCEL_REASON, OKX_POST_ONLY_CANCEL_SOURCE, OKX_SUPPORTED_ORDER_TYPES,
124 OKX_SUPPORTED_TIME_IN_FORCE, okx_algo_target_currency, okx_reduce_only_wire_value,
125 spot_trade_quote_ccy_wire_value, validate_algo_trigger_type,
126 },
127 credential::Credential,
128 enums::{
129 OKXAlgoOrderStatus, OKXAlgoOrderType, OKXEnvironment, OKXInstrumentStatus,
130 OKXInstrumentType, OKXMarginMode, OKXOrderStatus, OKXOrderType, OKXPositionMode,
131 OKXPositionSide, OKXSide, OKXTargetCurrency, OKXTradeMode, OKXTriggerType,
132 conditional_order_to_algo_type,
133 },
134 models::OKXInstrument,
135 parse::{
136 extract_inst_family, is_okx_spread_symbol, is_order_status_report_more_advanced,
137 okx_instrument_type, okx_instrument_type_from_symbol, parse_account_state,
138 parse_base_quote_from_symbol, parse_candlestick, parse_fill_report, parse_funding_rate,
139 parse_index_price_update, parse_instrument_any, parse_mark_price_update,
140 parse_millisecond_timestamp, parse_order_status_report, parse_position_status_report,
141 parse_price, parse_quantity, parse_spot_margin_position_from_balance,
142 parse_spread_fill_report, parse_spread_instrument, parse_spread_order_status_report,
143 parse_trade_tick, prefer_rpi_response_fields,
144 },
145 },
146 http::models::{OKXCandlestick, OKXTrade},
147 websocket::{messages::OKXAlgoOrderMsg, parse::parse_algo_order_status_report},
148};
149
150const OKX_SUCCESS_CODE: &str = "0";
151const OKX_PARTIAL_SUCCESS_CODE: &str = "2";
152const RETRY_AFTER_HEADER: &str = "Retry-After";
153
154#[derive(Debug, Error)]
155#[error("Failed to parse instrument {symbol}: {source}")]
156pub(crate) struct OKXInstrumentDefinitionError {
157 symbol: String,
158 #[source]
159 source: anyhow::Error,
160}
161
162impl OKXInstrumentDefinitionError {
163 fn new(symbol: &str, source: anyhow::Error) -> Self {
164 Self {
165 symbol: symbol.to_string(),
166 source,
167 }
168 }
169}
170
171#[derive(Debug, Error)]
172#[error("Failed to fetch pending algo order reports: {source}")]
173pub(crate) struct OKXPendingAlgoOrderReportsError {
174 #[source]
175 source: anyhow::Error,
176}
177
178impl OKXPendingAlgoOrderReportsError {
179 fn new(source: anyhow::Error) -> Self {
180 Self { source }
181 }
182}
183
184fn spot_quote_priority(symbol: &str) -> u8 {
189 symbol.rsplit_once('-').map_or(4, |(_, quote)| match quote {
190 "USDT" => 0,
191 "USDC" => 1,
192 "USD" => 2,
193 _ => 3,
194 })
195}
196
197#[derive(Deserialize)]
199struct OKXErrorEnvelope {
200 code: String,
201 msg: String,
202}
203
204fn resolve_okx_error_code(response_body: &[u8], envelope_code: &str) -> String {
205 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(response_body)
206 && let Some(s_code) = payload
207 .get("data")
208 .and_then(serde_json::Value::as_array)
209 .and_then(|items| items.first())
210 .and_then(|item| item.get(OKX_FIELD_SCODE))
211 .and_then(serde_json::Value::as_str)
212 {
213 let s_code = s_code.trim();
214 if !s_code.is_empty() {
215 return s_code.to_string();
216 }
217 }
218
219 envelope_code.to_string()
220}
221
222fn resolve_okx_error_message(response_body: &[u8], top_level_msg: &str) -> String {
223 let message = top_level_msg.trim();
224 let is_generic_top_level = message.eq_ignore_ascii_case("All operations failed");
225 if !message.is_empty() && !is_generic_top_level {
226 return message.to_string();
227 }
228
229 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(response_body)
230 && let Some(first_item) = payload
231 .get("data")
232 .and_then(serde_json::Value::as_array)
233 .and_then(|items| items.first())
234 {
235 if let Some(s_msg) = first_item
236 .get(OKX_FIELD_SMSG)
237 .and_then(serde_json::Value::as_str)
238 {
239 let s_msg = s_msg.trim();
240 if !s_msg.is_empty() {
241 return s_msg.to_string();
242 }
243 }
244
245 if let Some(s_code) = first_item
246 .get(OKX_FIELD_SCODE)
247 .and_then(serde_json::Value::as_str)
248 {
249 let s_code = s_code.trim();
250 if !s_code.is_empty() {
251 return s_code.to_string();
252 }
253 }
254 }
255
256 String::new()
257}
258
259fn deserialize_okx_response<T: DeserializeOwned>(
260 response_body: &[u8],
261) -> Result<OKXResponse<T>, OKXHttpError> {
262 if response_body.is_empty() {
263 return Err(OKXHttpError::EmptyResponse);
264 }
265
266 let contains_legacy_rpi_name = [br#""elp""#.as_slice(), br#""elpMaker""#.as_slice()]
267 .iter()
268 .any(|name| {
269 response_body
270 .windows(name.len())
271 .any(|window| window == *name)
272 });
273
274 if !contains_legacy_rpi_name {
275 return serde_json::from_slice(response_body)
276 .map_err(|e| classify_response_decode_error(&e));
277 }
278
279 let mut value: serde_json::Value =
280 serde_json::from_slice(response_body).map_err(|e| classify_response_decode_error(&e))?;
281 prefer_rpi_response_fields(&mut value);
282 serde_json::from_value(value).map_err(|e| classify_response_decode_error(&e))
283}
284
285fn classify_response_decode_error(error: &serde_json::Error) -> OKXHttpError {
286 if error.is_data() {
287 OKXHttpError::ResponseDecoding(error.to_string())
288 } else {
289 OKXHttpError::MalformedResponse(error.to_string())
290 }
291}
292
293fn retry_after(headers: &HashMap<String, String>, now: Timestamp) -> Option<Duration> {
294 let value = headers.get(RETRY_AFTER_HEADER)?;
295 let delay = parse_retry_after(value, now);
296 if delay.is_none() {
297 log::warn!("Invalid OKX response header {RETRY_AFTER_HEADER}={value:?}");
298 }
299 delay
300}
301
302#[cfg(test)]
303mod tests {
304 use std::time::Duration;
305
306 use anyhow::Context;
307 use nautilus_model::{
308 identifiers::{InstrumentId, Symbol},
309 instruments::{
310 CryptoFuture, CryptoFuturesSpread, InstrumentAny,
311 stubs::{crypto_future_btcusdt, crypto_futures_spread_btc_deribit},
312 },
313 };
314 use nautilus_testkit::http::assert_http_redirect_rejected;
315 use rstest::rstest;
316 use rust_decimal::Decimal;
317 use serde::{Serialize, Serializer, ser::Error as _};
318
319 use super::{
320 Method, OKX_GLOBAL_RATE_KEY, OKXEnvironment, OKXHttpClient, OKXHttpError,
321 OKXInstrumentDefinitionError, OKXRawHttpClient, Timestamp, deserialize_okx_response,
322 parse_retry_after, resolve_okx_error_code, resolve_okx_error_message,
323 };
324 use crate::http::models::OKXFeeRate;
325
326 #[rstest]
327 fn test_is_spread_prefers_cached_future(mut crypto_future_btcusdt: CryptoFuture) {
328 let client = OKXHttpClient::default();
329 let instrument_id = InstrumentId::from("BTC-USDT_BTC-USDT-SWAP.OKX");
330 crypto_future_btcusdt.id = instrument_id;
331 crypto_future_btcusdt.raw_symbol = instrument_id.symbol;
332 let uncached = client.is_spread(instrument_id);
333
334 client.cache_instrument(InstrumentAny::CryptoFuture(crypto_future_btcusdt));
335
336 assert!(uncached);
337 assert!(!client.is_spread(instrument_id));
338 }
339
340 #[rstest]
341 fn test_is_spread_prefers_cached_spread(
342 mut crypto_futures_spread_btc_deribit: CryptoFuturesSpread,
343 ) {
344 let client = OKXHttpClient::default();
345 let instrument_id = InstrumentId::from("BTC-USD_UM_XPERP-310404.OKX");
346 crypto_futures_spread_btc_deribit.id = instrument_id;
347 crypto_futures_spread_btc_deribit.raw_symbol = Symbol::from("BTC-USD_UM_XPERP-310404");
348 let uncached = client.is_spread(instrument_id);
349
350 client.cache_instrument(InstrumentAny::CryptoFuturesSpread(
351 crypto_futures_spread_btc_deribit,
352 ));
353
354 assert!(!uncached);
355 assert!(client.is_spread(instrument_id));
356 }
357
358 struct UnserializableParams;
359
360 impl Serialize for UnserializableParams {
361 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
362 where
363 S: Serializer,
364 {
365 Err(S::Error::custom("intentional serialization failure"))
366 }
367 }
368
369 #[tokio::test]
370 async fn test_authenticated_client_rejects_redirects() {
371 let client = OKXRawHttpClient::with_credentials(
372 "key".into(),
373 "secret".into(),
374 "pass".into(),
375 "http://localhost".into(),
376 3,
377 0,
378 1,
379 1,
380 OKXEnvironment::Demo,
381 None,
382 )
383 .unwrap()
384 .client;
385 assert_http_redirect_rejected(|url| async move {
386 client
387 .get(url, None, None, Some(3), None)
388 .await
389 .unwrap()
390 .status
391 .as_u16()
392 })
393 .await;
394 }
395
396 #[rstest]
397 fn test_instrument_definition_error_survives_anyhow_context_downcast() {
398 let result: anyhow::Result<()> = Err(OKXInstrumentDefinitionError::new(
399 "USDG-SGD",
400 anyhow::anyhow!("`tick_sz` is empty"),
401 )
402 .into());
403
404 let err = result.context("fetch instrument from API").unwrap_err();
405
406 assert!(err.downcast_ref::<OKXInstrumentDefinitionError>().is_some());
407 }
408
409 #[rstest]
410 fn test_resolve_okx_error_message_prefers_detailed_s_msg_over_generic_top_level() {
411 let body = br#"{
412 "code": "1",
413 "msg": "All operations failed",
414 "data": [
415 {
416 "sCode": "51046",
417 "sMsg": "Test detailed failure"
418 }
419 ]
420 }"#;
421
422 assert_eq!(
423 resolve_okx_error_message(body, "All operations failed"),
424 "Test detailed failure",
425 );
426 }
427
428 #[rstest]
429 fn test_resolve_okx_error_code_prefers_item_s_code_over_envelope() {
430 let body = br#"{
431 "code": "1",
432 "msg": "All operations failed",
433 "data": [
434 {
435 "sCode": "50013",
436 "sMsg": "System busy, please retry later"
437 }
438 ]
439 }"#;
440
441 assert_eq!(resolve_okx_error_code(body, "1"), "50013");
442 }
443
444 #[rstest]
445 #[case("/api/v5/account/bills", 200)]
446 #[case("/api/v5/account/bills-archive", 400)]
447 fn test_account_bills_quotas(#[case] endpoint: &str, #[case] interval_ms: u64) {
448 let keys = OKXRawHttpClient::rate_limit_keys(&format!("{endpoint}?ccy=USDT"));
449 assert_eq!(keys[0].as_str(), OKX_GLOBAL_RATE_KEY);
450 assert_eq!(keys[1].as_str(), format!("okx:{endpoint}"));
451 let quota = OKXRawHttpClient::rate_limiter_quotas()
452 .into_iter()
453 .find_map(|(key, quota)| (key == keys[1].as_str()).then_some(quota))
454 .expect("account bills quota");
455
456 assert_eq!(quota.burst_size().get(), 1);
458 assert_eq!(
459 quota.replenish_interval(),
460 Duration::from_millis(interval_ms)
461 );
462 }
463
464 #[rstest]
465 fn test_activate_feature_quota_fits_five_per_two_seconds() {
466 let keys = OKXRawHttpClient::rate_limit_keys("/api/v5/account/activate-feature");
467 let quota = OKXRawHttpClient::rate_limiter_quotas()
468 .into_iter()
469 .find_map(|(key, quota)| (key == keys[1].as_str()).then_some(quota))
470 .expect("activate-feature quota");
471
472 assert_eq!(quota.burst_size().get(), 2);
473 assert_eq!(quota.replenish_interval(), Duration::from_millis(500));
474 }
475
476 #[rstest]
477 fn test_rpi_response_fields_prefer_current_names_over_transition_aliases() {
478 let response = br#"{
479 "code": "0",
480 "msg": "",
481 "data": [{
482 "level": "VIP1",
483 "taker": "-0.0005",
484 "maker": "-0.0002",
485 "takerU": "-0.0005",
486 "makerU": "-0.0002",
487 "rpiMaker": "-0.00015",
488 "elpMaker": "-0.00016",
489 "instType": "SPOT",
490 "category": "1",
491 "ts": "1785406500000"
492 }]
493 }"#;
494
495 let parsed = deserialize_okx_response::<OKXFeeRate>(response).unwrap();
496
497 assert_eq!(parsed.data.len(), 1);
498 assert_eq!(parsed.data[0].rpi_maker, Some(Decimal::new(-15, 5)));
499 }
500
501 #[rstest]
502 fn test_parse_retry_after_supports_delay_seconds_and_http_date() {
503 let now: Timestamp = "1994-11-06T08:49:36Z".parse().unwrap();
504
505 assert_eq!(
506 parse_retry_after("5", now),
507 Some(std::time::Duration::from_secs(5))
508 );
509 assert_eq!(
510 parse_retry_after("Sun, 06 Nov 1994 08:51:36 GMT", now),
511 Some(std::time::Duration::from_secs(120))
512 );
513 assert_eq!(
514 parse_retry_after("Sun, 06 Nov 1994 08:47:36 GMT", now),
515 Some(std::time::Duration::ZERO)
516 );
517 assert_eq!(parse_retry_after("soon", now), None);
518 }
519
520 #[rstest]
521 fn test_empty_response_is_distinct_from_malformed_json() {
522 let empty = deserialize_okx_response::<serde_json::Value>(b"").unwrap_err();
523 let malformed = deserialize_okx_response::<serde_json::Value>(b"{").unwrap_err();
524
525 assert!(matches!(empty, OKXHttpError::EmptyResponse));
526 assert!(matches!(malformed, OKXHttpError::MalformedResponse(_)));
527 }
528
529 #[rstest]
530 #[tokio::test]
531 async fn test_request_serialization_failure_is_typed_before_transport() {
532 let client = OKXRawHttpClient::new(
533 Some("http://127.0.0.1:1".to_string()),
534 1,
535 0,
536 1,
537 1,
538 OKXEnvironment::Live,
539 None,
540 )
541 .unwrap();
542
543 let error = client
544 .send_request::<serde_json::Value, _>(
545 Method::GET,
546 "/api/v5/test",
547 Some(&UnserializableParams),
548 None,
549 false,
550 )
551 .await
552 .unwrap_err();
553
554 assert!(matches!(
555 error,
556 OKXHttpError::RequestSerialization(message)
557 if message.contains("intentional serialization failure")
558 ));
559 }
560
561 #[rstest]
562 #[case("BTC-USD")]
563 #[case("BTC-USD-241217")]
564 #[case("BTC-USD-241217-92000")]
565 fn test_option_summary_exp_time_rejects_short_symbol(#[case] symbol: &str) {
566 let result = super::OKXHttpClient::option_summary_exp_time(symbol);
567 assert!(result.is_err());
568 let err = result.unwrap_err().to_string();
569 assert!(
570 err.contains("Expected OKX option symbol with expiry"),
571 "unexpected error: {err}"
572 );
573 }
574
575 #[rstest]
576 fn test_option_summary_exp_time_extracts_expiry() {
577 let result =
578 super::OKXHttpClient::option_summary_exp_time("BTC-USD-241217-92000-C").unwrap();
579 assert_eq!(result, Some("241217".to_string()));
580 }
581}
582
583pub static OKX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
592 Quota::per_second(NonZeroU32::new(250).expect("non-zero")).expect("valid constant")
593});
594
595const OKX_GLOBAL_RATE_KEY: &str = "okx:global";
596
597const OKX_PAGE_SIZE: usize = 100;
599
600const MAX_RECONCILIATION_PAGES: usize = 50;
602
603#[derive(Clone, Copy, Debug)]
604pub(crate) struct ReportInstrumentScope<'a> {
605 pub instrument_types: &'a [OKXInstrumentType],
606 pub load_spreads: bool,
607 pub margin_mode: Option<OKXMarginMode>,
608}
609
610impl ReportInstrumentScope<'_> {
611 pub(crate) fn validate_position_margin(&self, position: &OKXPosition) -> anyhow::Result<()> {
612 if let Some(expected) = self.margin_mode
613 && matches!(
614 position.inst_type,
615 OKXInstrumentType::Swap | OKXInstrumentType::Futures
616 )
617 && matches!(
618 position.pos_side,
619 OKXPositionSide::Long | OKXPositionSide::Short
620 )
621 && position.mgn_mode != expected
622 && !Decimal::from_str(&position.pos)?.is_zero()
623 {
624 anyhow::bail!(
625 "OKX long/short position {} uses margin mode {}, expected {expected}; mixed margin modes are not supported",
626 position.inst_id,
627 position.mgn_mode,
628 );
629 }
630
631 Ok(())
632 }
633}
634
635#[derive(Debug)]
636pub(crate) struct ReportSweep<T> {
637 pub reports: Vec<T>,
638 pub complete: bool,
639}
640
641#[derive(Clone, Copy)]
642pub(crate) enum FillHistory {
643 Recent,
644 Extended,
645}
646
647#[derive(Debug)]
648pub(crate) struct AlgoOrderReportSweep {
649 pub reports: Vec<OrderStatusReport>,
650 pub complete: bool,
651 pub ambiguous_triggered_child_ids: AHashSet<VenueOrderId>,
652}
653
654struct PageSweep<T> {
655 items: Vec<T>,
656 complete: bool,
657}
658
659impl<T> PageSweep<T> {
660 fn from_pages(items: Vec<T>, exhausted: bool) -> Self {
661 Self {
662 complete: !exhausted,
663 items,
664 }
665 }
666}
667
668enum InstrumentResolution {
669 Found(Box<InstrumentAny>),
670 Skip,
671 Incomplete,
672}
673
674#[derive(Debug, Serialize, Deserialize)]
676pub struct OKXResponse<T> {
677 pub code: String,
679 pub msg: String,
681 pub data: Vec<T>,
683}
684
685pub struct OKXRawHttpClient {
691 clock: &'static AtomicTime,
692 base_url: String,
693 client: HttpClient,
694 credential: Option<Credential>,
695 retry_manager: RetryManager<OKXHttpError>,
696 cancellation_token: CancellationToken,
697 environment: OKXEnvironment,
698}
699
700impl Default for OKXRawHttpClient {
701 fn default() -> Self {
702 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
703 .expect("Failed to create default OKXRawHttpClient")
704 }
705}
706
707impl Debug for OKXRawHttpClient {
708 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709 let credential = self.credential.as_ref().map(|_| REDACTED);
710 f.debug_struct(stringify!(OKXRawHttpClient))
711 .field("base_url", &self.base_url)
712 .field("credential", &credential)
713 .finish_non_exhaustive()
714 }
715}
716
717impl OKXRawHttpClient {
718 fn rate_limiter_quotas() -> Vec<(String, Quota)> {
719 vec![
720 (OKX_GLOBAL_RATE_KEY.to_string(), *OKX_REST_QUOTA),
721 (
722 "okx:/api/v5/account/config".to_string(),
723 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
724 ),
725 (
726 "okx:/api/v5/account/set-position-mode".to_string(),
727 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
728 ),
729 (
730 "okx:/api/v5/account/activate-feature".to_string(),
731 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
732 ),
733 (
734 "okx:/api/v5/account/balance".to_string(),
735 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
736 ),
737 (
738 "okx:/api/v5/account/bills".to_string(),
739 Quota::with_period(Duration::from_millis(200)).expect("valid constant"),
740 ),
741 (
742 "okx:/api/v5/account/bills-archive".to_string(),
743 Quota::with_period(Duration::from_millis(400)).expect("valid constant"),
744 ),
745 (
746 "okx:/api/v5/account/trade-fee".to_string(),
747 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
748 ),
749 (
750 "okx:/api/v5/account/instruments".to_string(),
751 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
752 ),
753 (
754 "okx:/api/v5/account/positions".to_string(),
755 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
756 ),
757 (
758 "okx:/api/v5/account/positions-history".to_string(),
759 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
760 ),
761 (
762 "okx:/api/v5/public/instruments".to_string(),
763 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
764 ),
765 (
766 "okx:/api/v5/public/position-tiers".to_string(),
767 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
768 ),
769 (
770 "okx:/api/v5/public/event-contract/series".to_string(),
771 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
772 ),
773 (
774 "okx:/api/v5/public/event-contract/events".to_string(),
775 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
776 ),
777 (
778 "okx:/api/v5/public/event-contract/markets".to_string(),
779 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
780 ),
781 (
782 "okx:/api/v5/public/opt-summary".to_string(),
783 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
784 ),
785 (
786 "okx:/api/v5/public/time".to_string(),
787 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
788 ),
789 (
790 "okx:/api/v5/public/mark-price".to_string(),
791 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
792 ),
793 (
794 "okx:/api/v5/public/price-limit".to_string(),
795 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
796 ),
797 (
798 "okx:/api/v5/sprd/spreads".to_string(),
799 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
800 ),
801 (
802 "okx:/api/v5/sprd/order".to_string(),
803 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
804 ),
805 (
806 "okx:/api/v5/sprd/cancel-order".to_string(),
807 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
808 ),
809 (
810 "okx:/api/v5/sprd/mass-cancel".to_string(),
811 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
812 ),
813 (
814 "okx:/api/v5/sprd/orders-pending".to_string(),
815 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
816 ),
817 (
818 "okx:/api/v5/sprd/orders-history".to_string(),
819 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
820 ),
821 (
822 "okx:/api/v5/sprd/trades".to_string(),
823 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
824 ),
825 (
826 "okx:/api/v5/market/index-tickers".to_string(),
827 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
828 ),
829 (
830 "okx:/api/v5/market/books".to_string(),
831 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
832 ),
833 (
834 "okx:/api/v5/market/books-rpi".to_string(),
835 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
836 ),
837 (
838 "okx:/api/v5/market/candles".to_string(),
839 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
840 ),
841 (
842 "okx:/api/v5/market/history-candles".to_string(),
843 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
844 ),
845 (
846 "okx:/api/v5/market/history-trades".to_string(),
847 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
848 ),
849 (
850 "okx:/api/v5/public/funding-rate-history".to_string(),
851 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
852 ),
853 (
854 "okx:/api/v5/trade/order".to_string(),
855 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
856 ),
857 (
858 "okx:/api/v5/trade/batch-orders".to_string(),
859 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
860 ),
861 (
862 "okx:/api/v5/trade/amend-order".to_string(),
863 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
864 ),
865 (
866 "okx:/api/v5/trade/amend-batch-orders".to_string(),
867 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
868 ),
869 (
870 "okx:/api/v5/trade/cancel-batch-orders".to_string(),
871 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
872 ),
873 (
874 "okx:/api/v5/trade/orders-pending".to_string(),
875 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
876 ),
877 (
878 "okx:/api/v5/trade/orders-history".to_string(),
879 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
880 ),
881 (
882 "okx:/api/v5/trade/fills".to_string(),
883 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
884 ),
885 (
886 "okx:/api/v5/trade/fills-history".to_string(),
887 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
888 ),
889 (
890 "okx:/api/v5/trade/order-algo".to_string(),
891 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
892 ),
893 (
894 "okx:/api/v5/trade/cancel-algos".to_string(),
895 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
896 ),
897 (
898 "okx:/api/v5/trade/cancel-advance-algos".to_string(),
899 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
900 ),
901 (
902 "okx:/api/v5/trade/amend-algos".to_string(),
903 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
904 ),
905 (
906 "okx:/api/v5/trade/orders-algo-pending".to_string(),
907 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
908 ),
909 (
910 "okx:/api/v5/trade/orders-algo-history".to_string(),
911 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
912 ),
913 ]
914 }
915
916 fn rate_limit_keys(endpoint: &str) -> Vec<Ustr> {
917 let normalized = endpoint.split('?').next().unwrap_or(endpoint);
918 let route = format!("okx:{normalized}");
919
920 vec![Ustr::from(OKX_GLOBAL_RATE_KEY), Ustr::from(route.as_str())]
921 }
922
923 pub fn cancel_all_requests(&self) {
925 self.cancellation_token.cancel();
926 }
927
928 pub fn cancellation_token(&self) -> &CancellationToken {
930 &self.cancellation_token
931 }
932
933 pub fn new(
943 base_url: Option<String>,
944 timeout_secs: u64,
945 max_retries: u32,
946 retry_delay_ms: u64,
947 retry_delay_max_ms: u64,
948 environment: OKXEnvironment,
949 proxy_url: Option<String>,
950 ) -> Result<Self, OKXHttpError> {
951 let retry_config = RetryConfig {
952 max_retries,
953 initial_delay_ms: retry_delay_ms,
954 max_delay_ms: retry_delay_max_ms,
955 backoff_factor: 2.0,
956 jitter_ms: 1000,
957 operation_timeout_ms: Some(60_000),
958 immediate_first: false,
959 max_elapsed_ms: Some(180_000),
960 };
961
962 let retry_manager = RetryManager::new(retry_config);
963
964 Ok(Self {
965 clock: get_atomic_clock_realtime(),
966 base_url: base_url.unwrap_or(OKX_HTTP_URL.to_string()),
967 client: HttpClient::builder()
968 .headers(Self::default_headers(environment))
969 .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
970 .keyed_quotas(Self::rate_limiter_quotas())
971 .default_quota(*OKX_REST_QUOTA)
972 .timeout_secs(timeout_secs)
973 .maybe_proxy_url(proxy_url)
974 .build()
975 .map_err(|e| {
976 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
977 })?,
978 credential: None,
979 retry_manager,
980 cancellation_token: CancellationToken::new(),
981 environment,
982 })
983 }
984
985 #[expect(clippy::too_many_arguments)]
992 pub fn with_credentials(
993 api_key: String,
994 api_secret: String,
995 api_passphrase: String,
996 base_url: String,
997 timeout_secs: u64,
998 max_retries: u32,
999 retry_delay_ms: u64,
1000 retry_delay_max_ms: u64,
1001 environment: OKXEnvironment,
1002 proxy_url: Option<String>,
1003 ) -> Result<Self, OKXHttpError> {
1004 let retry_config = RetryConfig {
1005 max_retries,
1006 initial_delay_ms: retry_delay_ms,
1007 max_delay_ms: retry_delay_max_ms,
1008 backoff_factor: 2.0,
1009 jitter_ms: 1000,
1010 operation_timeout_ms: Some(60_000),
1011 immediate_first: false,
1012 max_elapsed_ms: Some(180_000),
1013 };
1014
1015 let retry_manager = RetryManager::new(retry_config);
1016
1017 Ok(Self {
1018 clock: get_atomic_clock_realtime(),
1019 base_url,
1020 client: HttpClient::builder()
1021 .redirect_policy(HttpRedirectPolicy::Reject)
1022 .headers(Self::default_headers(environment))
1023 .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
1024 .keyed_quotas(Self::rate_limiter_quotas())
1025 .default_quota(*OKX_REST_QUOTA)
1026 .timeout_secs(timeout_secs)
1027 .maybe_proxy_url(proxy_url)
1028 .build()
1029 .map_err(|e| {
1030 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
1031 })?,
1032 credential: Some(Credential::new(api_key, api_secret, api_passphrase)),
1033 retry_manager,
1034 cancellation_token: CancellationToken::new(),
1035 environment,
1036 })
1037 }
1038
1039 fn default_headers(environment: OKXEnvironment) -> HashMap<String, String> {
1041 let mut headers: HashMap<String, String> =
1042 create_standard_nautilus_headers().into_iter().collect();
1043
1044 if environment == OKXEnvironment::Demo {
1045 headers.insert("x-simulated-trading".to_string(), "1".to_string());
1046 }
1047
1048 headers
1049 }
1050
1051 fn sign_request(
1057 &self,
1058 method: &Method,
1059 path: &str,
1060 body: Option<&[u8]>,
1061 now: Timestamp,
1062 ) -> Result<HashMap<String, String>, OKXHttpError> {
1063 let credential = match self.credential.as_ref() {
1064 Some(c) => c,
1065 None => return Err(OKXHttpError::MissingCredentials),
1066 };
1067
1068 let api_key = credential.api_key().to_string();
1069 let api_passphrase = credential.api_passphrase().to_string();
1070
1071 let timestamp = format!("{now:.3}");
1073 let signature = credential.sign_bytes(×tamp, method.as_str(), path, body);
1074
1075 let mut headers = HashMap::new();
1076 headers.insert("OK-ACCESS-KEY".to_string(), api_key);
1077 headers.insert("OK-ACCESS-PASSPHRASE".to_string(), api_passphrase);
1078 headers.insert("OK-ACCESS-TIMESTAMP".to_string(), timestamp);
1079 headers.insert("OK-ACCESS-SIGN".to_string(), signature);
1080
1081 Ok(headers)
1082 }
1083
1084 async fn send_request<T: DeserializeOwned, P: Serialize>(
1100 &self,
1101 method: Method,
1102 path: &str,
1103 params: Option<&P>,
1104 body: Option<Vec<u8>>,
1105 authenticate: bool,
1106 ) -> Result<Vec<T>, OKXHttpError> {
1107 let query_string = params
1108 .map(serde_urlencoded::to_string)
1109 .transpose()
1110 .map_err(|e| {
1111 OKXHttpError::RequestSerialization(format!("Failed to serialize params: {e}"))
1112 })?
1113 .unwrap_or_default();
1114 let full_path = if query_string.is_empty() {
1115 path.to_string()
1116 } else {
1117 format!("{path}?{query_string}")
1118 };
1119 let url = format!("{}{full_path}", self.base_url);
1120 let accepts_partial_success = matches!(
1121 path,
1122 "/api/v5/trade/batch-orders"
1123 | "/api/v5/trade/amend-batch-orders"
1124 | "/api/v5/trade/cancel-batch-orders"
1125 );
1126
1127 let rate_keys: Vec<String> = Self::rate_limit_keys(path)
1129 .into_iter()
1130 .map(|k| k.to_string())
1131 .collect();
1132
1133 let operation = || {
1134 let url = url.clone();
1135 let method = method.clone();
1136 let body = body.clone();
1137 let rate_keys = rate_keys.clone();
1138 let full_path = full_path.clone();
1139
1140 async move {
1141 let mut headers = if authenticate {
1142 let now = self.clock.get_time_ns().to_datetime_utc();
1143 self.sign_request(&method, &full_path, body.as_deref(), now)?
1144 } else {
1145 HashMap::new()
1146 };
1147
1148 if body.is_some() {
1150 headers.insert("Content-Type".to_string(), "application/json".to_string());
1151 }
1152
1153 let resp = self
1154 .client
1155 .request_with_params::<()>(
1156 method.clone(),
1157 url,
1158 None,
1159 Some(headers),
1160 body,
1161 None,
1162 Some(rate_keys),
1163 )
1164 .await?;
1165
1166 log::trace!("Response: {resp:?}");
1167 let now = self.clock.get_time_ns().to_datetime_utc();
1168 let retry_after = retry_after(&resp.headers, now);
1169
1170 if resp.status.is_success() {
1171 let okx_response: OKXResponse<T> = deserialize_okx_response(&resp.body)?;
1172
1173 if okx_response.code != OKX_SUCCESS_CODE
1174 && !(accepts_partial_success
1175 && okx_response.code == OKX_PARTIAL_SUCCESS_CODE)
1176 {
1177 let error_code = resolve_okx_error_code(&resp.body, &okx_response.code);
1178 let message = resolve_okx_error_message(&resp.body, &okx_response.msg);
1179 return Err(OKXHttpError::from_venue_response(
1180 error_code,
1181 message,
1182 retry_after,
1183 ));
1184 }
1185
1186 Ok(okx_response.data)
1187 } else {
1188 let status = StatusCode::from_u16(resp.status.as_u16())
1189 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1190 let error_body = String::from_utf8_lossy(&resp.body);
1191 if status == StatusCode::NOT_FOUND {
1192 log::debug!("HTTP 404 with body: {error_body}");
1193 } else {
1194 log::debug!(
1195 "HTTP error {} with body: {error_body}",
1196 resp.status.as_str()
1197 );
1198 }
1199
1200 if let Ok(envelope) = serde_json::from_slice::<OKXErrorEnvelope>(&resp.body) {
1201 let error_code = resolve_okx_error_code(&resp.body, &envelope.code);
1202 let message = resolve_okx_error_message(&resp.body, &envelope.msg);
1203 return Err(OKXHttpError::from_venue_response(
1204 error_code,
1205 message,
1206 retry_after,
1207 ));
1208 }
1209
1210 if status.as_u16() >= 500 || status == StatusCode::TOO_MANY_REQUESTS {
1211 Err(OKXHttpError::RetryableStatus {
1212 status,
1213 body: error_body.to_string(),
1214 retry_after,
1215 })
1216 } else {
1217 Err(OKXHttpError::UnexpectedStatus {
1218 status,
1219 body: error_body.to_string(),
1220 })
1221 }
1222 }
1223 }
1224 };
1225
1226 let is_order_mutation = method == Method::POST
1243 && matches!(
1244 path,
1245 "/api/v5/trade/order"
1246 | "/api/v5/trade/batch-orders"
1247 | "/api/v5/trade/order-algo"
1248 | "/api/v5/trade/amend-algos"
1249 | "/api/v5/sprd/order"
1250 );
1251 let should_retry = |error: &OKXHttpError| !is_order_mutation && error.is_retryable();
1252
1253 let create_error = |error: RetryError| -> OKXHttpError {
1254 match error {
1255 RetryError::Canceled => {
1256 OKXHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
1257 }
1258 RetryError::OperationTimeout { timeout_ms } => {
1259 OKXHttpError::OperationTimeout { timeout_ms }
1260 }
1261 RetryError::InvalidConfiguration { message } => {
1262 OKXHttpError::ValidationError(message)
1263 }
1264 error @ RetryError::ElapsedBudgetExceeded { .. } => {
1265 OKXHttpError::RetryBudgetExceeded(error.to_string())
1266 }
1267 }
1268 };
1269
1270 self.retry_manager
1271 .invocation(path, operation, should_retry, create_error)
1272 .retry_delay(&OKXHttpError::retry_after)
1273 .cancellation_token(&self.cancellation_token)
1274 .execute()
1275 .await
1276 }
1277
1278 pub async fn set_position_mode(
1289 &self,
1290 params: SetPositionModeParams,
1291 ) -> Result<Vec<serde_json::Value>, OKXHttpError> {
1292 let path = "/api/v5/account/set-position-mode";
1293 let body = serde_json::to_vec(¶ms)?;
1294 self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
1295 .await
1296 }
1297
1298 pub async fn activate_feature(
1315 &self,
1316 params: ActivateFeatureParams,
1317 ) -> Result<Vec<serde_json::Value>, OKXHttpError> {
1318 let path = "/api/v5/account/activate-feature";
1319 let body = serde_json::to_vec(¶ms)?;
1320 self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
1321 .await
1322 }
1323
1324 pub async fn get_position_tiers(
1335 &self,
1336 params: GetPositionTiersParams,
1337 ) -> Result<Vec<OKXPositionTier>, OKXHttpError> {
1338 self.send_request(
1339 Method::GET,
1340 "/api/v5/public/position-tiers",
1341 Some(¶ms),
1342 None,
1343 false,
1344 )
1345 .await
1346 }
1347
1348 pub async fn get_instruments(
1359 &self,
1360 params: GetInstrumentsParams,
1361 ) -> Result<Vec<OKXInstrument>, OKXHttpError> {
1362 self.send_request(
1363 Method::GET,
1364 "/api/v5/public/instruments",
1365 Some(¶ms),
1366 None,
1367 false,
1368 )
1369 .await
1370 }
1371
1372 pub async fn get_account_instruments(
1383 &self,
1384 params: GetInstrumentsParams,
1385 ) -> Result<Vec<OKXInstrument>, OKXHttpError> {
1386 self.send_request(
1387 Method::GET,
1388 "/api/v5/account/instruments",
1389 Some(¶ms),
1390 None,
1391 true,
1392 )
1393 .await
1394 }
1395
1396 pub async fn get_spreads(
1407 &self,
1408 params: GetSpreadsParams,
1409 ) -> Result<Vec<OKXSpread>, OKXHttpError> {
1410 self.send_request(
1411 Method::GET,
1412 "/api/v5/sprd/spreads",
1413 Some(¶ms),
1414 None,
1415 false,
1416 )
1417 .await
1418 }
1419
1420 pub async fn place_spread_order(
1430 &self,
1431 request: OKXPlaceSpreadOrderRequest,
1432 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
1433 let body = serde_json::to_vec(&request)?;
1434
1435 self.send_request(
1436 Method::POST,
1437 "/api/v5/sprd/order",
1438 None::<&()>,
1439 Some(body),
1440 true,
1441 )
1442 .await
1443 }
1444
1445 pub async fn cancel_spread_order(
1455 &self,
1456 request: OKXCancelSpreadOrderRequest,
1457 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
1458 let body = serde_json::to_vec(&request)?;
1459
1460 self.send_request(
1461 Method::POST,
1462 "/api/v5/sprd/cancel-order",
1463 None::<&()>,
1464 Some(body),
1465 true,
1466 )
1467 .await
1468 }
1469
1470 pub async fn cancel_all_spread_orders(
1480 &self,
1481 request: OKXCancelAllSpreadOrdersRequest,
1482 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
1483 let body = serde_json::to_vec(&request)?;
1484
1485 self.send_request(
1486 Method::POST,
1487 "/api/v5/sprd/mass-cancel",
1488 None::<&()>,
1489 Some(body),
1490 true,
1491 )
1492 .await
1493 }
1494
1495 pub async fn get_spread_order(
1505 &self,
1506 params: GetSpreadOrderParams,
1507 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1508 self.send_request(Method::GET, "/api/v5/sprd/order", Some(¶ms), None, true)
1509 .await
1510 }
1511
1512 pub async fn get_spread_orders_pending(
1522 &self,
1523 params: GetSpreadOrdersParams,
1524 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1525 self.send_request(
1526 Method::GET,
1527 "/api/v5/sprd/orders-pending",
1528 Some(¶ms),
1529 None,
1530 true,
1531 )
1532 .await
1533 }
1534
1535 pub async fn get_spread_orders_history(
1545 &self,
1546 params: GetSpreadOrdersParams,
1547 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1548 self.send_request(
1549 Method::GET,
1550 "/api/v5/sprd/orders-history",
1551 Some(¶ms),
1552 None,
1553 true,
1554 )
1555 .await
1556 }
1557
1558 pub async fn get_spread_trades(
1568 &self,
1569 params: GetSpreadTradesParams,
1570 ) -> Result<Vec<OKXSpreadTrade>, OKXHttpError> {
1571 self.send_request(
1572 Method::GET,
1573 "/api/v5/sprd/trades",
1574 Some(¶ms),
1575 None,
1576 true,
1577 )
1578 .await
1579 }
1580
1581 pub async fn get_event_contract_series(
1591 &self,
1592 params: GetEventContractSeriesParams,
1593 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
1594 self.send_request(
1595 Method::GET,
1596 "/api/v5/public/event-contract/series",
1597 Some(¶ms),
1598 None,
1599 false,
1600 )
1601 .await
1602 }
1603
1604 pub async fn get_event_contract_events(
1614 &self,
1615 params: GetEventContractEventsParams,
1616 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
1617 self.send_request(
1618 Method::GET,
1619 "/api/v5/public/event-contract/events",
1620 Some(¶ms),
1621 None,
1622 false,
1623 )
1624 .await
1625 }
1626
1627 pub async fn get_event_contract_markets(
1637 &self,
1638 params: GetEventContractMarketsParams,
1639 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
1640 self.send_request(
1641 Method::GET,
1642 "/api/v5/public/event-contract/markets",
1643 Some(¶ms),
1644 None,
1645 false,
1646 )
1647 .await
1648 }
1649
1650 pub async fn get_option_summary(
1660 &self,
1661 params: GetOptionSummaryParams,
1662 ) -> Result<Vec<OKXOptionSummary>, OKXHttpError> {
1663 self.send_request(
1664 Method::GET,
1665 "/api/v5/public/opt-summary",
1666 Some(¶ms),
1667 None,
1668 false,
1669 )
1670 .await
1671 }
1672
1673 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
1687 let response: Vec<OKXServerTime> = self
1688 .send_request::<_, ()>(Method::GET, "/api/v5/public/time", None, None, false)
1689 .await?;
1690 response
1691 .first()
1692 .map(|t| t.ts)
1693 .ok_or(OKXHttpError::EmptyResponse)
1694 }
1695
1696 pub async fn get_mark_price(
1710 &self,
1711 params: GetMarkPriceParams,
1712 ) -> Result<Vec<OKXMarkPrice>, OKXHttpError> {
1713 self.send_request(
1714 Method::GET,
1715 "/api/v5/public/mark-price",
1716 Some(¶ms),
1717 None,
1718 false,
1719 )
1720 .await
1721 }
1722
1723 pub async fn get_price_limit(
1734 &self,
1735 params: GetPriceLimitParams,
1736 ) -> Result<Vec<OKXPriceLimit>, OKXHttpError> {
1737 self.send_request(
1738 Method::GET,
1739 "/api/v5/public/price-limit",
1740 Some(¶ms),
1741 None,
1742 false,
1743 )
1744 .await
1745 }
1746
1747 pub async fn get_index_tickers(
1757 &self,
1758 params: GetIndexTickerParams,
1759 ) -> Result<Vec<OKXIndexTicker>, OKXHttpError> {
1760 self.send_request(
1761 Method::GET,
1762 "/api/v5/market/index-tickers",
1763 Some(¶ms),
1764 None,
1765 false,
1766 )
1767 .await
1768 }
1769
1770 pub async fn get_history_trades(
1780 &self,
1781 params: GetTradesParams,
1782 ) -> Result<Vec<OKXTrade>, OKXHttpError> {
1783 self.send_request(
1784 Method::GET,
1785 "/api/v5/market/history-trades",
1786 Some(¶ms),
1787 None,
1788 false,
1789 )
1790 .await
1791 }
1792
1793 pub async fn get_order_book(
1803 &self,
1804 params: GetOrderBookParams,
1805 ) -> Result<Vec<OKXOrderBookSnapshot>, OKXHttpError> {
1806 self.send_request(
1807 Method::GET,
1808 "/api/v5/market/books",
1809 Some(¶ms),
1810 None,
1811 false,
1812 )
1813 .await
1814 }
1815
1816 pub async fn get_rpi_order_book(
1826 &self,
1827 params: GetRpiOrderBookParams,
1828 ) -> Result<Vec<OKXRpiOrderBookSnapshot>, OKXHttpError> {
1829 self.send_request(
1830 Method::GET,
1831 "/api/v5/market/books-rpi",
1832 Some(¶ms),
1833 None,
1834 false,
1835 )
1836 .await
1837 }
1838
1839 pub async fn get_funding_rate_history(
1849 &self,
1850 params: GetFundingRateHistoryParams,
1851 ) -> Result<Vec<OKXFundingRateHistory>, OKXHttpError> {
1852 self.send_request(
1853 Method::GET,
1854 "/api/v5/public/funding-rate-history",
1855 Some(¶ms),
1856 None,
1857 false,
1858 )
1859 .await
1860 }
1861
1862 pub async fn get_candles(
1872 &self,
1873 params: GetCandlesticksParams,
1874 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1875 self.send_request(
1876 Method::GET,
1877 "/api/v5/market/candles",
1878 Some(¶ms),
1879 None,
1880 false,
1881 )
1882 .await
1883 }
1884
1885 pub async fn get_history_candles(
1895 &self,
1896 params: GetCandlesticksParams,
1897 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1898 self.send_request(
1899 Method::GET,
1900 "/api/v5/market/history-candles",
1901 Some(¶ms),
1902 None,
1903 false,
1904 )
1905 .await
1906 }
1907
1908 pub async fn get_account_configuration(
1922 &self,
1923 ) -> Result<Vec<OKXAccountConfiguration>, OKXHttpError> {
1924 let path = "/api/v5/account/config";
1925 self.send_request::<_, ()>(Method::GET, path, None, None, true)
1926 .await
1927 }
1928
1929 pub async fn get_balance(&self) -> Result<Vec<OKXAccount>, OKXHttpError> {
1940 let path = "/api/v5/account/balance";
1941 self.send_request::<_, ()>(Method::GET, path, None, None, true)
1942 .await
1943 }
1944
1945 pub async fn get_account_bills(
1959 &self,
1960 params: GetAccountBillsParams,
1961 ) -> Result<Vec<OKXAccountBill>, OKXHttpError> {
1962 self.send_request(
1963 Method::GET,
1964 "/api/v5/account/bills",
1965 Some(¶ms),
1966 None,
1967 true,
1968 )
1969 .await
1970 }
1971
1972 pub async fn get_account_bills_archive(
1986 &self,
1987 params: GetAccountBillsParams,
1988 ) -> Result<Vec<OKXAccountBill>, OKXHttpError> {
1989 self.send_request(
1990 Method::GET,
1991 "/api/v5/account/bills-archive",
1992 Some(¶ms),
1993 None,
1994 true,
1995 )
1996 .await
1997 }
1998
1999 pub async fn get_trade_fee(
2014 &self,
2015 params: GetTradeFeeParams,
2016 ) -> Result<Vec<OKXFeeRate>, OKXHttpError> {
2017 if params.group_id.is_some() && (params.inst_id.is_some() || params.inst_family.is_some()) {
2018 return Err(OKXHttpError::ValidationError(
2019 "group_id cannot be combined with inst_id or inst_family".to_string(),
2020 ));
2021 }
2022
2023 self.send_request(
2024 Method::GET,
2025 "/api/v5/account/trade-fee",
2026 Some(¶ms),
2027 None,
2028 true,
2029 )
2030 .await
2031 }
2032
2033 pub async fn get_order(
2043 &self,
2044 params: GetOrderParams,
2045 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
2046 self.send_request(
2047 Method::GET,
2048 "/api/v5/trade/order",
2049 Some(¶ms),
2050 None,
2051 true,
2052 )
2053 .await
2054 }
2055
2056 pub async fn get_algo_order(
2066 &self,
2067 params: GetAlgoOrderParams,
2068 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
2069 self.send_request(
2070 Method::GET,
2071 "/api/v5/trade/order-algo",
2072 Some(¶ms),
2073 None,
2074 true,
2075 )
2076 .await
2077 }
2078
2079 pub async fn get_orders_pending(
2089 &self,
2090 params: GetOrderListParams,
2091 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
2092 self.send_request(
2093 Method::GET,
2094 "/api/v5/trade/orders-pending",
2095 Some(¶ms),
2096 None,
2097 true,
2098 )
2099 .await
2100 }
2101
2102 pub async fn get_orders_history(
2112 &self,
2113 params: GetOrderHistoryParams,
2114 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
2115 self.send_request(
2116 Method::GET,
2117 "/api/v5/trade/orders-history",
2118 Some(¶ms),
2119 None,
2120 true,
2121 )
2122 .await
2123 }
2124
2125 pub async fn get_order_algo_pending(
2131 &self,
2132 params: GetAlgoOrdersParams,
2133 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
2134 self.send_request(
2135 Method::GET,
2136 "/api/v5/trade/orders-algo-pending",
2137 Some(¶ms),
2138 None,
2139 true,
2140 )
2141 .await
2142 }
2143
2144 pub async fn get_order_algo_history(
2150 &self,
2151 params: GetAlgoOrdersParams,
2152 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
2153 self.send_request(
2154 Method::GET,
2155 "/api/v5/trade/orders-algo-history",
2156 Some(¶ms),
2157 None,
2158 true,
2159 )
2160 .await
2161 }
2162
2163 pub async fn get_fills(
2173 &self,
2174 params: GetTransactionDetailsParams,
2175 ) -> Result<Vec<OKXTransactionDetail>, OKXHttpError> {
2176 self.send_request(
2177 Method::GET,
2178 "/api/v5/trade/fills",
2179 Some(¶ms),
2180 None,
2181 true,
2182 )
2183 .await
2184 }
2185
2186 pub async fn get_fills_history(
2196 &self,
2197 params: GetTransactionDetailsParams,
2198 ) -> Result<Vec<OKXTransactionDetail>, OKXHttpError> {
2199 self.send_request(
2200 Method::GET,
2201 "/api/v5/trade/fills-history",
2202 Some(¶ms),
2203 None,
2204 true,
2205 )
2206 .await
2207 }
2208
2209 pub async fn get_positions(
2221 &self,
2222 params: GetPositionsParams,
2223 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
2224 self.send_request(
2225 Method::GET,
2226 "/api/v5/account/positions",
2227 Some(¶ms),
2228 None,
2229 true,
2230 )
2231 .await
2232 }
2233
2234 pub async fn get_positions_history(
2244 &self,
2245 params: GetPositionsHistoryParams,
2246 ) -> Result<Vec<OKXPositionHistory>, OKXHttpError> {
2247 self.send_request(
2248 Method::GET,
2249 "/api/v5/account/positions-history",
2250 Some(¶ms),
2251 None,
2252 true,
2253 )
2254 .await
2255 }
2256}
2257
2258#[derive(Debug)]
2263#[cfg_attr(
2264 feature = "python",
2265 pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
2266)]
2267#[cfg_attr(
2268 feature = "python",
2269 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
2270)]
2271pub struct OKXHttpClient {
2272 pub(crate) inner: Arc<OKXRawHttpClient>,
2273 pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
2274 trade_quote_ccy_lists: Arc<AtomicMap<Ustr, Vec<Ustr>>>,
2275 spot_trade_quote_ccy: Arc<Mutex<Option<Ustr>>>,
2276 cache_initialized: AtomicBool,
2277}
2278
2279impl Clone for OKXHttpClient {
2280 fn clone(&self) -> Self {
2281 let cache_initialized = AtomicBool::new(false);
2282
2283 let is_initialized = self.cache_initialized.load(Ordering::Acquire);
2284 if is_initialized {
2285 cache_initialized.store(true, Ordering::Release);
2286 }
2287
2288 Self {
2289 inner: self.inner.clone(),
2290 instruments_cache: self.instruments_cache.clone(),
2291 trade_quote_ccy_lists: self.trade_quote_ccy_lists.clone(),
2292 spot_trade_quote_ccy: self.spot_trade_quote_ccy.clone(),
2293 cache_initialized,
2294 }
2295 }
2296}
2297
2298impl Default for OKXHttpClient {
2299 fn default() -> Self {
2300 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
2301 .expect("Failed to create default OKXHttpClient")
2302 }
2303}
2304
2305impl OKXHttpClient {
2306 pub fn new(
2316 base_url: Option<String>,
2317 timeout_secs: u64,
2318 max_retries: u32,
2319 retry_delay_ms: u64,
2320 retry_delay_max_ms: u64,
2321 environment: OKXEnvironment,
2322 proxy_url: Option<String>,
2323 ) -> anyhow::Result<Self> {
2324 Ok(Self {
2325 inner: Arc::new(OKXRawHttpClient::new(
2326 base_url,
2327 timeout_secs,
2328 max_retries,
2329 retry_delay_ms,
2330 retry_delay_max_ms,
2331 environment,
2332 proxy_url,
2333 )?),
2334 instruments_cache: Arc::new(AtomicMap::new()),
2335 trade_quote_ccy_lists: Arc::new(AtomicMap::new()),
2336 spot_trade_quote_ccy: Arc::new(Mutex::new(None)),
2337 cache_initialized: AtomicBool::new(false),
2338 })
2339 }
2340
2341 fn generate_ts_init(&self) -> UnixNanos {
2343 self.inner.clock.get_time_ns()
2344 }
2345
2346 pub fn from_env() -> anyhow::Result<Self> {
2353 Self::with_credentials(
2354 None,
2355 None,
2356 None,
2357 None,
2358 60,
2359 3,
2360 1000,
2361 10_000,
2362 OKXEnvironment::Live,
2363 None,
2364 )
2365 }
2366
2367 #[expect(clippy::too_many_arguments)]
2374 pub fn with_credentials(
2375 api_key: Option<String>,
2376 api_secret: Option<String>,
2377 api_passphrase: Option<String>,
2378 base_url: Option<String>,
2379 timeout_secs: u64,
2380 max_retries: u32,
2381 retry_delay_ms: u64,
2382 retry_delay_max_ms: u64,
2383 environment: OKXEnvironment,
2384 proxy_url: Option<String>,
2385 ) -> anyhow::Result<Self> {
2386 let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
2387 let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
2388 let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
2389 let base_url = base_url.unwrap_or(OKX_HTTP_URL.to_string());
2390
2391 Ok(Self {
2392 inner: Arc::new(OKXRawHttpClient::with_credentials(
2393 api_key,
2394 api_secret,
2395 api_passphrase,
2396 base_url,
2397 timeout_secs,
2398 max_retries,
2399 retry_delay_ms,
2400 retry_delay_max_ms,
2401 environment,
2402 proxy_url,
2403 )?),
2404 instruments_cache: Arc::new(AtomicMap::new()),
2405 trade_quote_ccy_lists: Arc::new(AtomicMap::new()),
2406 spot_trade_quote_ccy: Arc::new(Mutex::new(None)),
2407 cache_initialized: AtomicBool::new(false),
2408 })
2409 }
2410
2411 fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
2417 self.instruments_cache
2418 .get_cloned(&symbol)
2419 .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not in cache"))
2420 }
2421
2422 fn instrument_from_cache_by_id(
2423 &self,
2424 instrument_id: InstrumentId,
2425 ) -> anyhow::Result<InstrumentAny> {
2426 self.instruments_cache
2427 .get_cloned(&instrument_id.symbol.inner())
2428 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
2429 }
2430
2431 pub fn cancel_all_requests(&self) {
2433 self.inner.cancel_all_requests();
2434 }
2435
2436 pub fn cancellation_token(&self) -> &CancellationToken {
2438 self.inner.cancellation_token()
2439 }
2440
2441 pub async fn get_orders_pending(
2447 &self,
2448 params: GetOrderListParams,
2449 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
2450 self.inner.get_orders_pending(params).await
2451 }
2452
2453 pub async fn get_order_algo_pending(
2459 &self,
2460 params: GetAlgoOrdersParams,
2461 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
2462 self.inner.get_order_algo_pending(params).await
2463 }
2464
2465 pub async fn get_positions(
2471 &self,
2472 params: GetPositionsParams,
2473 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
2474 self.inner.get_positions(params).await
2475 }
2476
2477 pub fn base_url(&self) -> &str {
2479 self.inner.base_url.as_str()
2480 }
2481
2482 pub fn api_key(&self) -> Option<&str> {
2484 self.inner.credential.as_ref().map(Credential::api_key)
2485 }
2486
2487 #[must_use]
2489 pub fn api_key_masked(&self) -> Option<String> {
2490 self.inner
2491 .credential
2492 .as_ref()
2493 .map(Credential::api_key_masked)
2494 }
2495
2496 #[must_use]
2498 pub fn is_demo(&self) -> bool {
2499 self.inner.environment == OKXEnvironment::Demo
2500 }
2501
2502 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
2510 self.inner.get_server_time().await
2511 }
2512
2513 #[must_use]
2517 pub fn is_initialized(&self) -> bool {
2518 self.cache_initialized.load(Ordering::Acquire)
2519 }
2520
2521 #[must_use]
2524 pub fn get_cached_symbols(&self) -> Vec<String> {
2525 self.instruments_cache
2526 .load()
2527 .keys()
2528 .map(ToString::to_string)
2529 .collect()
2530 }
2531
2532 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
2536 self.instruments_cache.rcu(|m| {
2537 for inst in instruments {
2538 m.insert(inst.raw_symbol().inner(), inst.clone());
2539 }
2540 });
2541 self.cache_initialized.store(true, Ordering::Release);
2542 }
2543
2544 pub fn cache_instrument(&self, instrument: InstrumentAny) {
2548 self.instruments_cache
2549 .insert(instrument.raw_symbol().inner(), instrument);
2550 self.cache_initialized.store(true, Ordering::Release);
2551 }
2552
2553 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
2555 self.instruments_cache.get_cloned(symbol)
2556 }
2557
2558 pub(crate) fn is_spread(&self, instrument_id: InstrumentId) -> bool {
2559 self.instruments_cache
2560 .load()
2561 .get(&instrument_id.symbol.inner())
2562 .map_or_else(
2563 || is_okx_spread_symbol(instrument_id.symbol.as_str()),
2564 InstrumentAny::is_spread,
2565 )
2566 }
2567
2568 pub fn set_spot_trade_quote_ccy(&self, ccy: Option<String>) {
2570 *self
2571 .spot_trade_quote_ccy
2572 .lock()
2573 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2574 ccy.map(|value| Ustr::from(value.as_str()));
2575 }
2576
2577 pub fn cache_trade_quote_ccy_lists(
2579 &self,
2580 mappings: impl IntoIterator<Item = (Ustr, Vec<Ustr>)>,
2581 ) {
2582 let entries: Vec<_> = mappings.into_iter().collect();
2583 self.trade_quote_ccy_lists.rcu(|m| {
2584 for (inst_id, list) in &entries {
2585 m.insert(*inst_id, list.clone());
2586 }
2587 });
2588 }
2589
2590 #[must_use]
2592 pub fn trade_quote_ccy_lists_snapshot(&self) -> Vec<(Ustr, Vec<Ustr>)> {
2593 self.trade_quote_ccy_lists
2594 .load()
2595 .iter()
2596 .map(|(inst_id, list)| (*inst_id, list.clone()))
2597 .collect()
2598 }
2599
2600 fn cache_trade_quote_ccy_list_from_instrument(&self, instrument: &OKXInstrument) {
2601 if !instrument.trade_quote_ccy_list.is_empty() {
2602 self.trade_quote_ccy_lists
2603 .insert(instrument.inst_id, instrument.trade_quote_ccy_list.clone());
2604 }
2605 }
2606
2607 fn resolve_spot_trade_quote_ccy(
2608 &self,
2609 instrument_type: OKXInstrumentType,
2610 inst_id: Ustr,
2611 ) -> Result<Option<Ustr>, OKXHttpError> {
2612 let configured = *self
2613 .spot_trade_quote_ccy
2614 .lock()
2615 .unwrap_or_else(std::sync::PoisonError::into_inner);
2616 let available = self
2617 .trade_quote_ccy_lists
2618 .load()
2619 .get(&inst_id)
2620 .cloned()
2621 .unwrap_or_default();
2622 spot_trade_quote_ccy_wire_value(
2623 instrument_type,
2624 configured.as_ref().map(Ustr::as_str),
2625 &available,
2626 )
2627 .map_err(OKXHttpError::ValidationError)
2628 }
2629
2630 pub async fn request_account_state(
2639 &self,
2640 account_id: AccountId,
2641 account_type: AccountType,
2642 ) -> anyhow::Result<AccountState> {
2643 let resp = self
2644 .inner
2645 .get_balance()
2646 .await
2647 .map_err(|e| anyhow::anyhow!(e))?;
2648
2649 let ts_init = self.generate_ts_init();
2650 let raw = resp
2651 .first()
2652 .ok_or_else(|| anyhow::anyhow!("No account state returned from OKX"))?;
2653 let account_state = parse_account_state(raw, account_id, account_type, ts_init)?;
2654
2655 Ok(account_state)
2656 }
2657
2658 pub async fn set_position_mode(&self, position_mode: OKXPositionMode) -> anyhow::Result<()> {
2671 let mut params = SetPositionModeParamsBuilder::default();
2672 params.pos_mode(position_mode);
2673 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2674
2675 match self.inner.set_position_mode(params).await {
2676 Ok(_) => Ok(()),
2677 Err(e) => {
2678 if let OKXHttpError::OkxError {
2679 error_code,
2680 message,
2681 } = &e
2682 && error_code == "50115"
2683 {
2684 log::warn!(
2685 "Account does not support position mode setting (derivatives trading not enabled): {message}"
2686 );
2687 return Ok(()); }
2689 anyhow::bail!(e)
2690 }
2691 }
2692 }
2693
2694 pub async fn activate_feature(&self, feature: &str) -> anyhow::Result<()> {
2711 let params = ActivateFeatureParams {
2712 feature: feature.to_string(),
2713 };
2714
2715 self.inner
2716 .activate_feature(params)
2717 .await
2718 .map(|_| ())
2719 .map_err(Into::into)
2720 }
2721
2722 pub async fn refresh_account_trade_quote_ccy_lists(
2728 &self,
2729 instrument_type: OKXInstrumentType,
2730 instrument_family: Option<String>,
2731 ) -> anyhow::Result<()> {
2732 let mut params = GetInstrumentsParamsBuilder::default();
2733 params.inst_type(instrument_type);
2734 if let Some(family) = instrument_family {
2735 params.inst_family(family);
2736 }
2737
2738 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2739 let instruments = self
2740 .inner
2741 .get_account_instruments(params)
2742 .await
2743 .map_err(|e| anyhow::anyhow!(e))?;
2744 self.cache_trade_quote_ccy_lists(
2745 instruments
2746 .iter()
2747 .filter(|instrument| !instrument.trade_quote_ccy_list.is_empty())
2748 .map(|instrument| (instrument.inst_id, instrument.trade_quote_ccy_list.clone())),
2749 );
2750
2751 Ok(())
2752 }
2753
2754 pub async fn request_instruments(
2769 &self,
2770 instrument_type: OKXInstrumentType,
2771 instrument_family: Option<String>,
2772 ) -> anyhow::Result<(Vec<InstrumentAny>, Vec<(Ustr, u64)>)> {
2773 if instrument_type == OKXInstrumentType::Option && instrument_family.is_none() {
2774 anyhow::bail!(
2775 "option instruments require instrument_family (OKX instFamily), for example BTC-USD"
2776 );
2777 }
2778
2779 let resp = if instrument_type == OKXInstrumentType::Events {
2780 let series_ids = if let Some(series_id) = instrument_family.clone() {
2781 vec![series_id]
2782 } else {
2783 self.inner
2784 .get_event_contract_series(GetEventContractSeriesParams::default())
2785 .await
2786 .map_err(|e| anyhow::anyhow!(e))?
2787 .into_iter()
2788 .map(|series| series.series_id)
2789 .collect()
2790 };
2791
2792 let mut event_instruments = Vec::new();
2793
2794 for series_id in series_ids {
2795 let mut params = GetInstrumentsParamsBuilder::default();
2796 params.inst_type(OKXInstrumentType::Events);
2797 params.series_id(series_id);
2798 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2799 let mut page = self
2800 .inner
2801 .get_instruments(params)
2802 .await
2803 .map_err(|e| anyhow::anyhow!(e))?;
2804 event_instruments.append(&mut page);
2805 }
2806 event_instruments
2807 } else {
2808 let mut params = GetInstrumentsParamsBuilder::default();
2809 params.inst_type(instrument_type);
2810
2811 if let Some(family) = instrument_family.clone() {
2812 params.inst_family(family);
2813 }
2814
2815 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2816
2817 self.inner
2818 .get_instruments(params)
2819 .await
2820 .map_err(|e| anyhow::anyhow!(e))?
2821 };
2822
2823 let ts_init = self.generate_ts_init();
2824
2825 let mut instruments: Vec<InstrumentAny> = Vec::new();
2826 let mut inst_id_codes: Vec<(Ustr, u64)> = Vec::new();
2827 let mut trade_quote_ccy_lists: Vec<(Ustr, Vec<Ustr>)> = Vec::new();
2828
2829 for inst in &resp {
2830 if let Some(code) = inst.inst_id_code {
2832 inst_id_codes.push((inst.inst_id, code));
2833 }
2834
2835 if !inst.trade_quote_ccy_list.is_empty() {
2836 trade_quote_ccy_lists.push((inst.inst_id, inst.trade_quote_ccy_list.clone()));
2837 }
2838
2839 if inst.state == OKXInstrumentStatus::Preopen {
2842 continue;
2843 }
2844
2845 match parse_instrument_any(inst, None, None, ts_init) {
2846 Ok(Some(instrument_any)) => {
2847 instruments.push(instrument_any);
2848 }
2849 Ok(None) => {
2850 }
2852 Err(e) => {
2853 log::warn!("Failed to parse instrument {}: {e}", inst.inst_id);
2854 }
2855 }
2856 }
2857
2858 self.cache_trade_quote_ccy_lists(trade_quote_ccy_lists);
2859
2860 Ok((instruments, inst_id_codes))
2861 }
2862
2863 pub async fn request_spread_instruments(
2869 &self,
2870 params: GetSpreadsParams,
2871 ) -> anyhow::Result<Vec<InstrumentAny>> {
2872 let resp = self
2873 .inner
2874 .get_spreads(params)
2875 .await
2876 .map_err(|e| anyhow::anyhow!(e))?;
2877
2878 let ts_init = self.generate_ts_init();
2879 let mut instruments = Vec::new();
2880
2881 for spread in &resp {
2882 match parse_spread_instrument(spread, None, None, ts_init) {
2883 Ok(instrument) => instruments.push(instrument),
2884 Err(e) => log::warn!("Failed to parse spread {}: {e}", spread.sprd_id),
2885 }
2886 }
2887
2888 Ok(instruments)
2889 }
2890
2891 pub async fn request_instrument(
2902 &self,
2903 instrument_id: InstrumentId,
2904 ) -> anyhow::Result<InstrumentAny> {
2905 let symbol = instrument_id.symbol.as_str();
2906 if self.is_spread(instrument_id) {
2907 let instrument = self.request_spread_instrument(symbol).await?;
2908 self.cache_instrument(instrument.clone());
2909 return Ok(instrument);
2910 }
2911
2912 let instrument_type = okx_instrument_type_from_symbol(symbol);
2913
2914 let resp = if instrument_type == OKXInstrumentType::Events {
2915 let series = self
2916 .inner
2917 .get_event_contract_series(GetEventContractSeriesParams::default())
2918 .await
2919 .map_err(|e| anyhow::anyhow!(e))?;
2920
2921 let mut matched = Vec::new();
2922
2923 for series in series {
2924 let mut params = GetInstrumentsParamsBuilder::default();
2925 params.inst_type(OKXInstrumentType::Events);
2926 params.series_id(series.series_id);
2927 params.inst_id(symbol);
2928 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2929
2930 let mut page = self
2931 .inner
2932 .get_instruments(params)
2933 .await
2934 .map_err(|e| anyhow::anyhow!(e))?;
2935 matched.append(&mut page);
2936 if !matched.is_empty() {
2937 break;
2938 }
2939 }
2940 matched
2941 } else {
2942 let mut params = GetInstrumentsParamsBuilder::default();
2943 params.inst_type(instrument_type);
2944 params.inst_id(symbol);
2945
2946 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2947
2948 self.inner
2949 .get_instruments(params)
2950 .await
2951 .map_err(|e| anyhow::anyhow!(e))?
2952 };
2953
2954 let raw_inst = resp
2955 .first()
2956 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2957 self.cache_trade_quote_ccy_list_from_instrument(raw_inst);
2958
2959 if raw_inst.state == OKXInstrumentStatus::Preopen {
2961 return Err(OKXInstrumentDefinitionError::new(
2962 symbol,
2963 anyhow::anyhow!("instrument is in pre-open state"),
2964 )
2965 .into());
2966 }
2967
2968 let ts_init = self.generate_ts_init();
2969 let Some(instrument) = parse_instrument_any(raw_inst, None, None, ts_init)
2970 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e))?
2971 else {
2972 return Err(OKXInstrumentDefinitionError::new(
2973 symbol,
2974 anyhow::anyhow!("unsupported instrument type"),
2975 )
2976 .into());
2977 };
2978
2979 self.cache_instrument(instrument.clone());
2980
2981 Ok(instrument)
2982 }
2983
2984 async fn request_spread_instrument(&self, symbol: &str) -> anyhow::Result<InstrumentAny> {
2985 let resp = self
2986 .inner
2987 .get_spreads(GetSpreadsParams {
2988 sprd_id: Some(symbol.to_string()),
2989 ..Default::default()
2990 })
2991 .await
2992 .map_err(|e| anyhow::anyhow!(e))?;
2993
2994 let raw_spread = resp
2995 .first()
2996 .ok_or_else(|| anyhow::anyhow!("Spread instrument {symbol} not found"))?;
2997 let ts_init = self.generate_ts_init();
2998
2999 parse_spread_instrument(raw_spread, None, None, ts_init)
3000 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e).into())
3001 }
3002
3003 pub async fn request_event_contract_series(
3009 &self,
3010 params: GetEventContractSeriesParams,
3011 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
3012 self.inner.get_event_contract_series(params).await
3013 }
3014
3015 pub async fn request_event_contract_events(
3021 &self,
3022 params: GetEventContractEventsParams,
3023 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
3024 self.inner.get_event_contract_events(params).await
3025 }
3026
3027 pub async fn request_event_contract_markets(
3033 &self,
3034 params: GetEventContractMarketsParams,
3035 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
3036 self.inner.get_event_contract_markets(params).await
3037 }
3038
3039 pub(crate) async fn request_option_chain_reference_price(
3045 &self,
3046 instrument_id: InstrumentId,
3047 ) -> anyhow::Result<Option<Price>> {
3048 let symbol = instrument_id.symbol.inner().as_str();
3049 let inst_family = extract_inst_family(symbol)?.to_string();
3050 let exp_time = Self::option_summary_exp_time(symbol)?;
3051 let summaries = self
3052 .inner
3053 .get_option_summary(GetOptionSummaryParams {
3054 inst_family,
3055 exp_time,
3056 })
3057 .await
3058 .map_err(|e| anyhow::anyhow!(e))?;
3059
3060 for summary in summaries {
3061 if summary.inst_type != OKXInstrumentType::Option || summary.inst_id != symbol {
3062 continue;
3063 }
3064
3065 let decimal = match Decimal::from_str(&summary.fwd_px) {
3066 Ok(decimal) if decimal > Decimal::ZERO => decimal,
3067 Ok(_) => return Ok(None),
3068 Err(e) => {
3069 log::warn!("Invalid OKX option-chain reference price for {instrument_id}: {e}");
3070 return Ok(None);
3071 }
3072 };
3073 return Price::from_decimal(decimal).map(Some).map_err(Into::into);
3074 }
3075
3076 Ok(None)
3077 }
3078
3079 pub async fn request_mark_price(
3085 &self,
3086 instrument_id: InstrumentId,
3087 ) -> anyhow::Result<MarkPriceUpdate> {
3088 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
3089 let mut params = GetMarkPriceParamsBuilder::default();
3090 params.inst_type(okx_instrument_type(&inst)?);
3091 params.inst_id(instrument_id.symbol.inner());
3092 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
3093
3094 let resp = self
3095 .inner
3096 .get_mark_price(params)
3097 .await
3098 .map_err(|e| anyhow::anyhow!(e))?;
3099
3100 let raw = resp
3101 .first()
3102 .ok_or_else(|| anyhow::anyhow!("No mark price returned from OKX"))?;
3103 let ts_init = self.generate_ts_init();
3104
3105 let mark_price =
3106 parse_mark_price_update(raw, instrument_id, inst.price_precision(), ts_init)
3107 .map_err(|e| anyhow::anyhow!(e))?;
3108 Ok(mark_price)
3109 }
3110
3111 pub async fn request_price_limit(
3117 &self,
3118 instrument_id: InstrumentId,
3119 ) -> anyhow::Result<OKXPriceLimit> {
3120 let mut params = GetPriceLimitParamsBuilder::default();
3121 params.inst_id(instrument_id.symbol.inner());
3122 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
3123
3124 let resp = self
3125 .inner
3126 .get_price_limit(params)
3127 .await
3128 .map_err(|e| anyhow::anyhow!(e))?;
3129
3130 resp.first()
3131 .cloned()
3132 .ok_or_else(|| anyhow::anyhow!("No price limit returned from OKX"))
3133 }
3134
3135 fn option_summary_exp_time(symbol: &str) -> anyhow::Result<Option<String>> {
3136 let parts: Vec<&str> = symbol.split('-').collect();
3137 anyhow::ensure!(
3138 parts.len() >= 5,
3139 "Expected OKX option symbol with expiry, received {symbol}"
3140 );
3141 Ok(Some(parts[2].to_string()))
3142 }
3143
3144 pub async fn request_index_price(
3150 &self,
3151 instrument_id: InstrumentId,
3152 ) -> anyhow::Result<IndexPriceUpdate> {
3153 let symbol = instrument_id.symbol.inner();
3155 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())?;
3156 let inst_id = format!("{base}-{quote}");
3157
3158 let mut params = GetIndexTickerParamsBuilder::default();
3159 params.inst_id(Ustr::from(&inst_id));
3160 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
3161
3162 let resp = self
3163 .inner
3164 .get_index_tickers(params)
3165 .await
3166 .map_err(|e| anyhow::anyhow!(e))?;
3167
3168 let raw = resp
3169 .first()
3170 .ok_or_else(|| anyhow::anyhow!("No index price returned from OKX"))?;
3171 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
3172 let ts_init = self.generate_ts_init();
3173
3174 let index_price =
3175 parse_index_price_update(raw, instrument_id, inst.price_precision(), ts_init)
3176 .map_err(|e| anyhow::anyhow!(e))?;
3177 Ok(index_price)
3178 }
3179
3180 pub async fn request_book_snapshot(
3186 &self,
3187 instrument_id: InstrumentId,
3188 depth: Option<u32>,
3189 ) -> anyhow::Result<OrderBook> {
3190 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3191 let price_precision = inst.price_precision();
3192 let size_precision = inst.size_precision();
3193
3194 let params = GetOrderBookParams {
3195 inst_id: instrument_id.symbol.to_string(),
3196 sz: depth,
3197 };
3198
3199 let resp = self
3200 .inner
3201 .get_order_book(params)
3202 .await
3203 .map_err(|e| anyhow::anyhow!(e))?;
3204
3205 let snapshot = resp
3206 .first()
3207 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
3208
3209 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
3210 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
3211
3212 for (i, level) in snapshot.bids.iter().enumerate() {
3213 let price = parse_price(&level.0, price_precision)?;
3214 let size = parse_quantity(&level.1, size_precision)?;
3215 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
3216 book.add(order, 0, i as u64, ts_event);
3217 }
3218
3219 let bids_len = snapshot.bids.len();
3220
3221 for (i, level) in snapshot.asks.iter().enumerate() {
3222 let price = parse_price(&level.0, price_precision)?;
3223 let size = parse_quantity(&level.1, size_precision)?;
3224 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
3225 book.add(order, 0, (bids_len + i) as u64, ts_event);
3226 }
3227
3228 log::debug!(
3229 "Fetched order book for {} with {} bids and {} asks",
3230 instrument_id,
3231 snapshot.bids.len(),
3232 snapshot.asks.len(),
3233 );
3234
3235 Ok(book)
3236 }
3237
3238 pub async fn request_rpi_book_snapshot(
3244 &self,
3245 instrument_id: InstrumentId,
3246 depth: Option<u32>,
3247 ) -> anyhow::Result<OrderBook> {
3248 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3249 let price_precision = inst.price_precision();
3250 let size_precision = inst.size_precision();
3251 let params = GetRpiOrderBookParams {
3252 inst_id: instrument_id.symbol.to_string(),
3253 sz: depth,
3254 };
3255 let resp = self
3256 .inner
3257 .get_rpi_order_book(params)
3258 .await
3259 .map_err(|e| anyhow::anyhow!(e))?;
3260 let snapshot = resp
3261 .first()
3262 .ok_or_else(|| anyhow::anyhow!("No RPI order book returned from OKX"))?;
3263 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
3264 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
3265
3266 for (i, level) in snapshot.bids.iter().enumerate() {
3267 let price = Price::from_decimal_dp(level.0, price_precision)?;
3268 let size = Quantity::from_decimal_dp(level.1, size_precision)?;
3269 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
3270 book.add(order, 0, i as u64, ts_event);
3271 }
3272
3273 let bids_len = snapshot.bids.len();
3274 for (i, level) in snapshot.asks.iter().enumerate() {
3275 let price = Price::from_decimal_dp(level.0, price_precision)?;
3276 let size = Quantity::from_decimal_dp(level.1, size_precision)?;
3277 let index = (bids_len + i) as u64;
3278 let order = BookOrder::new(OrderSide::Sell, price, size, index);
3279 book.add(order, 0, index, ts_event);
3280 }
3281
3282 log::debug!(
3283 "Fetched RPI order book for {} with {} bids and {} asks",
3284 instrument_id,
3285 snapshot.bids.len(),
3286 snapshot.asks.len(),
3287 );
3288
3289 Ok(book)
3290 }
3291
3292 pub async fn request_orderbook_snapshot(
3298 &self,
3299 instrument_id: InstrumentId,
3300 depth: Option<u32>,
3301 ) -> anyhow::Result<OrderBookDeltas> {
3302 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3303 let price_precision = inst.price_precision();
3304 let size_precision = inst.size_precision();
3305
3306 let params = GetOrderBookParams {
3307 inst_id: instrument_id.symbol.to_string(),
3308 sz: depth,
3309 };
3310
3311 let resp = self
3312 .inner
3313 .get_order_book(params)
3314 .await
3315 .map_err(|e| anyhow::anyhow!(e))?;
3316
3317 let snapshot = resp
3318 .first()
3319 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
3320
3321 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
3322 let total_levels = snapshot.bids.len() + snapshot.asks.len();
3323 let mut deltas = Vec::with_capacity(total_levels + 1);
3324
3325 let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_event);
3326
3327 if total_levels == 0 {
3328 clear.flags |= RecordFlag::F_LAST as u8;
3329 }
3330 deltas.push(clear);
3331
3332 let mut processed = 0_usize;
3333
3334 for (i, level) in snapshot.bids.iter().enumerate() {
3335 let price = parse_price(&level.0, price_precision)?;
3336 let size = parse_quantity(&level.1, size_precision)?;
3337 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
3338 processed += 1;
3339 let mut flags = RecordFlag::F_SNAPSHOT as u8;
3340
3341 if processed == total_levels {
3342 flags |= RecordFlag::F_LAST as u8;
3343 }
3344 deltas.push(OrderBookDelta::new(
3345 instrument_id,
3346 BookAction::Add,
3347 order,
3348 flags,
3349 0,
3350 ts_event,
3351 ts_event,
3352 ));
3353 }
3354
3355 let bids_len = snapshot.bids.len();
3356
3357 for (i, level) in snapshot.asks.iter().enumerate() {
3358 let price = parse_price(&level.0, price_precision)?;
3359 let size = parse_quantity(&level.1, size_precision)?;
3360 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
3361 processed += 1;
3362 let mut flags = RecordFlag::F_SNAPSHOT as u8;
3363
3364 if processed == total_levels {
3365 flags |= RecordFlag::F_LAST as u8;
3366 }
3367 deltas.push(OrderBookDelta::new(
3368 instrument_id,
3369 BookAction::Add,
3370 order,
3371 flags,
3372 0,
3373 ts_event,
3374 ts_event,
3375 ));
3376 }
3377
3378 log::debug!(
3379 "Fetched order book snapshot for {} with {} bids and {} asks",
3380 instrument_id,
3381 snapshot.bids.len(),
3382 snapshot.asks.len(),
3383 );
3384
3385 OrderBookDeltas::new_checked(instrument_id, deltas)
3386 .context("failed to assemble OrderBookDeltas from OKX snapshot")
3387 }
3388
3389 pub async fn request_funding_rates(
3395 &self,
3396 instrument_id: InstrumentId,
3397 start: Option<Timestamp>,
3398 end: Option<Timestamp>,
3399 limit: Option<u32>,
3400 ) -> anyhow::Result<Vec<FundingRateUpdate>> {
3401 let mut params = GetFundingRateHistoryParams {
3402 inst_id: instrument_id.symbol.to_string(),
3403 ..Default::default()
3404 };
3405
3406 if let Some(start) = start {
3408 params.before = Some(start.as_millisecond().to_string());
3409 }
3410
3411 if let Some(end) = end {
3412 params.after = Some(end.as_millisecond().to_string());
3413 }
3414
3415 params.limit = limit;
3416
3417 let resp = self
3418 .inner
3419 .get_funding_rate_history(params)
3420 .await
3421 .map_err(|e| anyhow::anyhow!(e))?;
3422
3423 let mut rates = Vec::with_capacity(resp.len());
3424
3425 for window in resp.windows(2) {
3426 let raw = &window[0];
3427 let interval_millis = raw
3428 .funding_time
3429 .checked_sub(window[1].funding_time)
3430 .context("funding interval negative, funding rates out of order")?;
3431 let rate = parse_funding_rate(raw, instrument_id, Some(interval_millis))?;
3432 rates.push(rate);
3433 }
3434
3435 if let Some(last_raw) = resp.last() {
3436 let rate = parse_funding_rate(last_raw, instrument_id, None)?;
3438 rates.push(rate);
3439 }
3440
3441 rates.reverse();
3444
3445 log::debug!(
3446 "Fetched {} funding rates for {}",
3447 rates.len(),
3448 instrument_id,
3449 );
3450
3451 Ok(rates)
3452 }
3453
3454 pub async fn request_trades(
3460 &self,
3461 instrument_id: InstrumentId,
3462 start: Option<Timestamp>,
3463 end: Option<Timestamp>,
3464 limit: Option<u32>,
3465 ) -> anyhow::Result<Vec<TradeTick>> {
3466 const OKX_TRADES_MAX_LIMIT: u32 = 100;
3467 const MAX_PAGES: usize = 500;
3468 const MAX_CONSECUTIVE_EMPTY: usize = 3;
3469
3470 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3471 enum Mode {
3472 Latest,
3473 Backward,
3474 Range,
3475 }
3476
3477 let limit = if limit == Some(0) { None } else { limit };
3478
3479 if let (Some(s), Some(e)) = (start, end) {
3480 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
3481 }
3482
3483 let now = self.inner.clock.get_time_ns().to_datetime_utc();
3484
3485 if let Some(s) = start
3486 && s > now
3487 {
3488 return Ok(Vec::new());
3489 }
3490
3491 let end = if let Some(e) = end
3492 && e > now
3493 {
3494 Some(now)
3495 } else {
3496 end
3497 };
3498
3499 let mode = match (start, end) {
3500 (None, None) => Mode::Latest,
3501 (Some(_), None) => Mode::Backward,
3502 (None, Some(_)) => Mode::Backward,
3503 (Some(_), Some(_)) => Mode::Range,
3504 };
3505
3506 let start_ms = start.map(jiff::Timestamp::as_millisecond);
3507 let end_ms = end.map(jiff::Timestamp::as_millisecond);
3508
3509 let ts_init = self.generate_ts_init();
3510 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3511
3512 if matches!(mode, Mode::Backward | Mode::Range) {
3515 let mut before_trade_id: Option<String> = None;
3516 let mut pages = 0usize;
3517 let mut page_results: Vec<Vec<TradeTick>> = Vec::new();
3518 let mut seen_trades: AHashSet<(String, i64)> = AHashSet::new();
3519 let mut unique_count = 0usize;
3520 let mut consecutive_empty_pages = 0usize;
3521
3522 let effective_limit = if start.is_some() {
3525 limit.unwrap_or(u32::MAX)
3526 } else {
3527 limit.unwrap_or(OKX_TRADES_MAX_LIMIT)
3528 };
3529
3530 log::debug!(
3531 "Starting trades pagination: mode={mode:?}, start={start:?}, end={end:?}, limit={limit:?}, effective_limit={effective_limit}"
3532 );
3533
3534 loop {
3535 if pages >= MAX_PAGES {
3536 log::warn!("Hit MAX_PAGES limit of {MAX_PAGES}");
3537 break;
3538 }
3539
3540 if effective_limit < u32::MAX && unique_count >= effective_limit as usize {
3541 log::debug!("Reached effective limit: unique_count={unique_count}");
3542 break;
3543 }
3544
3545 let remaining = (effective_limit as usize).saturating_sub(unique_count);
3546 let page_cap = remaining.min(OKX_TRADES_MAX_LIMIT as usize) as u32;
3547
3548 log::debug!(
3549 "Requesting page {}: before_id={:?}, page_cap={}, unique_count={}",
3550 pages + 1,
3551 before_trade_id,
3552 page_cap,
3553 unique_count
3554 );
3555
3556 let mut params_builder = GetTradesParamsBuilder::default();
3557 params_builder
3558 .inst_id(instrument_id.symbol.inner())
3559 .limit(page_cap)
3560 .pagination_type(1);
3561
3562 if let Some(ref before_id) = before_trade_id {
3564 params_builder.after(before_id.clone());
3565 }
3566
3567 let params = params_builder.build().map_err(anyhow::Error::new)?;
3568 let raw = self
3569 .inner
3570 .get_history_trades(params)
3571 .await
3572 .map_err(anyhow::Error::new)?;
3573
3574 log::debug!("Received {} raw trades from API", raw.len());
3575
3576 if let (Some(first), Some(last)) = (raw.first(), raw.last()) {
3577 log::debug!(
3578 "Raw response trade ID range: first={} (newest), last={} (oldest)",
3579 first.trade_id,
3580 last.trade_id,
3581 );
3582 }
3583
3584 if raw.is_empty() {
3585 log::debug!("API returned empty page, stopping pagination");
3586 break;
3587 }
3588
3589 pages += 1;
3590
3591 let mut page_trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
3592 let mut hit_start_boundary = false;
3593 let mut filtered_out = 0usize;
3594 let mut duplicates = 0usize;
3595
3596 for r in &raw {
3597 match parse_trade_tick(
3598 r,
3599 instrument_id,
3600 inst.price_precision(),
3601 inst.size_precision(),
3602 ts_init,
3603 ) {
3604 Ok(trade) => {
3605 let ts_ms = trade.ts_event.as_i64() / 1_000_000;
3606
3607 if let Some(e_ms) = end_ms
3608 && ts_ms > e_ms
3609 {
3610 filtered_out += 1;
3611 continue;
3612 }
3613
3614 if let Some(s_ms) = start_ms
3615 && ts_ms < s_ms
3616 {
3617 hit_start_boundary = true;
3618 filtered_out += 1;
3619 break;
3620 }
3621
3622 let trade_key = (trade.trade_id.to_string(), trade.ts_event.as_i64());
3623 if seen_trades.insert(trade_key) {
3624 unique_count += 1;
3625 page_trades.push(trade);
3626 } else {
3627 duplicates += 1;
3628 }
3629 }
3630 Err(e) => log::error!("{e}"),
3631 }
3632 }
3633
3634 log::debug!(
3635 "Page {} processed: {} trades kept, {} filtered out, {} duplicates, hit_start_boundary={}",
3636 pages,
3637 page_trades.len(),
3638 filtered_out,
3639 duplicates,
3640 hit_start_boundary
3641 );
3642
3643 let oldest_trade_id = if page_trades.is_empty() {
3645 if unique_count > 0 {
3648 consecutive_empty_pages += 1;
3649 if consecutive_empty_pages >= MAX_CONSECUTIVE_EMPTY {
3650 log::debug!(
3651 "Stopping: {consecutive_empty_pages} consecutive pages with no trades in range after collecting {unique_count} trades"
3652 );
3653 break;
3654 }
3655 }
3656 raw.last().map(|t| {
3658 let id = t.trade_id.to_string();
3659 log::debug!(
3660 "Setting cursor from raw response (no unique trades): oldest_id={id}"
3661 );
3662 id
3663 })
3664 } else {
3665 let oldest_id = page_trades.last().map(|t| {
3667 let id = t.trade_id.to_string();
3668 log::debug!(
3669 "Setting cursor from deduplicated trades: oldest_id={}, ts_event={}",
3670 id,
3671 t.ts_event.as_i64()
3672 );
3673 id
3674 });
3675 page_trades.reverse();
3676 page_results.push(page_trades);
3677 consecutive_empty_pages = 0;
3678 oldest_id
3679 };
3680
3681 if let Some(ref old_id) = before_trade_id
3682 && oldest_trade_id.as_ref() == Some(old_id)
3683 {
3684 break;
3685 }
3686
3687 if oldest_trade_id.is_none() {
3688 break;
3689 }
3690
3691 before_trade_id = oldest_trade_id;
3692
3693 if hit_start_boundary {
3694 break;
3695 }
3696
3697 time::sleep(time::Duration::from_millis(50)).await;
3698 }
3699
3700 log::debug!(
3701 "Pagination complete: {pages} pages, {unique_count} unique trades collected"
3702 );
3703
3704 let mut out: Vec<TradeTick> = Vec::new();
3705
3706 for page in page_results.into_iter().rev() {
3707 out.extend(page);
3708 }
3709
3710 let mut dedup_keys = AHashSet::new();
3712 let pre_dedup_len = out.len();
3713 out.retain(|trade| {
3714 dedup_keys.insert((trade.trade_id.to_string(), trade.ts_event.as_i64()))
3715 });
3716
3717 if out.len() < pre_dedup_len {
3718 log::debug!(
3719 "Removed {} duplicate trades during final dedup",
3720 pre_dedup_len - out.len()
3721 );
3722 }
3723
3724 if let Some(lim) = limit
3725 && lim > 0
3726 && out.len() > lim as usize
3727 {
3728 let excess = out.len() - lim as usize;
3729 log::debug!("Trimming {excess} oldest trades to respect limit={lim}");
3730 out.drain(0..excess);
3731 }
3732
3733 log::debug!("Returning {} trades", out.len());
3734 return Ok(out);
3735 }
3736
3737 let req_limit = limit
3738 .unwrap_or(OKX_TRADES_MAX_LIMIT)
3739 .min(OKX_TRADES_MAX_LIMIT);
3740 let params = GetTradesParamsBuilder::default()
3741 .inst_id(instrument_id.symbol.inner())
3742 .limit(req_limit)
3743 .build()
3744 .map_err(anyhow::Error::new)?;
3745
3746 let raw = self
3747 .inner
3748 .get_history_trades(params)
3749 .await
3750 .map_err(anyhow::Error::new)?;
3751
3752 let mut trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
3753
3754 for r in &raw {
3755 match parse_trade_tick(
3756 r,
3757 instrument_id,
3758 inst.price_precision(),
3759 inst.size_precision(),
3760 ts_init,
3761 ) {
3762 Ok(trade) => trades.push(trade),
3763 Err(e) => log::error!("{e}"),
3764 }
3765 }
3766
3767 trades.reverse();
3769
3770 if let Some(lim) = limit
3771 && lim > 0
3772 && trades.len() > lim as usize
3773 {
3774 trades.drain(0..trades.len() - lim as usize);
3775 }
3776
3777 Ok(trades)
3778 }
3779
3780 pub async fn request_bars(
3826 &self,
3827 bar_type: BarType,
3828 start: Option<Timestamp>,
3829 mut end: Option<Timestamp>,
3830 limit: Option<u32>,
3831 ) -> anyhow::Result<Vec<Bar>> {
3832 const HISTORY_SPLIT_DAYS: i64 = 100;
3833 const MAX_PAGES_SOFT: usize = 500;
3834
3835 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3836 enum Mode {
3837 Latest,
3838 Backward,
3839 Range,
3840 }
3841
3842 let limit = if limit == Some(0) { None } else { limit };
3843
3844 anyhow::ensure!(
3845 bar_type.aggregation_source() == AggregationSource::External,
3846 "Only EXTERNAL aggregation is supported"
3847 );
3848
3849 if let (Some(s), Some(e)) = (start, end) {
3850 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
3851 }
3852
3853 let now = self.inner.clock.get_time_ns().to_datetime_utc();
3854
3855 if let Some(s) = start
3856 && s > now
3857 {
3858 return Ok(Vec::new());
3859 }
3860
3861 if let Some(e) = end
3862 && e > now
3863 {
3864 end = Some(now);
3865 }
3866
3867 let spec = bar_type.spec();
3868 let step = spec.step.get();
3869 let bar_param = match spec.aggregation {
3870 BarAggregation::Second => format!("{step}s"),
3871 BarAggregation::Minute => format!("{step}m"),
3872 BarAggregation::Hour => format!("{step}H"),
3873 BarAggregation::Day => format!("{step}D"),
3874 BarAggregation::Week => format!("{step}W"),
3875 BarAggregation::Month => format!("{step}M"),
3876 a => anyhow::bail!("OKX does not support {a:?} aggregation"),
3877 };
3878
3879 let slot_ms: i64 = match spec.aggregation {
3880 BarAggregation::Second => (step as i64) * 1_000,
3881 BarAggregation::Minute => (step as i64) * 60_000,
3882 BarAggregation::Hour => (step as i64) * 3_600_000,
3883 BarAggregation::Day => (step as i64) * 86_400_000,
3884 BarAggregation::Week => (step as i64) * 7 * 86_400_000,
3885 BarAggregation::Month => (step as i64) * 30 * 86_400_000,
3886 _ => unreachable!("Unsupported aggregation should have been caught above"),
3887 };
3888 let slot_ns: i64 = slot_ms * 1_000_000;
3889
3890 let mode = match (start, end) {
3891 (None, None) => Mode::Latest,
3892 (Some(_), None) => Mode::Backward, (None, Some(_)) => Mode::Backward,
3894 (Some(_), Some(_)) => Mode::Range,
3895 };
3896
3897 let start_ns = start.and_then(|s| i64::try_from(s.as_nanosecond()).ok());
3898 let end_ns = end.and_then(|e| i64::try_from(e.as_nanosecond()).ok());
3899
3900 let start_ms = start.map(|s| {
3902 let ms = s.as_millisecond();
3903
3904 if slot_ms > 0 {
3905 (ms / slot_ms) * slot_ms } else {
3907 ms
3908 }
3909 });
3910
3911 let end_ms = end.map(Timestamp::as_millisecond);
3912 let now_ms = now.as_millisecond();
3913
3914 let instrument_id = bar_type.instrument_id();
3915 let symbol = instrument_id.symbol;
3916 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3917
3918 let mut out: Vec<Bar> = Vec::new();
3919 let mut pages = 0usize;
3920
3921 let mut after_ms: Option<i64> = match mode {
3926 Mode::Range => end_ms.or(Some(now_ms)), _ => None,
3928 };
3929 let mut before_ms: Option<i64> = match mode {
3930 Mode::Backward => end_ms.map(|v| v.saturating_sub(1)),
3931 Mode::Range => start_ms.map(|v| v.saturating_sub(1)),
3932 Mode::Latest => None,
3933 };
3934
3935 let forward_prepend_mode = matches!(mode, Mode::Range);
3937
3938 if matches!(mode, Mode::Backward | Mode::Range)
3942 && let Some(b) = before_ms
3943 {
3944 let buffer_ms = slot_ms.max(60_000); if b >= now_ms.saturating_sub(buffer_ms) {
3950 before_ms = Some(now_ms.saturating_sub(buffer_ms));
3951 }
3952 }
3953
3954 let mut have_latest_first_page = false;
3955 let mut progressless_loops = 0u8;
3956
3957 loop {
3958 if let Some(lim) = limit
3959 && lim > 0
3960 && out.len() >= lim as usize
3961 {
3962 break;
3963 }
3964
3965 if pages >= MAX_PAGES_SOFT {
3966 break;
3967 }
3968
3969 let pivot_ms = if let Some(a) = after_ms {
3970 a
3971 } else if let Some(b) = before_ms {
3972 b
3973 } else {
3974 now_ms
3975 };
3976 let age_ms = now_ms.saturating_sub(pivot_ms);
3982 let age_hours = age_ms / (60 * 60 * 1000);
3983 let using_history = age_hours > 1; let page_ceiling = if using_history { 100 } else { 300 };
3986 let remaining = limit
3987 .filter(|&l| l > 0) .map_or(page_ceiling, |l| (l as usize).saturating_sub(out.len()));
3989 let page_cap = remaining.min(page_ceiling);
3990
3991 let mut p = GetCandlesticksParamsBuilder::default();
3992 p.inst_id(symbol.as_str())
3993 .bar(&bar_param)
3994 .limit(page_cap as u32);
3995
3996 let mut req_used_before = false;
3998
3999 match mode {
4000 Mode::Latest => {
4001 if have_latest_first_page && let Some(b) = before_ms {
4002 p.before_ms(b);
4003 req_used_before = true;
4004 }
4005 }
4006 Mode::Backward => {
4007 if let Some(b) = before_ms {
4009 p.after_ms(b);
4010 }
4011 }
4012 Mode::Range => {
4013 if let Some(a) = after_ms {
4016 p.after_ms(a);
4017 }
4018
4019 if let Some(b) = before_ms {
4020 p.before_ms(b);
4021 req_used_before = true;
4022 }
4023 }
4024 }
4025
4026 let params = p.build().map_err(anyhow::Error::new)?;
4027
4028 let mut raw = if using_history {
4029 self.inner
4030 .get_history_candles(params.clone())
4031 .await
4032 .map_err(anyhow::Error::new)?
4033 } else {
4034 self.inner
4035 .get_candles(params.clone())
4036 .await
4037 .map_err(anyhow::Error::new)?
4038 };
4039
4040 if raw.is_empty() {
4042 if matches!(mode, Mode::Latest)
4044 && have_latest_first_page
4045 && !using_history
4046 && let Some(b) = before_ms
4047 {
4048 let mut p2 = GetCandlesticksParamsBuilder::default();
4049 p2.inst_id(symbol.as_str())
4050 .bar(&bar_param)
4051 .limit(page_cap as u32);
4052 p2.before_ms(b);
4053 let params2 = p2.build().map_err(anyhow::Error::new)?;
4054 let raw2 = self
4055 .inner
4056 .get_history_candles(params2)
4057 .await
4058 .map_err(anyhow::Error::new)?;
4059
4060 if raw2.is_empty() {
4061 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
4063 before_ms = Some(b.saturating_sub(jump));
4064 progressless_loops = progressless_loops.saturating_add(1);
4065 if progressless_loops >= 3 {
4066 break;
4067 }
4068 continue;
4069 } else {
4070 raw = raw2;
4071 }
4072 }
4073
4074 if raw.is_empty()
4076 && matches!(mode, Mode::Latest)
4077 && !have_latest_first_page
4078 && !using_history
4079 {
4080 let jump_days_ms = (HISTORY_SPLIT_DAYS + 1) * 86_400_000;
4081 before_ms = Some(now_ms.saturating_sub(jump_days_ms));
4082 have_latest_first_page = true;
4083 continue;
4084 }
4085
4086 if raw.is_empty() {
4088 break;
4089 }
4090 }
4091 pages += 1;
4094
4095 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
4097 for r in &raw {
4098 page.push(parse_historical_candlestick(
4099 r,
4100 bar_type,
4101 inst.price_precision(),
4102 inst.size_precision(),
4103 )?);
4104 }
4105 page.reverse();
4106
4107 let page_oldest_ms = page.first().map(|b| b.ts_event.as_i64() / 1_000_000);
4108 let page_newest_ms = page.last().map(|b| b.ts_event.as_i64() / 1_000_000);
4109
4110 let mut filtered: Vec<Bar> = page
4111 .clone()
4112 .into_iter()
4113 .filter(|b| {
4114 let ts = b.ts_event.as_i64();
4115 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
4116 let ok_before = end_ns.is_none_or(|ens| b.ts_init.as_i64() <= ens);
4117 ok_after && ok_before
4118 })
4119 .collect();
4120
4121 if !page.is_empty() && filtered.is_empty() {
4122 if matches!(mode, Mode::Range)
4124 && !forward_prepend_mode
4125 && let (Some(newest_ms), Some(start_ms)) = (page_newest_ms, start_ms)
4126 && newest_ms < start_ms.saturating_sub(slot_ms * 2)
4127 {
4128 break;
4130 }
4131 }
4132
4133 let contribution;
4135
4136 if out.is_empty() {
4137 contribution = filtered.len();
4138 out = filtered;
4139 } else {
4140 match mode {
4141 Mode::Backward | Mode::Latest => {
4142 if let Some(first) = out.first() {
4143 filtered.retain(|b| b.ts_event < first.ts_event);
4144 }
4145 contribution = filtered.len();
4146 if contribution != 0 {
4147 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
4148 new_out.extend_from_slice(&filtered);
4149 new_out.extend_from_slice(&out);
4150 out = new_out;
4151 }
4152 }
4153 Mode::Range => {
4154 if forward_prepend_mode || req_used_before {
4155 if let Some(first) = out.first() {
4157 filtered.retain(|b| b.ts_event < first.ts_event);
4158 }
4159 contribution = filtered.len();
4160 if contribution != 0 {
4161 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
4162 new_out.extend_from_slice(&filtered);
4163 new_out.extend_from_slice(&out);
4164 out = new_out;
4165 }
4166 } else {
4167 if let Some(last) = out.last() {
4169 filtered.retain(|b| b.ts_event > last.ts_event);
4170 }
4171 contribution = filtered.len();
4172 out.extend(filtered);
4173 }
4174 }
4175 }
4176 }
4177
4178 if contribution == 0 {
4179 match mode {
4181 Mode::Range => after_ms = page_oldest_ms,
4182 Mode::Backward => before_ms = page_oldest_ms,
4183 Mode::Latest => {
4184 if let Some(b) = before_ms {
4185 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
4186 before_ms = Some(b.saturating_sub(jump));
4187 }
4188 }
4189 }
4190
4191 progressless_loops = progressless_loops.saturating_add(1);
4192 if progressless_loops >= 3 {
4193 break;
4194 }
4195 } else {
4196 progressless_loops = 0;
4197
4198 match mode {
4200 Mode::Latest | Mode::Backward => {
4201 if let Some(oldest) = page_oldest_ms {
4202 before_ms = Some(oldest.saturating_sub(1));
4203 have_latest_first_page = true;
4204 } else {
4205 break;
4206 }
4207 }
4208 Mode::Range => {
4209 if forward_prepend_mode || req_used_before {
4210 if let Some(oldest) = page_oldest_ms {
4211 after_ms = Some(oldest);
4212 } else {
4213 break;
4214 }
4215 } else if let Some(newest) = page_newest_ms {
4216 after_ms = Some(newest.saturating_add(1));
4217 before_ms = None;
4218 } else {
4219 break;
4220 }
4221 }
4222 }
4223 }
4224
4225 if let Some(lim) = limit
4227 && lim > 0
4228 && out.len() >= lim as usize
4229 {
4230 break;
4231 }
4232
4233 if let Some(ens) = end_ns
4234 && let Some(last) = out.last()
4235 && last.ts_event.as_i64() >= ens
4236 {
4237 break;
4238 }
4239
4240 if let Some(sns) = start_ns
4241 && let Some(first) = out.first()
4242 && (matches!(mode, Mode::Backward) || forward_prepend_mode)
4243 && first.ts_event.as_i64() <= sns
4244 {
4245 break;
4246 }
4247
4248 time::sleep(time::Duration::from_millis(50)).await;
4249 }
4250
4251 if out.is_empty() && matches!(mode, Mode::Range) {
4253 let pivot = end_ms.unwrap_or(now_ms.saturating_sub(1));
4254 let hist = (now_ms.saturating_sub(pivot)) / (24 * 60 * 60 * 1000) > HISTORY_SPLIT_DAYS;
4255 let mut p = GetCandlesticksParamsBuilder::default();
4256 p.inst_id(symbol.as_str())
4257 .bar(&bar_param)
4258 .limit(300)
4259 .before_ms(pivot);
4260 let params = p.build().map_err(anyhow::Error::new)?;
4261 let raw = if hist {
4262 self.inner.get_history_candles(params).await
4263 } else {
4264 self.inner.get_candles(params).await
4265 }
4266 .map_err(anyhow::Error::new)?;
4267
4268 if !raw.is_empty() {
4269 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
4270 for r in &raw {
4271 page.push(parse_historical_candlestick(
4272 r,
4273 bar_type,
4274 inst.price_precision(),
4275 inst.size_precision(),
4276 )?);
4277 }
4278 page.reverse();
4279 out = page
4280 .into_iter()
4281 .filter(|b| {
4282 let ts = b.ts_event.as_i64();
4283 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
4284 let ok_before = end_ns.is_none_or(|ens| b.ts_init.as_i64() <= ens);
4285 ok_after && ok_before
4286 })
4287 .collect();
4288 }
4289 }
4290
4291 if let Some(ens) = end_ns {
4293 while out.last().is_some_and(|b| b.ts_event.as_i64() > ens) {
4294 out.pop();
4295 }
4296 }
4297
4298 if matches!(mode, Mode::Range)
4300 && !forward_prepend_mode
4301 && let Some(sns) = start_ns
4302 {
4303 let lower = sns.saturating_sub(slot_ns);
4304 while out.first().is_some_and(|b| b.ts_event.as_i64() < lower) {
4305 out.remove(0);
4306 }
4307 }
4308
4309 if let Some(lim) = limit
4311 && lim > 0
4312 && out.len() > lim as usize
4313 {
4314 let start = out.len() - lim as usize;
4315 out.drain(..start);
4316 }
4317
4318 Ok(out)
4319 }
4320
4321 #[expect(clippy::too_many_arguments)]
4332 pub async fn request_order_status_reports(
4333 &self,
4334 account_id: AccountId,
4335 instrument_type: Option<OKXInstrumentType>,
4336 instrument_id: Option<InstrumentId>,
4337 start: Option<Timestamp>,
4338 end: Option<Timestamp>,
4339 open_only: bool,
4340 limit: Option<u32>,
4341 ) -> anyhow::Result<Vec<OrderStatusReport>> {
4342 Ok(self
4343 .request_order_status_reports_scoped(
4344 account_id,
4345 instrument_type,
4346 instrument_id,
4347 start,
4348 end,
4349 open_only,
4350 limit,
4351 None,
4352 None,
4353 )
4354 .await?
4355 .reports)
4356 }
4357
4358 #[expect(clippy::too_many_arguments)]
4359 pub(crate) async fn request_order_status_reports_scoped(
4360 &self,
4361 account_id: AccountId,
4362 instrument_type: Option<OKXInstrumentType>,
4363 instrument_id: Option<InstrumentId>,
4364 start: Option<Timestamp>,
4365 end: Option<Timestamp>,
4366 open_only: bool,
4367 limit: Option<u32>,
4368 scope: Option<ReportInstrumentScope<'_>>,
4369 max_history_pages: Option<u32>,
4370 ) -> anyhow::Result<ReportSweep<OrderStatusReport>> {
4371 if instrument_id.as_ref().is_some_and(|id| self.is_spread(*id))
4372 || (instrument_id.is_none() && instrument_type.is_none())
4373 {
4374 return self
4375 .request_spread_order_status_reports_scoped(
4376 account_id,
4377 instrument_id,
4378 start,
4379 end,
4380 open_only,
4381 limit,
4382 scope,
4383 max_history_pages,
4384 )
4385 .await;
4386 }
4387
4388 let instrument_type = if let Some(instrument_type) = instrument_type {
4389 instrument_type
4390 } else {
4391 let instrument_id = instrument_id.ok_or_else(|| {
4392 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
4393 })?;
4394 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4395 okx_instrument_type(&instrument)?
4396 };
4397
4398 let mut history_base = GetOrderHistoryParamsBuilder::default();
4399 history_base.inst_type(instrument_type);
4400
4401 if let Some(instrument_id) = instrument_id.as_ref() {
4402 history_base.inst_id(instrument_id.symbol.inner().to_string());
4403 }
4404 let history_base = history_base.build().map_err(|e| anyhow::anyhow!(e))?;
4405
4406 let mut pending_base = GetOrderListParamsBuilder::default();
4407 pending_base.inst_type(instrument_type);
4408
4409 if let Some(instrument_id) = instrument_id.as_ref() {
4410 pending_base.inst_id(instrument_id.symbol.inner().to_string());
4411 }
4412 let pending_base = pending_base.build().map_err(|e| anyhow::anyhow!(e))?;
4413
4414 let (combined_resp, mut complete) = if open_only {
4415 let pending = self.paginate_orders_pending(&pending_base, limit).await?;
4416 (pending.items, pending.complete)
4417 } else {
4418 let (history, pending) = Box::pin(async {
4419 tokio::try_join!(
4420 self.paginate_orders_history(
4421 &history_base,
4422 limit,
4423 reconciliation_history_page_limit(max_history_pages),
4424 ),
4425 self.paginate_orders_pending(&pending_base, limit),
4426 )
4427 })
4428 .await?;
4429 let mut combined_resp = history.items;
4430 combined_resp.extend(pending.items);
4431 (combined_resp, history.complete && pending.complete)
4432 };
4433
4434 let start_ns = start.map(UnixNanos::from);
4436 let end_ns = end.map(UnixNanos::from);
4437
4438 let ts_init = self.generate_ts_init();
4439 let mut reports = Vec::with_capacity(combined_resp.len());
4440
4441 let mut seen: AHashSet<String> = AHashSet::new();
4443
4444 for order in combined_resp {
4445 let seen_key = if !order.cl_ord_id.is_empty() {
4446 order.cl_ord_id.as_str().to_string()
4447 } else if let Some(algo_cl_ord_id) = order
4448 .algo_cl_ord_id
4449 .as_ref()
4450 .filter(|value| !value.as_str().is_empty())
4451 {
4452 algo_cl_ord_id.as_str().to_string()
4453 } else if let Some(algo_id) = order
4454 .algo_id
4455 .as_ref()
4456 .filter(|value| !value.as_str().is_empty())
4457 {
4458 algo_id.as_str().to_string()
4459 } else {
4460 order.ord_id.as_str().to_string()
4461 };
4462
4463 if !seen.insert(seen_key) {
4464 continue; }
4466
4467 if report_ts_outside_window(order.u_time, start_ns, end_ns)
4470 && !is_open_okx_order(order.state)
4471 {
4472 continue;
4473 }
4474
4475 let inst = match self.resolve_report_instrument(
4476 order.inst_id,
4477 order.inst_type,
4478 false,
4479 is_open_okx_order(order.state),
4480 scope,
4481 )? {
4482 InstrumentResolution::Found(inst) => inst,
4483 InstrumentResolution::Skip => continue,
4484 InstrumentResolution::Incomplete => {
4485 complete = false;
4486 continue;
4487 }
4488 };
4489
4490 let report = match parse_order_status_report(
4491 &order,
4492 account_id,
4493 inst.id(),
4494 inst.price_precision(),
4495 inst.size_precision(),
4496 ts_init,
4497 ) {
4498 Ok(report) => report,
4499 Err(e) => {
4500 log::warn!("Failed to parse order status report: {e}");
4501 complete = false;
4502 continue;
4503 }
4504 };
4505
4506 if let Some(start_ns) = start_ns
4507 && report.ts_last < start_ns
4508 && report.order_status.is_closed()
4509 {
4510 continue;
4511 }
4512
4513 if let Some(end_ns) = end_ns
4514 && report.ts_last > end_ns
4515 && report.order_status.is_closed()
4516 {
4517 continue;
4518 }
4519
4520 reports.push(report);
4521 }
4522
4523 Ok(ReportSweep { reports, complete })
4524 }
4525
4526 pub async fn request_order_status_report(
4532 &self,
4533 account_id: AccountId,
4534 instrument_id: InstrumentId,
4535 client_order_id: ClientOrderId,
4536 ) -> anyhow::Result<Option<OrderStatusReport>> {
4537 self.request_order_status_report_by_identifier(
4538 account_id,
4539 instrument_id,
4540 Some(client_order_id),
4541 None,
4542 )
4543 .await
4544 }
4545
4546 pub(crate) async fn request_order_status_report_by_venue_order_id(
4547 &self,
4548 account_id: AccountId,
4549 instrument_id: InstrumentId,
4550 venue_order_id: VenueOrderId,
4551 ) -> anyhow::Result<Option<OrderStatusReport>> {
4552 self.request_order_status_report_by_identifier(
4553 account_id,
4554 instrument_id,
4555 None,
4556 Some(venue_order_id),
4557 )
4558 .await
4559 }
4560
4561 async fn request_order_status_report_by_identifier(
4562 &self,
4563 account_id: AccountId,
4564 instrument_id: InstrumentId,
4565 client_order_id: Option<ClientOrderId>,
4566 venue_order_id: Option<VenueOrderId>,
4567 ) -> anyhow::Result<Option<OrderStatusReport>> {
4568 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4569 let mut params_builder = GetOrderParamsBuilder::default();
4570 params_builder.inst_id(instrument_id.symbol.inner().to_string());
4571
4572 match (client_order_id, venue_order_id) {
4573 (Some(client_order_id), None) => {
4574 params_builder.cl_ord_id(client_order_id.as_str().to_string());
4575 }
4576 (None, Some(venue_order_id)) => {
4577 params_builder.ord_id(venue_order_id.as_str().to_string());
4578 }
4579 _ => anyhow::bail!(
4580 "Exactly one of client_order_id or venue_order_id is required for an order detail request"
4581 ),
4582 }
4583
4584 let params = params_builder
4585 .build()
4586 .map_err(|e| anyhow::anyhow!("Failed to build order detail params: {e}"))?;
4587 let orders = match self.inner.get_order(params).await {
4588 Ok(orders) => orders,
4589 Err(e) if e.is_order_not_found() => return Ok(None),
4590 Err(e) => return Err(e.into()),
4591 };
4592 let order = match orders.as_slice() {
4593 [] => return Ok(None),
4594 [order] => order,
4595 _ => anyhow::bail!(
4596 "Order detail returned {} records for one identifier",
4597 orders.len(),
4598 ),
4599 };
4600
4601 if order.inst_id.as_str() != instrument_id.symbol.inner() {
4602 anyhow::bail!(
4603 "Order detail instrument mismatch for {instrument_id}: returned {}",
4604 order.inst_id,
4605 );
4606 }
4607
4608 if let Some(venue_order_id) = venue_order_id
4609 && order.ord_id.as_str() != venue_order_id.as_str()
4610 {
4611 anyhow::bail!(
4612 "Order detail venue order ID mismatch for {venue_order_id}: returned {}",
4613 order.ord_id,
4614 );
4615 }
4616
4617 let ts_init = self.generate_ts_init();
4618 let mut report = parse_order_status_report(
4619 order,
4620 account_id,
4621 instrument.id(),
4622 instrument.price_precision(),
4623 instrument.size_precision(),
4624 ts_init,
4625 )?;
4626 let post_only_rejected = order.state == OKXOrderStatus::Canceled
4627 && report.filled_qty.is_zero()
4628 && (order.cancel_source == OKX_POST_ONLY_CANCEL_SOURCE
4629 || order.cancel_source_reason.contains("POST_ONLY"));
4630 if post_only_rejected {
4631 report.order_status = OrderStatus::Rejected;
4632 report.post_only = true;
4633 let reason = if order.cancel_source_reason.is_empty() {
4634 OKX_POST_ONLY_CANCEL_REASON.to_string()
4635 } else {
4636 order.cancel_source_reason.clone()
4637 };
4638 report.cancel_reason = Some(reason);
4639 }
4640
4641 Ok(Some(report))
4642 }
4643
4644 pub async fn request_spread_order_status_reports(
4650 &self,
4651 account_id: AccountId,
4652 instrument_id: Option<InstrumentId>,
4653 start: Option<Timestamp>,
4654 end: Option<Timestamp>,
4655 open_only: bool,
4656 limit: Option<u32>,
4657 ) -> anyhow::Result<Vec<OrderStatusReport>> {
4658 Ok(self
4659 .request_spread_order_status_reports_scoped(
4660 account_id,
4661 instrument_id,
4662 start,
4663 end,
4664 open_only,
4665 limit,
4666 None,
4667 None,
4668 )
4669 .await?
4670 .reports)
4671 }
4672
4673 #[expect(clippy::too_many_arguments)]
4674 pub(crate) async fn request_spread_order_status_reports_scoped(
4675 &self,
4676 account_id: AccountId,
4677 instrument_id: Option<InstrumentId>,
4678 start: Option<Timestamp>,
4679 end: Option<Timestamp>,
4680 open_only: bool,
4681 limit: Option<u32>,
4682 scope: Option<ReportInstrumentScope<'_>>,
4683 max_history_pages: Option<u32>,
4684 ) -> anyhow::Result<ReportSweep<OrderStatusReport>> {
4685 let mut pending_builder = GetSpreadOrdersParamsBuilder::default();
4686 let mut history_builder = GetSpreadOrdersParamsBuilder::default();
4687
4688 if let Some(instrument_id) = instrument_id.as_ref() {
4689 let sprd_id = instrument_id.symbol.inner().to_string();
4690 pending_builder.sprd_id(sprd_id.clone());
4691 history_builder.sprd_id(sprd_id);
4692 }
4693
4694 if let Some(start) = start {
4695 history_builder.begin(start.as_millisecond().to_string());
4696 }
4697
4698 if let Some(end) = end {
4699 history_builder.end(end.as_millisecond().to_string());
4700 }
4701
4702 if let Some(limit) = spread_page_limit(limit) {
4703 pending_builder.limit(limit);
4704 history_builder.limit(limit);
4705 }
4706
4707 let pending_base = pending_builder.build().map_err(|e| anyhow::anyhow!(e))?;
4708 let history_base = history_builder.build().map_err(|e| anyhow::anyhow!(e))?;
4709
4710 let (combined_resp, mut complete) = if open_only {
4711 let pending = self
4712 .paginate_spread_orders_pending(&pending_base, limit)
4713 .await?;
4714 (pending.items, pending.complete)
4715 } else {
4716 let (history, pending) = Box::pin(async {
4717 tokio::try_join!(
4718 self.paginate_spread_orders_history(
4719 &history_base,
4720 limit,
4721 reconciliation_history_page_limit(max_history_pages),
4722 ),
4723 self.paginate_spread_orders_pending(&pending_base, limit),
4724 )
4725 })
4726 .await?;
4727 let mut combined_resp = history.items;
4728 combined_resp.extend(pending.items);
4729 (combined_resp, history.complete && pending.complete)
4730 };
4731
4732 let start_ns = start.map(UnixNanos::from);
4733 let end_ns = end.map(UnixNanos::from);
4734 let ts_init = self.generate_ts_init();
4735 let mut reports = Vec::with_capacity(combined_resp.len());
4736 let mut seen: AHashSet<String> = AHashSet::new();
4737
4738 for order in combined_resp {
4739 let seen_key = if order.cl_ord_id.is_empty() {
4740 order.ord_id.as_str().to_string()
4741 } else {
4742 order.cl_ord_id.as_str().to_string()
4743 };
4744
4745 if !seen.insert(seen_key) {
4746 continue;
4747 }
4748
4749 if let Some(ts) = order.u_time.or(order.c_time)
4752 && report_ts_outside_window(ts, start_ns, end_ns)
4753 && !is_open_okx_order(order.state)
4754 {
4755 continue;
4756 }
4757
4758 let inst = match self.resolve_report_instrument(
4759 order.sprd_id,
4760 OKXInstrumentType::Any,
4761 true,
4762 is_open_okx_order(order.state),
4763 scope,
4764 )? {
4765 InstrumentResolution::Found(inst) => inst,
4766 InstrumentResolution::Skip => continue,
4767 InstrumentResolution::Incomplete => {
4768 complete = false;
4769 continue;
4770 }
4771 };
4772
4773 let report = match parse_spread_order_status_report(
4774 &order,
4775 account_id,
4776 inst.id(),
4777 inst.price_precision(),
4778 inst.size_precision(),
4779 ts_init,
4780 ) {
4781 Ok(report) => report,
4782 Err(e) => {
4783 log::warn!("Failed to parse spread order status report: {e}");
4784 complete = false;
4785 continue;
4786 }
4787 };
4788
4789 if let Some(start_ns) = start_ns
4790 && report.ts_last < start_ns
4791 && report.order_status.is_closed()
4792 {
4793 continue;
4794 }
4795
4796 if let Some(end_ns) = end_ns
4797 && report.ts_last > end_ns
4798 && report.order_status.is_closed()
4799 {
4800 continue;
4801 }
4802
4803 reports.push(report);
4804 }
4805
4806 Ok(ReportSweep { reports, complete })
4807 }
4808
4809 async fn paginate_spread_orders_history(
4810 &self,
4811 base: &GetSpreadOrdersParams,
4812 limit: Option<u32>,
4813 max_pages: usize,
4814 ) -> anyhow::Result<PageSweep<OKXSpreadOrder>> {
4815 let mut all = Vec::new();
4816 let mut cursor: Option<String> = None;
4817 let mut exhausted = true;
4818
4819 for _ in 0..max_pages {
4820 let mut params = base.clone();
4821 params.end_id = cursor.take();
4822
4823 let page = self
4824 .inner
4825 .get_spread_orders_history(params)
4826 .await
4827 .map_err(|e| anyhow::anyhow!(e))?;
4828
4829 let page_len = page.len();
4830 cursor = page.last().map(|o| o.ord_id.to_string());
4831 all.extend(page);
4832
4833 if page_len < OKX_PAGE_SIZE {
4834 exhausted = false;
4835 break;
4836 }
4837
4838 if let Some(lim) = limit
4839 && all.len() >= lim as usize
4840 {
4841 exhausted = false;
4842 break;
4843 }
4844 }
4845
4846 if exhausted && !all.is_empty() {
4847 log::warn!(
4848 "Spread order history pagination hit {max_pages} page cap, \
4849 results may be truncated ({} records)",
4850 all.len()
4851 );
4852 }
4853
4854 if let Some(lim) = limit {
4855 all.truncate(lim as usize);
4856 }
4857
4858 Ok(PageSweep::from_pages(all, exhausted))
4859 }
4860
4861 async fn paginate_spread_orders_pending(
4862 &self,
4863 base: &GetSpreadOrdersParams,
4864 limit: Option<u32>,
4865 ) -> anyhow::Result<PageSweep<OKXSpreadOrder>> {
4866 let mut all = Vec::new();
4867 let mut cursor: Option<String> = None;
4868 let mut exhausted = true;
4869
4870 for _ in 0..MAX_RECONCILIATION_PAGES {
4871 let mut params = base.clone();
4872 params.end_id = cursor.take();
4873
4874 let page = self
4875 .inner
4876 .get_spread_orders_pending(params)
4877 .await
4878 .map_err(|e| anyhow::anyhow!(e))?;
4879
4880 let page_len = page.len();
4881 cursor = page.last().map(|o| o.ord_id.to_string());
4882 all.extend(page);
4883
4884 if page_len < OKX_PAGE_SIZE {
4885 exhausted = false;
4886 break;
4887 }
4888
4889 if let Some(lim) = limit
4890 && all.len() >= lim as usize
4891 {
4892 exhausted = false;
4893 break;
4894 }
4895 }
4896
4897 if exhausted && !all.is_empty() {
4898 log::warn!(
4899 "Pending spread orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4900 results may be truncated ({} records)",
4901 all.len()
4902 );
4903 }
4904
4905 if let Some(lim) = limit {
4906 all.truncate(lim as usize);
4907 }
4908
4909 Ok(PageSweep::from_pages(all, exhausted))
4910 }
4911
4912 async fn paginate_orders_history(
4914 &self,
4915 base: &GetOrderHistoryParams,
4916 limit: Option<u32>,
4917 max_pages: usize,
4918 ) -> anyhow::Result<PageSweep<OKXOrderHistory>> {
4919 let mut all = Vec::new();
4920 let mut cursor: Option<String> = None;
4921 let mut exhausted = true;
4922
4923 for _ in 0..max_pages {
4924 let mut params = base.clone();
4925 params.after = cursor.take();
4926
4927 let page = self
4928 .inner
4929 .get_orders_history(params)
4930 .await
4931 .map_err(|e| anyhow::anyhow!(e))?;
4932
4933 let page_len = page.len();
4934 cursor = page.last().map(|o| o.ord_id.to_string());
4935 all.extend(page);
4936
4937 if page_len < OKX_PAGE_SIZE {
4938 exhausted = false;
4939 break;
4940 }
4941
4942 if let Some(lim) = limit
4943 && all.len() >= lim as usize
4944 {
4945 exhausted = false;
4946 break;
4947 }
4948 }
4949
4950 if exhausted && !all.is_empty() {
4951 log::warn!(
4952 "Order history pagination hit {max_pages} page cap, \
4953 results may be truncated ({} records)",
4954 all.len()
4955 );
4956 }
4957
4958 if let Some(lim) = limit {
4959 all.truncate(lim as usize);
4960 }
4961
4962 Ok(PageSweep::from_pages(all, exhausted))
4963 }
4964
4965 async fn paginate_orders_pending(
4967 &self,
4968 base: &GetOrderListParams,
4969 limit: Option<u32>,
4970 ) -> anyhow::Result<PageSweep<OKXOrderHistory>> {
4971 let mut all = Vec::new();
4972 let mut cursor: Option<String> = None;
4973 let mut exhausted = true;
4974
4975 for _ in 0..MAX_RECONCILIATION_PAGES {
4976 let mut params = base.clone();
4977 params.after = cursor.take();
4978
4979 let page = self
4980 .inner
4981 .get_orders_pending(params)
4982 .await
4983 .map_err(|e| anyhow::anyhow!(e))?;
4984
4985 let page_len = page.len();
4986 cursor = page.last().map(|o| o.ord_id.to_string());
4987 all.extend(page);
4988
4989 if page_len < OKX_PAGE_SIZE {
4990 exhausted = false;
4991 break;
4992 }
4993
4994 if let Some(lim) = limit
4995 && all.len() >= lim as usize
4996 {
4997 exhausted = false;
4998 break;
4999 }
5000 }
5001
5002 if exhausted && !all.is_empty() {
5003 log::warn!(
5004 "Pending orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5005 results may be truncated ({} records)",
5006 all.len()
5007 );
5008 }
5009
5010 if let Some(lim) = limit {
5011 all.truncate(lim as usize);
5012 }
5013
5014 Ok(PageSweep::from_pages(all, exhausted))
5015 }
5016
5017 async fn paginate_fills(
5019 &self,
5020 base: &GetTransactionDetailsParams,
5021 limit: Option<u32>,
5022 history: FillHistory,
5023 max_pages: usize,
5024 ) -> anyhow::Result<PageSweep<OKXTransactionDetail>> {
5025 let mut all = Vec::new();
5026 let mut cursor: Option<String> = None;
5027 let mut exhausted = true;
5028
5029 for _ in 0..max_pages {
5030 let mut params = base.clone();
5031 params.after = cursor.take();
5032
5033 let page = match history {
5034 FillHistory::Recent => self.inner.get_fills(params).await,
5035 FillHistory::Extended => self.inner.get_fills_history(params).await,
5036 }
5037 .map_err(|e| anyhow::anyhow!(e))?;
5038
5039 let page_len = page.len();
5040 cursor = page.last().map(|o| o.bill_id.to_string());
5041 all.extend(page);
5042
5043 if page_len < OKX_PAGE_SIZE {
5044 exhausted = false;
5045 break;
5046 }
5047
5048 if let Some(lim) = limit
5049 && all.len() >= lim as usize
5050 {
5051 exhausted = false;
5052 break;
5053 }
5054 }
5055
5056 if exhausted && !all.is_empty() {
5057 log::warn!(
5058 "Fill pagination hit {max_pages} page cap, \
5059 results may be truncated ({} records)",
5060 all.len()
5061 );
5062 }
5063
5064 if let Some(lim) = limit {
5065 all.truncate(lim as usize);
5066 }
5067
5068 Ok(PageSweep::from_pages(all, exhausted))
5069 }
5070
5071 async fn paginate_algo_pending(
5073 &self,
5074 base: &GetAlgoOrdersParams,
5075 limit: Option<usize>,
5076 require_complete_active_coverage: bool,
5077 ) -> anyhow::Result<PageSweep<OKXOrderAlgo>> {
5078 let mut all = Vec::new();
5079 let mut cursor: Option<String> = None;
5080 let mut exhausted = true;
5081
5082 for _ in 0..MAX_RECONCILIATION_PAGES {
5083 let mut params = base.clone();
5084 params.after = cursor.take();
5085
5086 let page = match self.inner.get_order_algo_pending(params).await {
5087 Ok(result) => result,
5088 Err(OKXHttpError::UnexpectedStatus { status, .. })
5089 if status == StatusCode::NOT_FOUND && !require_complete_active_coverage =>
5090 {
5091 exhausted = false;
5092 break;
5093 }
5094 Err(e) => return Err(e.into()),
5095 };
5096
5097 let page_len = page.len();
5098 cursor = page.last().map(|o| o.algo_id.clone());
5099 all.extend(page);
5100
5101 if page_len < OKX_PAGE_SIZE {
5102 exhausted = false;
5103 break;
5104 }
5105
5106 if let Some(lim) = limit
5107 && all.len() >= lim
5108 {
5109 exhausted = false;
5110 break;
5111 }
5112 }
5113
5114 if exhausted && !all.is_empty() {
5115 log::warn!(
5116 "Algo pending pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5117 results may be truncated ({} records)",
5118 all.len()
5119 );
5120 }
5121
5122 Ok(PageSweep::from_pages(all, exhausted))
5123 }
5124
5125 async fn paginate_algo_history(
5127 &self,
5128 base: &GetAlgoOrdersParams,
5129 limit: Option<usize>,
5130 require_complete_active_coverage: bool,
5131 max_pages: usize,
5132 ) -> anyhow::Result<PageSweep<OKXOrderAlgo>> {
5133 let mut all = Vec::new();
5134 let mut cursor: Option<String> = None;
5135 let mut exhausted = true;
5136
5137 for _ in 0..max_pages {
5138 let mut params = base.clone();
5139 params.after = cursor.take();
5140
5141 let page = match self.inner.get_order_algo_history(params).await {
5142 Ok(result) => result,
5143 Err(OKXHttpError::UnexpectedStatus { status, .. })
5144 if status == StatusCode::NOT_FOUND && !require_complete_active_coverage =>
5145 {
5146 exhausted = false;
5147 break;
5148 }
5149 Err(e) => return Err(e.into()),
5150 };
5151
5152 let page_len = page.len();
5153 cursor = page.last().map(|o| o.algo_id.clone());
5154 all.extend(page);
5155
5156 if page_len < OKX_PAGE_SIZE {
5157 exhausted = false;
5158 break;
5159 }
5160
5161 if let Some(lim) = limit
5162 && all.len() >= lim
5163 {
5164 exhausted = false;
5165 break;
5166 }
5167 }
5168
5169 if exhausted && !all.is_empty() {
5170 log::warn!(
5171 "Algo history pagination hit {max_pages} page cap, \
5172 results may be truncated ({} records)",
5173 all.len()
5174 );
5175 }
5176
5177 Ok(PageSweep::from_pages(all, exhausted))
5178 }
5179
5180 pub async fn request_fill_reports(
5190 &self,
5191 account_id: AccountId,
5192 instrument_type: Option<OKXInstrumentType>,
5193 instrument_id: Option<InstrumentId>,
5194 start: Option<Timestamp>,
5195 end: Option<Timestamp>,
5196 limit: Option<u32>,
5197 ) -> anyhow::Result<Vec<FillReport>> {
5198 Ok(self
5199 .request_fill_reports_scoped(
5200 account_id,
5201 instrument_type,
5202 instrument_id,
5203 start,
5204 end,
5205 limit,
5206 FillHistory::Recent,
5207 None,
5208 None,
5209 )
5210 .await?
5211 .reports)
5212 }
5213
5214 #[expect(clippy::too_many_arguments)]
5215 pub(crate) async fn request_fill_reports_scoped(
5216 &self,
5217 account_id: AccountId,
5218 instrument_type: Option<OKXInstrumentType>,
5219 instrument_id: Option<InstrumentId>,
5220 start: Option<Timestamp>,
5221 end: Option<Timestamp>,
5222 limit: Option<u32>,
5223 history: FillHistory,
5224 scope: Option<ReportInstrumentScope<'_>>,
5225 max_history_pages: Option<u32>,
5226 ) -> anyhow::Result<ReportSweep<FillReport>> {
5227 if instrument_id.as_ref().is_some_and(|id| self.is_spread(*id))
5228 || (instrument_id.is_none() && instrument_type.is_none())
5229 {
5230 return self
5231 .request_spread_fill_reports_scoped(
5232 account_id,
5233 instrument_id,
5234 start,
5235 end,
5236 limit,
5237 scope,
5238 max_history_pages,
5239 )
5240 .await;
5241 }
5242
5243 let mut params = GetTransactionDetailsParamsBuilder::default();
5244
5245 let instrument_type = if let Some(instrument_type) = instrument_type {
5246 instrument_type
5247 } else {
5248 let instrument_id = instrument_id.ok_or_else(|| {
5249 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
5250 })?;
5251 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5252 okx_instrument_type(&instrument)?
5253 };
5254
5255 params.inst_type(instrument_type);
5256
5257 if let Some(instrument_id) = instrument_id {
5258 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5259 let instrument_type = okx_instrument_type(&instrument)?;
5260 params.inst_type(instrument_type);
5261 params.inst_id(instrument_id.symbol.inner().to_string());
5262 }
5263
5264 if let Some(start) = start {
5265 params.begin(start.as_millisecond().to_string());
5266 }
5267
5268 if let Some(end) = end {
5269 params.end(end.as_millisecond().to_string());
5270 }
5271
5272 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
5273
5274 let sweep = self
5275 .paginate_fills(
5276 ¶ms,
5277 limit,
5278 history,
5279 reconciliation_history_page_limit(max_history_pages),
5280 )
5281 .await?;
5282 let mut complete = sweep.complete;
5283
5284 let start_ns = start.map(UnixNanos::from);
5286 let end_ns = end.map(UnixNanos::from);
5287
5288 let ts_init = self.generate_ts_init();
5289 let mut reports = Vec::with_capacity(sweep.items.len());
5290
5291 for detail in sweep.items {
5292 if detail.fill_sz.is_empty() {
5294 continue;
5295 }
5296
5297 if !fill_quantity_is_positive(&detail.fill_sz).with_context(|| {
5298 format!(
5299 "failed to parse fill quantity for instrument {}",
5300 detail.inst_id
5301 )
5302 })? {
5303 continue;
5304 }
5305
5306 if report_ts_outside_window(detail.ts, start_ns, end_ns) {
5307 continue;
5308 }
5309
5310 let inst = match self.resolve_report_instrument(
5311 detail.inst_id,
5312 detail.inst_type,
5313 false,
5314 false,
5315 scope,
5316 )? {
5317 InstrumentResolution::Found(inst) => inst,
5318 InstrumentResolution::Skip => continue,
5319 InstrumentResolution::Incomplete => {
5320 complete = false;
5321 continue;
5322 }
5323 };
5324
5325 let report = parse_fill_report(
5326 &detail,
5327 account_id,
5328 inst.id(),
5329 inst.price_precision(),
5330 inst.size_precision(),
5331 ts_init,
5332 )
5333 .with_context(|| {
5334 format!(
5335 "failed to parse fill report for instrument {}",
5336 detail.inst_id
5337 )
5338 })?;
5339
5340 if let Some(start_ns) = start_ns
5341 && report.ts_event < start_ns
5342 {
5343 continue;
5344 }
5345
5346 if let Some(end_ns) = end_ns
5347 && report.ts_event > end_ns
5348 {
5349 continue;
5350 }
5351
5352 reports.push(report);
5353 }
5354
5355 Ok(ReportSweep { reports, complete })
5356 }
5357
5358 pub async fn request_spread_fill_reports(
5364 &self,
5365 account_id: AccountId,
5366 instrument_id: Option<InstrumentId>,
5367 start: Option<Timestamp>,
5368 end: Option<Timestamp>,
5369 limit: Option<u32>,
5370 ) -> anyhow::Result<Vec<FillReport>> {
5371 Ok(self
5372 .request_spread_fill_reports_scoped(
5373 account_id,
5374 instrument_id,
5375 start,
5376 end,
5377 limit,
5378 None,
5379 None,
5380 )
5381 .await?
5382 .reports)
5383 }
5384
5385 #[expect(clippy::too_many_arguments)]
5386 pub(crate) async fn request_spread_fill_reports_scoped(
5387 &self,
5388 account_id: AccountId,
5389 instrument_id: Option<InstrumentId>,
5390 start: Option<Timestamp>,
5391 end: Option<Timestamp>,
5392 limit: Option<u32>,
5393 scope: Option<ReportInstrumentScope<'_>>,
5394 max_history_pages: Option<u32>,
5395 ) -> anyhow::Result<ReportSweep<FillReport>> {
5396 let mut builder = GetSpreadTradesParamsBuilder::default();
5397
5398 if let Some(instrument_id) = instrument_id.as_ref() {
5399 builder.sprd_id(instrument_id.symbol.inner().to_string());
5400 }
5401
5402 if let Some(start) = start {
5403 builder.begin(start.as_millisecond().to_string());
5404 }
5405
5406 if let Some(end) = end {
5407 builder.end(end.as_millisecond().to_string());
5408 }
5409
5410 if let Some(limit) = spread_page_limit(limit) {
5411 builder.limit(limit);
5412 }
5413
5414 let params = builder.build().map_err(|e| anyhow::anyhow!(e))?;
5415 let sweep = self
5416 .paginate_spread_fills(
5417 ¶ms,
5418 limit,
5419 reconciliation_history_page_limit(max_history_pages),
5420 )
5421 .await?;
5422 let mut complete = sweep.complete;
5423
5424 let start_ns = start.map(UnixNanos::from);
5425 let end_ns = end.map(UnixNanos::from);
5426 let ts_init = self.generate_ts_init();
5427 let mut reports = Vec::with_capacity(sweep.items.len());
5428
5429 for detail in sweep.items {
5430 if detail.fill_sz.is_empty() {
5431 continue;
5432 }
5433
5434 if !fill_quantity_is_positive(&detail.fill_sz).with_context(|| {
5435 format!(
5436 "failed to parse spread fill quantity for instrument {}",
5437 detail.sprd_id
5438 )
5439 })? {
5440 continue;
5441 }
5442
5443 if report_ts_outside_window(detail.ts, start_ns, end_ns) {
5444 continue;
5445 }
5446
5447 let inst = match self.resolve_report_instrument(
5448 detail.sprd_id,
5449 OKXInstrumentType::Any,
5450 true,
5451 false,
5452 scope,
5453 )? {
5454 InstrumentResolution::Found(inst) => inst,
5455 InstrumentResolution::Skip => continue,
5456 InstrumentResolution::Incomplete => {
5457 complete = false;
5458 continue;
5459 }
5460 };
5461
5462 let report = parse_spread_fill_report(
5463 &detail,
5464 account_id,
5465 inst.id(),
5466 inst.price_precision(),
5467 inst.size_precision(),
5468 ts_init,
5469 )
5470 .with_context(|| {
5471 format!(
5472 "failed to parse spread fill report for instrument {}",
5473 detail.sprd_id
5474 )
5475 })?;
5476
5477 if let Some(start_ns) = start_ns
5478 && report.ts_event < start_ns
5479 {
5480 continue;
5481 }
5482
5483 if let Some(end_ns) = end_ns
5484 && report.ts_event > end_ns
5485 {
5486 continue;
5487 }
5488
5489 reports.push(report);
5490 }
5491
5492 Ok(ReportSweep { reports, complete })
5493 }
5494
5495 async fn paginate_spread_fills(
5496 &self,
5497 base: &GetSpreadTradesParams,
5498 limit: Option<u32>,
5499 max_pages: usize,
5500 ) -> anyhow::Result<PageSweep<OKXSpreadTrade>> {
5501 let mut all = Vec::new();
5502 let mut cursor: Option<String> = None;
5503 let mut exhausted = true;
5504
5505 for _ in 0..max_pages {
5506 let mut params = base.clone();
5507 params.end_id = cursor.take();
5508
5509 let page = self
5510 .inner
5511 .get_spread_trades(params)
5512 .await
5513 .map_err(|e| anyhow::anyhow!(e))?;
5514
5515 let page_len = page.len();
5516 cursor = page.last().map(|o| o.trade_id.to_string());
5517 all.extend(page);
5518
5519 if page_len < OKX_PAGE_SIZE {
5520 exhausted = false;
5521 break;
5522 }
5523
5524 if let Some(lim) = limit
5525 && all.len() >= lim as usize
5526 {
5527 exhausted = false;
5528 break;
5529 }
5530 }
5531
5532 if exhausted && !all.is_empty() {
5533 log::warn!(
5534 "Spread fill pagination hit {max_pages} page cap, \
5535 results may be truncated ({} records)",
5536 all.len()
5537 );
5538 }
5539
5540 if let Some(lim) = limit {
5541 all.truncate(lim as usize);
5542 }
5543
5544 Ok(PageSweep::from_pages(all, exhausted))
5545 }
5546
5547 pub async fn request_position_status_reports(
5575 &self,
5576 account_id: AccountId,
5577 instrument_type: Option<OKXInstrumentType>,
5578 instrument_id: Option<InstrumentId>,
5579 ) -> anyhow::Result<Vec<PositionStatusReport>> {
5580 Ok(self
5581 .request_position_status_reports_scoped(
5582 account_id,
5583 instrument_type,
5584 instrument_id,
5585 None,
5586 )
5587 .await?
5588 .reports)
5589 }
5590
5591 pub(crate) async fn request_position_status_reports_scoped(
5592 &self,
5593 account_id: AccountId,
5594 instrument_type: Option<OKXInstrumentType>,
5595 instrument_id: Option<InstrumentId>,
5596 scope: Option<ReportInstrumentScope<'_>>,
5597 ) -> anyhow::Result<ReportSweep<PositionStatusReport>> {
5598 let mut params = GetPositionsParamsBuilder::default();
5599
5600 let instrument_type = if let Some(instrument_type) = instrument_type {
5601 instrument_type
5602 } else {
5603 let instrument_id = instrument_id.ok_or_else(|| {
5604 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
5605 })?;
5606 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5607 okx_instrument_type(&instrument)?
5608 };
5609
5610 params.inst_type(instrument_type);
5611
5612 instrument_id
5613 .as_ref()
5614 .map(|i| params.inst_id(i.symbol.inner()));
5615
5616 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
5617
5618 let resp = self
5619 .inner
5620 .get_positions(params)
5621 .await
5622 .map_err(|e| anyhow::anyhow!(e))?;
5623
5624 let ts_init = self.generate_ts_init();
5625 let mut reports: Vec<PositionStatusReport> = Vec::with_capacity(resp.len());
5626 let mut position_ids = AHashMap::new();
5627
5628 for position in resp {
5629 let inst = match self.resolve_report_instrument(
5630 position.inst_id,
5631 position.inst_type,
5632 false,
5633 true,
5634 scope,
5635 )? {
5636 InstrumentResolution::Found(inst) => inst,
5637 InstrumentResolution::Skip => continue,
5638 InstrumentResolution::Incomplete => {
5639 anyhow::bail!(
5640 "Instrument {} missing from cache for position report",
5641 position.inst_id
5642 );
5643 }
5644 };
5645
5646 if let Some(scope) = scope {
5647 scope.validate_position_margin(&position)?;
5648 }
5649
5650 let report = parse_position_status_report(
5651 &position,
5652 account_id,
5653 inst.id(),
5654 inst.size_precision(),
5655 ts_init,
5656 )
5657 .with_context(|| {
5658 format!(
5659 "failed to parse position status report for instrument {}",
5660 position.inst_id
5661 )
5662 })?;
5663
5664 if let Some(position_id) = report.venue_position_id {
5665 if let Some(index) = position_ids.get(&position_id).copied() {
5666 if report.quantity.is_zero() {
5667 continue;
5668 }
5669
5670 let existing: &mut PositionStatusReport = &mut reports[index];
5671 if existing.quantity.is_zero() {
5672 *existing = report;
5673 continue;
5674 }
5675
5676 anyhow::bail!(
5677 "Multiple OKX positions map to {position_id}; mixed margin modes are not supported"
5678 );
5679 }
5680
5681 position_ids.insert(position_id, reports.len());
5682 }
5683
5684 reports.push(report);
5685 }
5686
5687 Ok(ReportSweep {
5688 reports,
5689 complete: true,
5690 })
5691 }
5692
5693 pub async fn request_spot_margin_position_reports(
5708 &self,
5709 account_id: AccountId,
5710 ) -> anyhow::Result<Vec<PositionStatusReport>> {
5711 let accounts = self
5712 .inner
5713 .get_balance()
5714 .await
5715 .map_err(|e| anyhow::anyhow!(e))?;
5716
5717 let ts_init = self.generate_ts_init();
5718 let mut reports = Vec::new();
5719
5720 let cache_snapshot = self.instruments_cache.load();
5734 let mut candidates: Vec<&InstrumentAny> = cache_snapshot
5735 .values()
5736 .filter(|inst| matches!(inst, InstrumentAny::CurrencyPair(_)))
5737 .collect();
5738 candidates.sort_by(|a, b| {
5739 let a_sym = a.id().symbol.as_str().to_string();
5740 let b_sym = b.id().symbol.as_str().to_string();
5741 spot_quote_priority(&a_sym)
5742 .cmp(&spot_quote_priority(&b_sym))
5743 .then_with(|| a_sym.cmp(&b_sym))
5744 });
5745
5746 let mut by_base: AHashMap<Ustr, (InstrumentId, u8)> = AHashMap::new();
5747
5748 for inst in candidates {
5749 if let Some(base) = inst.base_currency() {
5750 let base_code = base.code;
5751 by_base
5752 .entry(base_code)
5753 .or_insert_with(|| (inst.id(), inst.size_precision()));
5754 }
5755 }
5756
5757 for account in accounts {
5758 for balance in account.details {
5759 let ccy_str = balance.ccy.as_str();
5760
5761 let Some((instrument_id, size_precision)) =
5762 by_base.get(&Ustr::from(ccy_str)).copied()
5763 else {
5764 log::debug!("Skipping balance for {ccy_str} - no matching instrument in cache");
5765 continue;
5766 };
5767
5768 match parse_spot_margin_position_from_balance(
5769 &balance,
5770 account_id,
5771 instrument_id,
5772 size_precision,
5773 ts_init,
5774 ) {
5775 Ok(Some(report)) => reports.push(report),
5776 Ok(None) => {} Err(e) => {
5778 log::error!(
5779 "Failed to parse spot margin position from balance for {ccy_str}: {e}"
5780 );
5781 }
5782 }
5783 }
5784 }
5785
5786 Ok(reports)
5787 }
5788
5789 pub async fn place_order(
5799 &self,
5800 request: OKXPlaceOrderRequest,
5801 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5802 let body = serde_json::to_vec(&request)?;
5803
5804 let resp: Vec<OKXPlaceOrderResponse> = self
5805 .inner
5806 .send_request::<_, ()>(Method::POST, "/api/v5/trade/order", None, Some(body), true)
5807 .await?;
5808
5809 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
5810 }
5811
5812 pub async fn place_orders(
5818 &self,
5819 requests: Vec<OKXPlaceOrderRequest>,
5820 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
5821 if requests.is_empty() {
5822 return Ok(Vec::new());
5823 }
5824
5825 let body = serde_json::to_vec(&requests)?;
5826 self.inner
5827 .send_request::<_, ()>(
5828 Method::POST,
5829 "/api/v5/trade/batch-orders",
5830 None,
5831 Some(body),
5832 true,
5833 )
5834 .await
5835 }
5836
5837 pub async fn amend_order(
5843 &self,
5844 request: OKXAmendOrderRequest,
5845 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5846 let body = serde_json::to_vec(&request)?;
5847 let resp: Vec<OKXPlaceOrderResponse> = self
5848 .inner
5849 .send_request::<_, ()>(
5850 Method::POST,
5851 "/api/v5/trade/amend-order",
5852 None,
5853 Some(body),
5854 true,
5855 )
5856 .await?;
5857
5858 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
5859 }
5860
5861 pub async fn amend_orders(
5867 &self,
5868 requests: Vec<OKXAmendOrderRequest>,
5869 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
5870 if requests.is_empty() {
5871 return Ok(Vec::new());
5872 }
5873
5874 let body = serde_json::to_vec(&requests)?;
5875 self.inner
5876 .send_request::<_, ()>(
5877 Method::POST,
5878 "/api/v5/trade/amend-batch-orders",
5879 None,
5880 Some(body),
5881 true,
5882 )
5883 .await
5884 }
5885
5886 pub async fn place_spread_order(
5892 &self,
5893 request: OKXPlaceSpreadOrderRequest,
5894 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5895 let resp = self.inner.place_spread_order(request).await?;
5896 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5897
5898 if let Some(ref code) = item.s_code
5899 && code != OKX_SUCCESS_CODE
5900 {
5901 let msg = item.s_msg.clone().unwrap_or_default();
5902 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
5903 }
5904
5905 Ok(item)
5906 }
5907
5908 pub async fn cancel_spread_order(
5914 &self,
5915 request: OKXCancelSpreadOrderRequest,
5916 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
5917 let resp = self.inner.cancel_spread_order(request).await?;
5918 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5919
5920 if let Some(ref code) = item.s_code
5921 && code != OKX_SUCCESS_CODE
5922 {
5923 let msg = item.s_msg.clone().unwrap_or_default();
5924 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
5925 }
5926
5927 Ok(item)
5928 }
5929
5930 pub async fn cancel_all_spread_orders(
5936 &self,
5937 spread_id: InstrumentId,
5938 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
5939 let request = OKXCancelAllSpreadOrdersRequest {
5940 sprd_id: spread_id.symbol.as_str().to_string(),
5941 };
5942
5943 self.inner.cancel_all_spread_orders(request).await
5944 }
5945
5946 pub async fn cancel_order(
5952 &self,
5953 instrument_id: InstrumentId,
5954 client_order_id: Option<ClientOrderId>,
5955 venue_order_id: Option<VenueOrderId>,
5956 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
5957 if client_order_id.is_none() && venue_order_id.is_none() {
5958 return Err(OKXHttpError::ValidationError(
5959 "Either `client_order_id` or `venue_order_id` is required".to_string(),
5960 ));
5961 }
5962
5963 let ord_id = venue_order_id.as_ref().map(ToString::to_string);
5964 let cl_ord_id = if venue_order_id.is_none() {
5965 client_order_id.as_ref().map(|id| id.as_str().to_string())
5966 } else {
5967 None
5968 };
5969
5970 if self.is_spread(instrument_id) {
5971 return self
5972 .cancel_spread_order(OKXCancelSpreadOrderRequest { ord_id, cl_ord_id })
5973 .await;
5974 }
5975
5976 let request = OKXCancelOrderRequest {
5977 inst_id: instrument_id.symbol.as_str().to_string(),
5978 inst_id_code: None,
5979 ord_id,
5980 cl_ord_id,
5981 };
5982 let mut resp = self.cancel_orders(vec![request]).await?;
5983 let item = resp.pop().ok_or(OKXHttpError::EmptyResponse)?;
5984
5985 if let Some(ref code) = item.s_code
5986 && code != OKX_SUCCESS_CODE
5987 {
5988 let msg = item.s_msg.clone().unwrap_or_default();
5989 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
5990 }
5991
5992 Ok(item)
5993 }
5994
5995 pub async fn cancel_all_orders(
6001 &self,
6002 instrument_id: InstrumentId,
6003 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
6004 if self.is_spread(instrument_id) {
6005 return self.cancel_all_spread_orders(instrument_id).await;
6006 }
6007
6008 let instrument = self
6009 .instrument_from_cache(instrument_id.symbol.inner())
6010 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6011 let instrument_type = okx_instrument_type(&instrument)
6012 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6013
6014 let mut pending_base = GetOrderListParamsBuilder::default();
6015 pending_base.inst_type(instrument_type);
6016 pending_base.inst_id(instrument_id.symbol.inner().to_string());
6017 let pending_base = pending_base
6018 .build()
6019 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6020
6021 let pending = self
6022 .paginate_orders_pending(&pending_base, None)
6023 .await
6024 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6025 let requests = pending
6026 .items
6027 .into_iter()
6028 .map(|order| OKXCancelOrderRequest {
6029 inst_id: order.inst_id.to_string(),
6030 inst_id_code: None,
6031 ord_id: if order.ord_id.is_empty() {
6032 None
6033 } else {
6034 Some(order.ord_id.to_string())
6035 },
6036 cl_ord_id: if order.ord_id.is_empty() && !order.cl_ord_id.is_empty() {
6037 Some(order.cl_ord_id.to_string())
6038 } else {
6039 None
6040 },
6041 })
6042 .collect();
6043
6044 self.cancel_orders(requests).await
6045 }
6046
6047 pub async fn cancel_orders(
6060 &self,
6061 requests: Vec<OKXCancelOrderRequest>,
6062 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
6063 if requests.is_empty() {
6064 return Ok(Vec::new());
6065 }
6066
6067 let body = serde_json::to_vec(&requests)?;
6068
6069 let resp: Vec<OKXCancelOrderResponse> = self
6070 .inner
6071 .send_request::<_, ()>(
6072 Method::POST,
6073 "/api/v5/trade/cancel-batch-orders",
6074 None,
6075 Some(body),
6076 true,
6077 )
6078 .await?;
6079
6080 for item in &resp {
6081 if let Some(ref code) = item.s_code
6082 && code != OKX_SUCCESS_CODE
6083 {
6084 let msg = item.s_msg.as_deref().unwrap_or("");
6085 log::warn!(
6086 "Order cancel rejected: ord_id={} sCode={code} sMsg={msg}",
6087 item.ord_id
6088 );
6089 }
6090 }
6091
6092 Ok(resp)
6093 }
6094
6095 pub async fn place_algo_order(
6105 &self,
6106 request: OKXPlaceAlgoOrderRequest,
6107 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
6108 let body = serde_json::to_vec(&request)?;
6109
6110 let resp: Vec<OKXPlaceAlgoOrderResponse> = self
6111 .inner
6112 .send_request::<_, ()>(
6113 Method::POST,
6114 "/api/v5/trade/order-algo",
6115 None,
6116 Some(body),
6117 true,
6118 )
6119 .await?;
6120
6121 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
6122
6123 if let Some(ref code) = item.s_code
6124 && code != "0"
6125 {
6126 let msg = item.s_msg.clone().unwrap_or_default();
6127 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
6128 }
6129
6130 Ok(item)
6131 }
6132
6133 pub async fn cancel_algo_order(
6143 &self,
6144 request: OKXCancelAlgoOrderRequest,
6145 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
6146 let body = serde_json::to_vec(&[request])?;
6149
6150 let resp: Vec<OKXCancelAlgoOrderResponse> = self
6151 .inner
6152 .send_request::<_, ()>(
6153 Method::POST,
6154 "/api/v5/trade/cancel-algos",
6155 None,
6156 Some(body),
6157 true,
6158 )
6159 .await?;
6160
6161 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
6162
6163 if let Some(ref code) = item.s_code
6164 && code != "0"
6165 {
6166 let msg = item.s_msg.clone().unwrap_or_default();
6167 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
6168 }
6169
6170 Ok(item)
6171 }
6172
6173 pub async fn cancel_algo_orders(
6186 &self,
6187 requests: Vec<OKXCancelAlgoOrderRequest>,
6188 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
6189 if requests.is_empty() {
6190 return Ok(Vec::new());
6191 }
6192
6193 let body = serde_json::to_vec(&requests)?;
6194
6195 let resp: Vec<OKXCancelAlgoOrderResponse> = self
6196 .inner
6197 .send_request::<_, ()>(
6198 Method::POST,
6199 "/api/v5/trade/cancel-algos",
6200 None,
6201 Some(body),
6202 true,
6203 )
6204 .await?;
6205
6206 for item in &resp {
6207 if let Some(ref code) = item.s_code
6208 && code != "0"
6209 {
6210 let msg = item.s_msg.as_deref().unwrap_or("");
6211 log::debug!(
6212 "Algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
6213 item.algo_id
6214 );
6215 }
6216 }
6217
6218 Ok(resp)
6219 }
6220
6221 pub async fn cancel_advance_algo_orders(
6234 &self,
6235 requests: Vec<OKXCancelAlgoOrderRequest>,
6236 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
6237 if requests.is_empty() {
6238 return Ok(Vec::new());
6239 }
6240
6241 let body = serde_json::to_vec(&requests)?;
6242
6243 let resp: Vec<OKXCancelAlgoOrderResponse> = self
6244 .inner
6245 .send_request::<_, ()>(
6246 Method::POST,
6247 "/api/v5/trade/cancel-advance-algos",
6248 None,
6249 Some(body),
6250 true,
6251 )
6252 .await?;
6253
6254 for item in &resp {
6255 if let Some(ref code) = item.s_code
6256 && code != "0"
6257 {
6258 let msg = item.s_msg.as_deref().unwrap_or("");
6259 log::debug!(
6260 "Advance algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
6261 item.algo_id
6262 );
6263 }
6264 }
6265
6266 Ok(resp)
6267 }
6268
6269 pub async fn amend_algo_order(
6279 &self,
6280 request: OKXAmendAlgoOrderRequest,
6281 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
6282 let body = serde_json::to_vec(&request)?;
6283
6284 let resp: Vec<OKXAmendAlgoOrderResponse> = self
6285 .inner
6286 .send_request::<_, ()>(
6287 Method::POST,
6288 "/api/v5/trade/amend-algos",
6289 None,
6290 Some(body),
6291 true,
6292 )
6293 .await?;
6294
6295 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
6296 }
6297
6298 #[expect(clippy::too_many_arguments)]
6307 pub async fn amend_algo_order_with_domain_types(
6308 &self,
6309 instrument_id: InstrumentId,
6310 algo_id: String,
6311 new_trigger_price: Option<Price>,
6312 new_sl_trigger_price: Option<Price>,
6313 new_limit_price: Option<Price>,
6314 new_quantity: Option<Quantity>,
6315 new_callback_ratio: Option<String>,
6316 new_callback_spread: Option<String>,
6317 new_activation_price: Option<Price>,
6318 new_tp_trigger_price: Option<Price>,
6319 new_tp_order_price: Option<String>,
6320 new_tp_trigger_px_type: Option<String>,
6321 new_sl_order_price: Option<String>,
6322 new_sl_trigger_px_type: Option<String>,
6323 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
6324 let request = OKXAmendAlgoOrderRequest {
6325 inst_id: instrument_id.symbol.as_str().to_string(),
6326 algo_id,
6327 algo_cl_ord_id: None,
6328 new_sz: new_quantity.map(|q| q.to_string()),
6329 new_trigger_px: new_trigger_price.map(|p| p.to_string()),
6330 new_trigger_px_type: None,
6331 new_tp_trigger_px: new_tp_trigger_price.map(|p| p.to_string()),
6332 new_tp_ord_px: new_tp_order_price,
6333 new_tp_trigger_px_type,
6334 new_sl_trigger_px: new_sl_trigger_price.map(|p| p.to_string()),
6335 new_sl_ord_px: new_sl_order_price,
6336 new_sl_trigger_px_type,
6337 new_order_px: new_limit_price.map(|p| p.to_string()),
6338 new_callback_ratio,
6339 new_callback_spread,
6340 new_active_px: new_activation_price.map(|p| p.to_string()),
6341 };
6342
6343 self.amend_algo_order(request).await
6344 }
6345
6346 #[expect(clippy::too_many_arguments)]
6355 pub async fn place_order_with_domain_types(
6356 &self,
6357 instrument_id: InstrumentId,
6358 td_mode: OKXTradeMode,
6359 client_order_id: ClientOrderId,
6360 order_side: OrderSide,
6361 order_type: OrderType,
6362 quantity: Quantity,
6363 time_in_force: Option<TimeInForce>,
6364 price: Option<Price>,
6365 post_only: Option<bool>,
6366 reduce_only: Option<bool>,
6367 quote_quantity: Option<bool>,
6368 position_side: Option<PositionSide>,
6369 attach_algo_ords: Option<Vec<OKXAttachAlgoOrdRequest>>,
6370 px_usd: Option<String>,
6371 px_vol: Option<String>,
6372 outcome: Option<String>,
6373 slippage_pct: Option<String>,
6374 rpi: Option<bool>,
6375 rpi_taker_access: Option<bool>,
6376 rpi_px_round: Option<bool>,
6377 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
6378 let rpi = rpi.unwrap_or(false);
6379
6380 if self.is_spread(instrument_id) {
6381 if reduce_only.unwrap_or(false)
6382 || quote_quantity.unwrap_or(false)
6383 || attach_algo_ords
6384 .as_ref()
6385 .is_some_and(|orders| !orders.is_empty())
6386 || px_usd.is_some()
6387 || px_vol.is_some()
6388 || outcome.is_some()
6389 || slippage_pct.is_some()
6390 || rpi
6391 || rpi_taker_access.is_some()
6392 || rpi_px_round.is_some()
6393 {
6394 return Err(OKXHttpError::ValidationError(
6395 "OKX spread orders do not support regular order extensions".to_string(),
6396 ));
6397 }
6398
6399 return self
6400 .place_spread_order_with_domain_types(
6401 instrument_id,
6402 client_order_id,
6403 order_side,
6404 order_type,
6405 quantity,
6406 time_in_force,
6407 price,
6408 post_only,
6409 )
6410 .await;
6411 }
6412
6413 if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
6414 return Err(OKXHttpError::ValidationError(format!(
6415 "Unsupported order type: {order_type:?}",
6416 )));
6417 }
6418
6419 if matches!(
6420 order_type,
6421 OrderType::StopMarket
6422 | OrderType::StopLimit
6423 | OrderType::MarketIfTouched
6424 | OrderType::LimitIfTouched
6425 | OrderType::TrailingStopMarket
6426 ) {
6427 return Err(OKXHttpError::ValidationError(
6428 "Conditional order types must use OKX algo order placement".to_string(),
6429 ));
6430 }
6431
6432 if let Some(tif) = time_in_force
6433 && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
6434 {
6435 return Err(OKXHttpError::ValidationError(format!(
6436 "Unsupported time in force: {tif:?}",
6437 )));
6438 }
6439
6440 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6441 return Err(OKXHttpError::ValidationError(
6442 "Invalid order side".to_string(),
6443 ));
6444 }
6445
6446 let instrument = self
6447 .instrument_from_cache(instrument_id.symbol.inner())
6448 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6449 let instrument_type = okx_instrument_type(&instrument)
6450 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6451
6452 if instrument_type == OKXInstrumentType::Option
6454 && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
6455 {
6456 return Err(OKXHttpError::ValidationError(
6457 "Market orders are not supported for OKX options, use Limit orders instead"
6458 .to_string(),
6459 ));
6460 }
6461
6462 let side = OKXSide::from(order_side);
6463 let pos_side = position_side.map(Into::into).or({
6464 if matches!(
6465 instrument_type,
6466 OKXInstrumentType::Swap | OKXInstrumentType::Futures | OKXInstrumentType::Option
6467 ) {
6468 Some(OKXPositionSide::Net)
6469 } else {
6470 None
6471 }
6472 });
6473
6474 let tgt_ccy = if instrument_type == OKXInstrumentType::Spot
6475 && order_type == OrderType::Market
6476 && td_mode == OKXTradeMode::Cash
6477 {
6478 match quote_quantity {
6479 Some(true) => Some(OKXTargetCurrency::QuoteCcy),
6480 Some(false) if order_side == OrderSide::Buy => Some(OKXTargetCurrency::BaseCcy),
6481 _ => None,
6482 }
6483 } else {
6484 None
6485 };
6486
6487 if rpi && order_type != OrderType::Limit {
6488 return Err(OKXHttpError::ValidationError(
6489 "OKX RPI orders require a limit order".to_string(),
6490 ));
6491 }
6492
6493 let (ord_type, px) = if rpi {
6494 (OKXOrderType::Rpi, price)
6495 } else if post_only.unwrap_or(false) {
6496 (OKXOrderType::PostOnly, price)
6497 } else if let Some(tif) = time_in_force {
6498 match (order_type, tif) {
6499 (OrderType::Market, TimeInForce::Fok) => {
6500 return Err(OKXHttpError::ValidationError(
6501 "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string(),
6502 ));
6503 }
6504 (OrderType::Market, TimeInForce::Ioc) => {
6505 if matches!(
6507 instrument_type,
6508 OKXInstrumentType::Spot | OKXInstrumentType::Option
6509 ) {
6510 (OKXOrderType::Market, price)
6511 } else {
6512 (OKXOrderType::OptimalLimitIoc, price)
6513 }
6514 }
6515 (OrderType::Limit, TimeInForce::Fok) => {
6516 if instrument_type == OKXInstrumentType::Option {
6518 (OKXOrderType::OpFok, price)
6519 } else {
6520 (OKXOrderType::Fok, price)
6521 }
6522 }
6523 (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
6524 _ => (OKXOrderType::from(order_type), price),
6525 }
6526 } else {
6527 (OKXOrderType::from(order_type), price)
6528 };
6529
6530 if instrument_type == OKXInstrumentType::Events && outcome.is_none() {
6531 return Err(OKXHttpError::ValidationError(
6532 "OKX event contract orders require `outcome`".to_string(),
6533 ));
6534 }
6535
6536 let reduce_only = if reduce_only == Some(true) || position_side.is_some() {
6537 okx_reduce_only_wire_value(
6538 instrument_type,
6539 td_mode,
6540 order_side,
6541 position_side,
6542 reduce_only,
6543 )
6544 .map_err(OKXHttpError::ValidationError)?
6545 } else {
6546 reduce_only
6547 };
6548
6549 let (px, px_usd, px_vol) = if px_usd.is_some() {
6551 (None, px_usd, None)
6552 } else if px_vol.is_some() {
6553 (None, None, px_vol)
6554 } else {
6555 (px.map(|p| p.to_string()), None, None)
6556 };
6557
6558 let trade_quote_ccy =
6559 self.resolve_spot_trade_quote_ccy(instrument_type, instrument_id.symbol.inner())?;
6560
6561 let request = OKXPlaceOrderRequest {
6562 inst_id: instrument_id.symbol.as_str().to_string(),
6563 td_mode,
6564 ccy: None,
6565 cl_ord_id: Some(client_order_id.as_str().to_string()),
6566 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6567 side,
6568 pos_side,
6569 ord_type,
6570 sz: quantity.to_string(),
6571 px,
6572 px_usd,
6573 px_vol,
6574 reduce_only,
6575 tgt_ccy,
6576 trade_quote_ccy,
6577 attach_algo_ords,
6578 outcome,
6579 slippage_pct,
6580 rpi_taker_access,
6581 rpi_px_round,
6582 };
6583
6584 self.place_order(request).await
6585 }
6586
6587 #[expect(clippy::too_many_arguments)]
6594 pub async fn place_spread_order_with_domain_types(
6595 &self,
6596 instrument_id: InstrumentId,
6597 client_order_id: ClientOrderId,
6598 order_side: OrderSide,
6599 order_type: OrderType,
6600 quantity: Quantity,
6601 time_in_force: Option<TimeInForce>,
6602 price: Option<Price>,
6603 post_only: Option<bool>,
6604 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
6605 if !self.is_spread(instrument_id) {
6606 return Err(OKXHttpError::ValidationError(format!(
6607 "Instrument is not an OKX spread: {instrument_id}",
6608 )));
6609 }
6610
6611 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6612 return Err(OKXHttpError::ValidationError(
6613 "Invalid order side".to_string(),
6614 ));
6615 }
6616
6617 if !matches!(order_type, OrderType::Limit) {
6618 return Err(OKXHttpError::ValidationError(
6619 "OKX spread orders support Limit orders only".to_string(),
6620 ));
6621 }
6622
6623 let ord_type = if post_only.unwrap_or(false) {
6624 OKXOrderType::PostOnly
6625 } else if matches!(time_in_force, Some(TimeInForce::Ioc)) {
6626 OKXOrderType::Ioc
6627 } else if matches!(time_in_force, Some(TimeInForce::Fok)) {
6628 return Err(OKXHttpError::ValidationError(
6629 "OKX spread orders do not support FOK time-in-force".to_string(),
6630 ));
6631 } else {
6632 OKXOrderType::Limit
6633 };
6634
6635 let price = price.ok_or_else(|| {
6636 OKXHttpError::ValidationError("OKX spread orders require a limit price".to_string())
6637 })?;
6638
6639 let request = OKXPlaceSpreadOrderRequest {
6640 sprd_id: instrument_id.symbol.as_str().to_string(),
6641 cl_ord_id: Some(client_order_id.as_str().to_string()),
6642 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6643 side: OKXSide::from(order_side),
6644 ord_type,
6645 sz: quantity.to_string(),
6646 px: Some(price.to_string()),
6647 };
6648
6649 self.place_spread_order(request).await
6650 }
6651
6652 #[expect(clippy::too_many_arguments)]
6666 pub async fn place_algo_order_with_domain_types(
6667 &self,
6668 instrument_id: InstrumentId,
6669 td_mode: OKXTradeMode,
6670 client_order_id: ClientOrderId,
6671 order_side: OrderSide,
6672 order_type: OrderType,
6673 quantity: Quantity,
6674 quote_quantity: bool,
6675 trigger_price: Option<Price>,
6676 trigger_type: Option<TriggerType>,
6677 limit_price: Option<Price>,
6678 reduce_only: Option<bool>,
6679 position_side: Option<PositionSide>,
6680 close_fraction: Option<String>,
6681 callback_ratio: Option<String>,
6682 callback_spread: Option<String>,
6683 activation_price: Option<Price>,
6684 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
6685 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6686 return Err(OKXHttpError::ValidationError(
6687 "Invalid order side".to_string(),
6688 ));
6689 }
6690
6691 let okx_side = OKXSide::from(order_side);
6692
6693 let instrument_type = okx_instrument_type_from_symbol(instrument_id.symbol.as_str());
6694
6695 let tgt_ccy = okx_algo_target_currency(
6696 instrument_type,
6697 td_mode,
6698 order_side,
6699 order_type,
6700 quote_quantity,
6701 )
6702 .map_err(OKXHttpError::from_order_denial)?;
6703
6704 validate_algo_trigger_type(instrument_type, td_mode, order_type, trigger_type)
6705 .map_err(OKXHttpError::ValidationError)?;
6706
6707 let ccy = if instrument_type == OKXInstrumentType::Spot && td_mode != OKXTradeMode::Cash {
6708 let (_, quote) = parse_base_quote_from_symbol(instrument_id.symbol.as_str())
6709 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6710 Some(quote.to_string())
6711 } else {
6712 None
6713 };
6714
6715 let trigger_px_type_enum = trigger_type.map_or(OKXTriggerType::Last, Into::into);
6716
6717 let uses_close_fraction = close_fraction.is_some();
6718
6719 let (
6720 algo_type,
6721 sz,
6722 trigger_px,
6723 order_px,
6724 trigger_px_type,
6725 sl_trigger_px,
6726 sl_ord_px,
6727 sl_trigger_px_type,
6728 tp_trigger_px,
6729 tp_ord_px,
6730 tp_trigger_px_type,
6731 pos_side,
6732 reduce_only,
6733 ) = if uses_close_fraction || tgt_ccy.is_some() {
6734 if order_type == OrderType::TrailingStopMarket {
6735 return Err(OKXHttpError::ValidationError(
6736 "OKX close_fraction does not support TrailingStopMarket".to_string(),
6737 ));
6738 }
6739
6740 let trigger_px = trigger_price.map(|p| p.to_string()).ok_or_else(|| {
6741 OKXHttpError::ValidationError(
6742 "OKX conditional orders require trigger_price".to_string(),
6743 )
6744 })?;
6745
6746 let close_order_px =
6747 if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched) {
6748 limit_price.map(|p| p.to_string()).ok_or_else(|| {
6749 OKXHttpError::ValidationError(format!(
6750 "OKX {order_type:?} close_fraction orders require limit_price"
6751 ))
6752 })?
6753 } else {
6754 "-1".to_string()
6755 };
6756
6757 let (
6758 sl_trigger_px,
6759 sl_ord_px,
6760 sl_trigger_px_type,
6761 tp_trigger_px,
6762 tp_ord_px,
6763 tp_trigger_px_type,
6764 ) = match order_type {
6765 OrderType::StopMarket | OrderType::StopLimit => (
6766 Some(trigger_px),
6767 Some(close_order_px),
6768 Some(trigger_px_type_enum),
6769 None,
6770 None,
6771 None,
6772 ),
6773 OrderType::MarketIfTouched | OrderType::LimitIfTouched => (
6774 None,
6775 None,
6776 None,
6777 Some(trigger_px),
6778 Some(close_order_px),
6779 Some(trigger_px_type_enum),
6780 ),
6781 _ => {
6782 return Err(OKXHttpError::ValidationError(format!(
6783 "OKX close_fraction is only supported for stop/touched conditional orders, received {order_type:?}"
6784 )));
6785 }
6786 };
6787
6788 (
6789 OKXAlgoOrderType::Conditional,
6790 (!uses_close_fraction).then(|| quantity.to_string()),
6791 None,
6792 None,
6793 None,
6794 sl_trigger_px,
6795 sl_ord_px,
6796 sl_trigger_px_type,
6797 tp_trigger_px,
6798 tp_ord_px,
6799 tp_trigger_px_type,
6800 if uses_close_fraction {
6801 Some(position_side.map_or(OKXPositionSide::Net, Into::into))
6802 } else {
6803 position_side.map(Into::into)
6804 },
6805 if uses_close_fraction {
6806 Some(true)
6807 } else {
6808 reduce_only
6809 },
6810 )
6811 } else {
6812 let algo_type = conditional_order_to_algo_type(order_type)
6813 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6814
6815 let order_px = if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
6816 {
6817 limit_price.map(|p| p.to_string())
6818 } else if order_type == OrderType::TrailingStopMarket {
6819 None
6820 } else {
6821 Some("-1".to_string())
6822 };
6823
6824 (
6825 algo_type,
6826 Some(quantity.to_string()),
6827 trigger_price.map(|p| p.to_string()),
6828 order_px,
6829 Some(trigger_px_type_enum),
6830 None,
6831 None,
6832 None,
6833 None,
6834 None,
6835 None,
6836 position_side.map(Into::into),
6837 reduce_only,
6838 )
6839 };
6840
6841 let reduce_only = if position_side.is_some() {
6842 okx_reduce_only_wire_value(
6843 instrument_type,
6844 td_mode,
6845 order_side,
6846 position_side,
6847 reduce_only,
6848 )
6849 .map_err(OKXHttpError::ValidationError)?
6850 } else {
6851 reduce_only
6852 };
6853
6854 let request = OKXPlaceAlgoOrderRequest {
6855 inst_id: instrument_id.symbol.as_str().to_string(),
6856 inst_id_code: None,
6857 td_mode,
6858 side: okx_side,
6859 ord_type: algo_type,
6860 sz,
6861 algo_cl_ord_id: Some(client_order_id.as_str().to_string()),
6862 trigger_px,
6863 order_px,
6864 trigger_px_type,
6865 sl_trigger_px,
6866 sl_ord_px,
6867 sl_trigger_px_type,
6868 tp_trigger_px,
6869 tp_ord_px,
6870 tp_trigger_px_type,
6871 tgt_ccy,
6872 pos_side,
6873 close_position: None,
6874 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6875 reduce_only,
6876 close_fraction,
6877 callback_ratio,
6878 callback_spread,
6879 active_px: activation_price.map(|p| p.to_string()),
6880 ccy,
6881 };
6882
6883 self.place_algo_order(request).await
6884 }
6885
6886 pub async fn cancel_algo_order_with_domain_types(
6895 &self,
6896 instrument_id: InstrumentId,
6897 algo_id: String,
6898 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
6899 let request = OKXCancelAlgoOrderRequest {
6900 inst_id: instrument_id.symbol.to_string(),
6901 inst_id_code: None,
6902 algo_id: Some(algo_id),
6903 algo_cl_ord_id: None,
6904 };
6905
6906 self.cancel_algo_order(request).await
6907 }
6908
6909 #[expect(clippy::too_many_arguments)]
6915 pub async fn request_algo_order_status_reports(
6916 &self,
6917 account_id: AccountId,
6918 instrument_type: Option<OKXInstrumentType>,
6919 instrument_id: Option<InstrumentId>,
6920 algo_id: Option<String>,
6921 algo_client_order_id: Option<ClientOrderId>,
6922 state: Option<OKXAlgoOrderStatus>,
6923 limit: Option<u32>,
6924 ) -> anyhow::Result<Vec<OrderStatusReport>> {
6925 Ok(self
6926 .request_algo_order_status_reports_sweep(
6927 account_id,
6928 instrument_type,
6929 instrument_id,
6930 algo_id,
6931 algo_client_order_id,
6932 state,
6933 limit,
6934 None,
6935 None,
6936 false,
6937 None,
6938 )
6939 .await?
6940 .reports)
6941 }
6942
6943 #[expect(clippy::too_many_arguments)]
6944 pub(crate) async fn request_algo_order_status_reports_sweep(
6945 &self,
6946 account_id: AccountId,
6947 instrument_type: Option<OKXInstrumentType>,
6948 instrument_id: Option<InstrumentId>,
6949 algo_id: Option<String>,
6950 algo_client_order_id: Option<ClientOrderId>,
6951 state: Option<OKXAlgoOrderStatus>,
6952 limit: Option<u32>,
6953 start: Option<Timestamp>,
6954 end: Option<Timestamp>,
6955 require_complete_active_coverage: bool,
6956 max_history_pages: Option<u32>,
6957 ) -> anyhow::Result<AlgoOrderReportSweep> {
6958 let mut instruments_cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
6959 let mut ambiguous_triggered_child_ids = AHashSet::new();
6960 let has_specific_lookup = algo_id.is_some() || algo_client_order_id.is_some();
6961 let start_ns = start.map(UnixNanos::from);
6962 let end_ns = end.map(UnixNanos::from);
6963 let mut complete = true;
6964
6965 let inst_type = if let Some(inst_type) = instrument_type {
6966 inst_type
6967 } else if let Some(inst_id) = instrument_id {
6968 let instrument = self.instrument_from_cache(inst_id.symbol.inner())?;
6969 let inst_type = okx_instrument_type(&instrument)?;
6970 instruments_cache.insert(inst_id.symbol.inner(), instrument);
6971 inst_type
6972 } else {
6973 anyhow::bail!("instrument_type or instrument_id required for algo order query")
6974 };
6975
6976 let ts_init = self.generate_ts_init();
6977 let mut reports = Vec::new();
6978 let mut seen: AHashMap<(String, String), usize> = AHashMap::new();
6979
6980 if has_specific_lookup {
6981 let mut params_builder = GetAlgoOrderParamsBuilder::default();
6982
6983 if let Some(algo_id) = algo_id {
6984 params_builder.algo_id(algo_id);
6985 }
6986
6987 if let Some(client_order_id) = algo_client_order_id {
6988 params_builder.algo_cl_ord_id(client_order_id.as_str().to_string());
6989 }
6990
6991 let params = params_builder
6992 .build()
6993 .map_err(|e| anyhow::anyhow!(format!("Failed to build algo order params: {e}")))?;
6994 let mut orders = match self.inner.get_algo_order(params).await {
6995 Ok(orders) => orders,
6996 Err(e) if e.is_order_not_found() => {
6997 return Ok(AlgoOrderReportSweep {
6998 reports,
6999 complete,
7000 ambiguous_triggered_child_ids,
7001 });
7002 }
7003 Err(e) => return Err(e.into()),
7004 };
7005
7006 if let Some(state) = state {
7007 orders.retain(|order| order.state == state);
7008 }
7009
7010 self.resolve_close_fraction_sizes(&mut orders).await?;
7011
7012 complete &= self.collect_algo_reports(
7013 account_id,
7014 &orders,
7015 false,
7016 &mut instruments_cache,
7017 ts_init,
7018 start_ns,
7019 end_ns,
7020 &mut seen,
7021 &mut reports,
7022 &mut ambiguous_triggered_child_ids,
7023 )?;
7024
7025 if let Some(limit) = limit {
7026 reports.truncate(limit as usize);
7027 }
7028
7029 return Ok(AlgoOrderReportSweep {
7030 reports,
7031 complete,
7032 ambiguous_triggered_child_ids,
7033 });
7034 }
7035
7036 let query_pending = state.is_none()
7037 || matches!(
7038 state,
7039 Some(OKXAlgoOrderStatus::Live | OKXAlgoOrderStatus::Pause)
7040 );
7041 let history_states: &[OKXAlgoOrderStatus] = match state {
7042 None => &[
7043 OKXAlgoOrderStatus::Effective,
7044 OKXAlgoOrderStatus::Canceled,
7045 OKXAlgoOrderStatus::OrderFailed,
7046 ],
7047 Some(OKXAlgoOrderStatus::Unknown) => &[],
7049 Some(OKXAlgoOrderStatus::Live | OKXAlgoOrderStatus::Pause) => &[],
7050 Some(
7051 OKXAlgoOrderStatus::Effective
7052 | OKXAlgoOrderStatus::OrderPlaced
7053 | OKXAlgoOrderStatus::PartiallyEffective
7054 | OKXAlgoOrderStatus::Filled,
7055 ) => &[OKXAlgoOrderStatus::Effective],
7056 Some(OKXAlgoOrderStatus::Canceled) => &[OKXAlgoOrderStatus::Canceled],
7057 Some(OKXAlgoOrderStatus::OrderFailed | OKXAlgoOrderStatus::PartiallyFailed) => {
7058 &[OKXAlgoOrderStatus::OrderFailed]
7059 }
7060 };
7061
7062 for ord_type in [
7063 OKXAlgoOrderType::Oco,
7064 OKXAlgoOrderType::Conditional,
7065 OKXAlgoOrderType::Trigger,
7066 OKXAlgoOrderType::MoveOrderStop,
7067 ] {
7068 let mut params_builder = GetAlgoOrdersParamsBuilder::default();
7069 params_builder.inst_type(inst_type);
7070 params_builder.ord_type(ord_type);
7071
7072 if let Some(inst_id) = instrument_id {
7073 params_builder.inst_id(inst_id.symbol.inner().to_string());
7074 }
7075
7076 let mut params = params_builder
7077 .build()
7078 .map_err(|e| anyhow::anyhow!(format!("Failed to build algo order params: {e}")))?;
7079
7080 if query_pending {
7081 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
7082 let pending_sweep = match self
7083 .paginate_algo_pending(¶ms, remaining, require_complete_active_coverage)
7084 .await
7085 {
7086 Ok(sweep) => sweep,
7087 Err(e) if require_complete_active_coverage => {
7088 return Err(OKXPendingAlgoOrderReportsError::new(e).into());
7089 }
7090 Err(e) => return Err(e),
7091 };
7092
7093 if require_complete_active_coverage && !pending_sweep.complete {
7094 return Err(OKXPendingAlgoOrderReportsError::new(anyhow::anyhow!(
7095 "Pending {ord_type:?} algo order pagination for {inst_type:?} did not establish complete coverage"
7096 ))
7097 .into());
7098 }
7099 complete &= pending_sweep.complete;
7100 let mut pending = pending_sweep.items;
7101
7102 if let Some(state) = state {
7103 pending.retain(|order| order.state == state);
7104 }
7105
7106 self.resolve_close_fraction_sizes(&mut pending)
7107 .await
7108 .map_err(OKXPendingAlgoOrderReportsError::new)?;
7109
7110 let pending_reports_complete = match self.collect_algo_reports(
7111 account_id,
7112 &pending,
7113 require_complete_active_coverage,
7114 &mut instruments_cache,
7115 ts_init,
7116 start_ns,
7117 end_ns,
7118 &mut seen,
7119 &mut reports,
7120 &mut ambiguous_triggered_child_ids,
7121 ) {
7122 Ok(complete) => complete,
7123 Err(e) if require_complete_active_coverage => {
7124 return Err(OKXPendingAlgoOrderReportsError::new(e).into());
7125 }
7126 Err(e) => return Err(e),
7127 };
7128
7129 if require_complete_active_coverage && !pending_reports_complete {
7130 return Err(OKXPendingAlgoOrderReportsError::new(anyhow::anyhow!(
7131 "Pending {ord_type:?} algo order reports for {inst_type:?} could not be completely converted"
7132 ))
7133 .into());
7134 }
7135 complete &= pending_reports_complete;
7136
7137 if let Some(lim) = limit
7138 && reports.len() >= lim as usize
7139 {
7140 reports.truncate(lim as usize);
7141 return Ok(AlgoOrderReportSweep {
7142 reports,
7143 complete,
7144 ambiguous_triggered_child_ids,
7145 });
7146 }
7147 }
7148
7149 for history_state in history_states {
7150 params.state = Some(*history_state);
7151 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
7152
7153 let history_sweep = match self
7154 .paginate_algo_history(
7155 ¶ms,
7156 remaining,
7157 require_complete_active_coverage,
7158 reconciliation_history_page_limit(max_history_pages),
7159 )
7160 .await
7161 {
7162 Ok(sweep) => sweep,
7163 Err(e) if require_complete_active_coverage => {
7164 log::warn!(
7165 "Failed to fetch {history_state:?} {ord_type:?} algo order history for {inst_type:?}: {e}"
7166 );
7167 complete = false;
7168 continue;
7169 }
7170 Err(e) => return Err(e),
7171 };
7172 complete &= history_sweep.complete;
7173 let mut history = history_sweep.items;
7174
7175 if let Some(state) = state {
7176 history.retain(|order| order.state == state);
7177 }
7178
7179 complete &= self.collect_algo_reports(
7180 account_id,
7181 &history,
7182 false,
7183 &mut instruments_cache,
7184 ts_init,
7185 start_ns,
7186 end_ns,
7187 &mut seen,
7188 &mut reports,
7189 &mut ambiguous_triggered_child_ids,
7190 )?;
7191
7192 if let Some(lim) = limit
7193 && reports.len() >= lim as usize
7194 {
7195 reports.truncate(lim as usize);
7196 return Ok(AlgoOrderReportSweep {
7197 reports,
7198 complete,
7199 ambiguous_triggered_child_ids,
7200 });
7201 }
7202 }
7203 }
7204
7205 Ok(AlgoOrderReportSweep {
7206 reports,
7207 complete,
7208 ambiguous_triggered_child_ids,
7209 })
7210 }
7211
7212 pub async fn request_algo_order_status_report(
7218 &self,
7219 account_id: AccountId,
7220 instrument_id: InstrumentId,
7221 algo_client_order_id: ClientOrderId,
7222 ) -> anyhow::Result<Option<OrderStatusReport>> {
7223 let reports = self
7224 .request_algo_order_status_reports(
7225 account_id,
7226 None,
7227 Some(instrument_id),
7228 None,
7229 Some(algo_client_order_id),
7230 None,
7231 Some(50_u32),
7232 )
7233 .await?;
7234
7235 Ok(reports.into_iter().next())
7236 }
7237
7238 pub fn raw_client(&self) -> &Arc<OKXRawHttpClient> {
7240 &self.inner
7241 }
7242
7243 async fn resolve_close_fraction_sizes(
7249 &self,
7250 orders: &mut [OKXOrderAlgo],
7251 ) -> anyhow::Result<()> {
7252 let needs_size = |order: &OKXOrderAlgo| {
7253 order.sz.is_empty() && !order.close_fraction.is_empty() && is_open_okx_algo(order.state)
7254 };
7255
7256 if !orders.iter().any(needs_size) {
7257 return Ok(());
7258 }
7259
7260 let positions = self
7261 .inner
7262 .get_positions(GetPositionsParams::default())
7263 .await
7264 .map_err(|e| anyhow::anyhow!(e))?;
7265
7266 for order in orders.iter_mut().filter(|order| needs_size(order)) {
7267 let position = positions.iter().find(|position| {
7268 position
7269 .close_order_algo
7270 .iter()
7271 .any(|algo| algo.algo_id == order.algo_id)
7272 });
7273
7274 let Some(position) = position else {
7275 log::warn!(
7276 "No position links close-fraction algo order {}, quantity remains zero",
7277 order.algo_id,
7278 );
7279 continue;
7280 };
7281
7282 let size = Decimal::from_str(&position.pos).with_context(|| {
7283 format!("invalid position size for algo order {}", order.algo_id)
7284 })?;
7285
7286 order.sz = size.abs().to_string();
7287 }
7288
7289 Ok(())
7290 }
7291
7292 #[expect(clippy::too_many_arguments)]
7293 fn collect_algo_reports(
7294 &self,
7295 account_id: AccountId,
7296 orders: &[OKXOrderAlgo],
7297 from_pending_source: bool,
7298 instruments_cache: &mut AHashMap<Ustr, InstrumentAny>,
7299 ts_init: UnixNanos,
7300 start_ns: Option<UnixNanos>,
7301 end_ns: Option<UnixNanos>,
7302 seen: &mut AHashMap<(String, String), usize>,
7303 reports: &mut Vec<OrderStatusReport>,
7304 ambiguous_triggered_child_ids: &mut AHashSet<VenueOrderId>,
7305 ) -> anyhow::Result<bool> {
7306 let mut complete = true;
7307
7308 for order in orders {
7309 let key = (order.algo_id.clone(), order.algo_cl_ord_id.clone());
7310
7311 let has_ambiguous_children = order.ord_id_list.len() > 1
7312 || !order.sub_algo_id_list.is_empty()
7313 || matches!(
7314 order.ord_id_list.as_slice(),
7315 [child_order_id]
7316 if !order.ord_id.is_empty() && child_order_id != &order.ord_id
7317 );
7318
7319 if !from_pending_source
7322 && report_ts_outside_window(order.u_time, start_ns, end_ns)
7323 && !is_open_okx_algo(order.state)
7324 {
7325 continue;
7326 }
7327
7328 let instrument = if let Some(instrument) = instruments_cache.get(&order.inst_id) {
7329 instrument.clone()
7330 } else {
7331 match self.resolve_report_instrument(
7332 order.inst_id,
7333 order.inst_type,
7334 false,
7335 is_open_okx_algo(order.state),
7336 None,
7337 )? {
7338 InstrumentResolution::Found(instrument) => {
7339 instruments_cache.insert(order.inst_id, (*instrument).clone());
7340 *instrument
7341 }
7342 InstrumentResolution::Skip => continue,
7343 InstrumentResolution::Incomplete => {
7344 complete = false;
7345 continue;
7346 }
7347 }
7348 };
7349
7350 match parse_http_algo_order(order, account_id, &instrument, ts_init) {
7351 Ok(report) => {
7352 if has_ambiguous_children && report.order_status == OrderStatus::Triggered {
7353 log::warn!(
7354 "Algo order {} has ambiguous child order identifiers",
7355 order.algo_id,
7356 );
7357 ambiguous_triggered_child_ids.insert(report.venue_order_id);
7358 }
7359
7360 if let Some(index) = seen.get(&key).copied() {
7361 if is_order_status_report_more_advanced(&report, &reports[index]) {
7362 reports[index] = report;
7363 }
7364 } else {
7365 seen.insert(key, reports.len());
7366 reports.push(report);
7367 }
7368 }
7369 Err(e) => {
7370 log::warn!("Failed to parse algo order report: {e}");
7371 complete = false;
7372 }
7373 }
7374 }
7375
7376 Ok(complete)
7377 }
7378
7379 fn resolve_report_instrument(
7380 &self,
7381 symbol: Ustr,
7382 inst_type: OKXInstrumentType,
7383 is_spread: bool,
7384 is_open_or_position: bool,
7385 scope: Option<ReportInstrumentScope<'_>>,
7386 ) -> anyhow::Result<InstrumentResolution> {
7387 if let Ok(instrument) = self.instrument_from_cache(symbol) {
7388 return Ok(InstrumentResolution::Found(Box::new(instrument)));
7389 }
7390
7391 let in_scope = match scope {
7392 None => true,
7393 Some(scope) if is_spread => scope.load_spreads,
7394 Some(scope) => {
7395 scope.instrument_types.contains(&inst_type)
7396 || scope.instrument_types.contains(&OKXInstrumentType::Any)
7397 }
7398 };
7399
7400 if !in_scope {
7401 log::debug!("Skipping report for out-of-scope instrument: symbol={symbol}");
7402 return Ok(InstrumentResolution::Skip);
7403 }
7404
7405 if is_open_or_position {
7406 anyhow::bail!("Instrument {symbol} missing from cache");
7407 }
7408
7409 log::warn!("Instrument {symbol} missing from cache");
7410 Ok(InstrumentResolution::Incomplete)
7411 }
7412
7413 pub(crate) async fn request_spread_order_status_report(
7414 &self,
7415 account_id: AccountId,
7416 instrument_id: InstrumentId,
7417 client_order_id: Option<ClientOrderId>,
7418 venue_order_id: Option<VenueOrderId>,
7419 ) -> anyhow::Result<Option<OrderStatusReport>> {
7420 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
7421 let mut params_builder = GetSpreadOrderParamsBuilder::default();
7422
7423 match (client_order_id, venue_order_id) {
7424 (Some(client_order_id), None) => {
7425 params_builder.cl_ord_id(client_order_id.as_str().to_string());
7426 }
7427 (None, Some(venue_order_id)) => {
7428 params_builder.ord_id(venue_order_id.as_str().to_string());
7429 }
7430 _ => anyhow::bail!(
7431 "Exactly one of client_order_id or venue_order_id is required for a spread order detail request"
7432 ),
7433 }
7434
7435 let params = params_builder
7436 .build()
7437 .map_err(|e| anyhow::anyhow!("Failed to build spread order detail params: {e}"))?;
7438 let orders = match self.inner.get_spread_order(params).await {
7439 Ok(orders) => orders,
7440 Err(e) if e.is_order_not_found() => return Ok(None),
7441 Err(e) => return Err(e.into()),
7442 };
7443 let Some(order) = orders.into_iter().next() else {
7444 return Ok(None);
7445 };
7446 let ts_init = self.generate_ts_init();
7447 let report = parse_spread_order_status_report(
7448 &order,
7449 account_id,
7450 instrument.id(),
7451 instrument.price_precision(),
7452 instrument.size_precision(),
7453 ts_init,
7454 )?;
7455
7456 Ok(Some(report))
7457 }
7458}
7459
7460fn parse_historical_candlestick(
7461 raw: &OKXCandlestick,
7462 bar_type: BarType,
7463 price_precision: u8,
7464 size_precision: u8,
7465) -> anyhow::Result<Bar> {
7466 let mut bar = parse_candlestick(
7467 raw,
7468 bar_type,
7469 price_precision,
7470 size_precision,
7471 UnixNanos::default(),
7472 )?;
7473
7474 bar.ts_init = if bar_type.spec().aggregation == BarAggregation::Month {
7475 let time_zone = jiff::tz::TimeZone::fixed(jiff::tz::offset(8));
7476 let (_, _, close) = get_time_bar_bounds(
7477 bar.ts_event.to_datetime_utc(),
7478 &bar_type,
7479 None,
7480 &time_zone,
7481 None,
7482 );
7483 UnixNanos::from(close)
7484 } else {
7485 bar.ts_event + get_bar_interval_ns(&bar_type)
7486 };
7487
7488 Ok(bar)
7489}
7490
7491fn is_open_okx_order(state: OKXOrderStatus) -> bool {
7492 matches!(
7493 state,
7494 OKXOrderStatus::Live | OKXOrderStatus::PartiallyFilled
7495 )
7496}
7497
7498fn fill_quantity_is_positive(value: &str) -> Result<bool, rust_decimal::Error> {
7499 Ok(Decimal::from_str(value)? > Decimal::ZERO)
7500}
7501
7502fn report_ts_outside_window(
7503 timestamp_ms: u64,
7504 start_ns: Option<UnixNanos>,
7505 end_ns: Option<UnixNanos>,
7506) -> bool {
7507 if start_ns.is_none() && end_ns.is_none() {
7508 return false;
7509 }
7510
7511 let ts = parse_millisecond_timestamp(timestamp_ms);
7512 start_ns.is_some_and(|start| ts < start) || end_ns.is_some_and(|end| ts > end)
7513}
7514
7515fn is_open_okx_algo(state: OKXAlgoOrderStatus) -> bool {
7516 matches!(
7517 state,
7518 OKXAlgoOrderStatus::Live
7519 | OKXAlgoOrderStatus::Pause
7520 | OKXAlgoOrderStatus::OrderPlaced
7521 | OKXAlgoOrderStatus::PartiallyEffective
7522 )
7523}
7524
7525fn spread_page_limit(limit: Option<u32>) -> Option<u32> {
7526 limit.map(|limit| limit.min(OKX_PAGE_SIZE as u32))
7527}
7528
7529fn parse_http_algo_order(
7530 order: &OKXOrderAlgo,
7531 account_id: AccountId,
7532 instrument: &InstrumentAny,
7533 ts_init: UnixNanos,
7534) -> anyhow::Result<OrderStatusReport> {
7535 let ord_id = if order.ord_id.is_empty() {
7536 match order.ord_id_list.as_slice() {
7537 [child_order_id] => child_order_id.clone(),
7538 _ => String::new(),
7539 }
7540 } else {
7541 order.ord_id.clone()
7542 };
7543 let ord_px = if order.ord_px.is_empty() {
7544 "-1".to_string()
7545 } else {
7546 order.ord_px.clone()
7547 };
7548
7549 let reduce_only = if order.reduce_only.is_empty() {
7550 "false".to_string()
7551 } else {
7552 order.reduce_only.clone()
7553 };
7554
7555 let msg = OKXAlgoOrderMsg {
7556 algo_id: order.algo_id.clone(),
7557 algo_cl_ord_id: order.algo_cl_ord_id.clone(),
7558 cl_ord_id: order.cl_ord_id.clone(),
7559 ord_id,
7560 ord_id_list: order.ord_id_list.clone(),
7561 inst_id: order.inst_id,
7562 inst_type: order.inst_type,
7563 ord_type: order.ord_type,
7564 state: order.state,
7565 side: order.side,
7566 pos_side: order.pos_side,
7567 sz: order.sz.clone(),
7568 trigger_px: order.trigger_px.clone(),
7569 trigger_px_type: order.trigger_px_type.unwrap_or(OKXTriggerType::None),
7570 sl_trigger_px: order.sl_trigger_px.clone(),
7571 sl_ord_px: order.sl_ord_px.clone(),
7572 sl_trigger_px_type: order.sl_trigger_px_type.unwrap_or(OKXTriggerType::None),
7573 tp_trigger_px: order.tp_trigger_px.clone(),
7574 tp_ord_px: order.tp_ord_px.clone(),
7575 tp_trigger_px_type: order.tp_trigger_px_type.unwrap_or(OKXTriggerType::None),
7576 ord_px,
7577 td_mode: order.td_mode,
7578 lever: order.lever.clone(),
7579 reduce_only,
7580 close_fraction: order.close_fraction.clone(),
7581 actual_px: order.actual_px.clone(),
7582 actual_sz: order.actual_sz.clone(),
7583 notional_usd: order.notional_usd.clone(),
7584 c_time: order.c_time,
7585 u_time: order.u_time,
7586 trigger_time: order.trigger_time.clone(),
7587 fail_code: String::new(),
7588 tag: order.tag.clone(),
7589 callback_ratio: order.callback_ratio.clone(),
7590 callback_spread: order.callback_spread.clone(),
7591 active_px: order.active_px.clone(),
7592 ccy: None,
7593 tgt_ccy: None,
7594 fee: None,
7595 fee_ccy: None,
7596 advance_ord_type: None,
7597 };
7598
7599 parse_algo_order_status_report(&msg, instrument, account_id, ts_init)
7600}
7601
7602fn reconciliation_history_page_limit(max_pages: Option<u32>) -> usize {
7603 max_pages.map_or(MAX_RECONCILIATION_PAGES, |pages| {
7604 (pages as usize).min(MAX_RECONCILIATION_PAGES)
7605 })
7606}