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, fmt::rfc2822::DateTimeParser};
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 },
61 enums::{
62 AccountType, AggregationSource, BarAggregation, BookAction, BookType, OrderSide,
63 OrderStatus, OrderType, PositionSide, RecordFlag, TimeInForce, TriggerType,
64 },
65 events::AccountState,
66 identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
67 instruments::{Instrument, InstrumentAny},
68 orderbook::OrderBook,
69 reports::{FillReport, OrderStatusReport, PositionStatusReport},
70 types::{Price, Quantity},
71};
72use nautilus_network::{
73 http::{HttpClient, HttpRedirectPolicy, Method, StatusCode, create_standard_nautilus_headers},
74 ratelimiter::quota::Quota,
75 retry::{RetryConfig, RetryError, RetryManager},
76};
77use rust_decimal::Decimal;
78use serde::{Deserialize, Serialize, de::DeserializeOwned};
79use thiserror::Error;
80use tokio_util::sync::CancellationToken;
81use ustr::Ustr;
82
83use super::{
84 error::OKXHttpError,
85 models::{
86 OKXAccount, OKXAccountConfiguration, OKXAmendAlgoOrderRequest, OKXAmendAlgoOrderResponse,
87 OKXAmendOrderRequest, OKXAttachAlgoOrdRequest, OKXCancelAlgoOrderRequest,
88 OKXCancelAlgoOrderResponse, OKXCancelAllSpreadOrdersRequest, OKXCancelOrderRequest,
89 OKXCancelOrderResponse, OKXCancelSpreadOrderRequest, OKXEventContractEvent,
90 OKXEventContractMarket, OKXEventContractSeries, OKXFeeRate, OKXFundingRateHistory,
91 OKXIndexTicker, OKXMarkPrice, OKXOptionSummary, OKXOrderAlgo, OKXOrderBookSnapshot,
92 OKXOrderHistory, OKXPlaceAlgoOrderRequest, OKXPlaceAlgoOrderResponse, OKXPlaceOrderRequest,
93 OKXPlaceOrderResponse, OKXPlaceSpreadOrderRequest, OKXPosition, OKXPositionHistory,
94 OKXPositionTier, OKXPriceLimit, OKXRpiOrderBookSnapshot, OKXServerTime, OKXSpread,
95 OKXSpreadOrder, OKXSpreadTrade, OKXTransactionDetail,
96 },
97 query::{
98 ActivateFeatureParams, GetAlgoOrderParams, GetAlgoOrderParamsBuilder, GetAlgoOrdersParams,
99 GetAlgoOrdersParamsBuilder, GetCandlesticksParams, GetCandlesticksParamsBuilder,
100 GetEventContractEventsParams, GetEventContractMarketsParams, GetEventContractSeriesParams,
101 GetFundingRateHistoryParams, GetIndexTickerParams, GetIndexTickerParamsBuilder,
102 GetInstrumentsParams, GetInstrumentsParamsBuilder, GetMarkPriceParams,
103 GetMarkPriceParamsBuilder, GetOptionSummaryParams, GetOrderBookParams,
104 GetOrderHistoryParams, GetOrderHistoryParamsBuilder, GetOrderListParams,
105 GetOrderListParamsBuilder, GetOrderParams, GetOrderParamsBuilder, GetPositionTiersParams,
106 GetPositionsHistoryParams, GetPositionsParams, GetPositionsParamsBuilder,
107 GetPriceLimitParams, GetPriceLimitParamsBuilder, GetRpiOrderBookParams,
108 GetSpreadOrderParams, GetSpreadOrderParamsBuilder, GetSpreadOrdersParams,
109 GetSpreadOrdersParamsBuilder, GetSpreadTradesParams, GetSpreadTradesParamsBuilder,
110 GetSpreadsParams, GetTradeFeeParams, GetTradesParams, GetTradesParamsBuilder,
111 GetTransactionDetailsParams, GetTransactionDetailsParamsBuilder, SetPositionModeParams,
112 SetPositionModeParamsBuilder,
113 },
114};
115use crate::{
116 common::{
117 consts::{
118 OKX_FIELD_SCODE, OKX_FIELD_SMSG, OKX_HTTP_URL, OKX_NAUTILUS_BROKER_ID,
119 OKX_POST_ONLY_CANCEL_REASON, OKX_POST_ONLY_CANCEL_SOURCE, OKX_SUPPORTED_ORDER_TYPES,
120 OKX_SUPPORTED_TIME_IN_FORCE, okx_reduce_only_wire_value,
121 spot_trade_quote_ccy_wire_value,
122 },
123 credential::Credential,
124 enums::{
125 OKXAlgoOrderStatus, OKXAlgoOrderType, OKXContractType, OKXEnvironment,
126 OKXInstrumentStatus, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionMode,
127 OKXPositionSide, OKXSide, OKXTargetCurrency, OKXTradeMode, OKXTriggerType,
128 conditional_order_to_algo_type,
129 },
130 models::OKXInstrument,
131 parse::{
132 extract_inst_family, is_okx_spread_symbol, is_order_status_report_more_advanced,
133 okx_instrument_type, okx_instrument_type_from_symbol, parse_account_state,
134 parse_base_quote_from_symbol, parse_candlestick, parse_fill_report, parse_funding_rate,
135 parse_index_price_update, parse_instrument_any, parse_mark_price_update,
136 parse_millisecond_timestamp, parse_order_status_report, parse_position_status_report,
137 parse_price, parse_quantity, parse_spot_margin_position_from_balance,
138 parse_spread_fill_report, parse_spread_instrument, parse_spread_order_status_report,
139 parse_trade_tick, prefer_rpi_response_fields,
140 },
141 },
142 http::models::{OKXCandlestick, OKXTrade},
143 websocket::{messages::OKXAlgoOrderMsg, parse::parse_algo_order_status_report},
144};
145
146const OKX_SUCCESS_CODE: &str = "0";
147const OKX_PARTIAL_SUCCESS_CODE: &str = "2";
148const RETRY_AFTER_HEADER: &str = "Retry-After";
149
150#[derive(Debug, Error)]
151#[error("Failed to parse instrument {symbol}: {source}")]
152pub(crate) struct OKXInstrumentDefinitionError {
153 symbol: String,
154 #[source]
155 source: anyhow::Error,
156}
157
158impl OKXInstrumentDefinitionError {
159 fn new(symbol: &str, source: anyhow::Error) -> Self {
160 Self {
161 symbol: symbol.to_string(),
162 source,
163 }
164 }
165}
166
167#[derive(Debug, Error)]
168#[error("Failed to fetch pending algo order reports: {source}")]
169pub(crate) struct OKXPendingAlgoOrderReportsError {
170 #[source]
171 source: anyhow::Error,
172}
173
174impl OKXPendingAlgoOrderReportsError {
175 fn new(source: anyhow::Error) -> Self {
176 Self { source }
177 }
178}
179
180fn spot_quote_priority(symbol: &str) -> u8 {
185 symbol.rsplit_once('-').map_or(4, |(_, quote)| match quote {
186 "USDT" => 0,
187 "USDC" => 1,
188 "USD" => 2,
189 _ => 3,
190 })
191}
192
193fn resolve_okx_error_code(response_body: &[u8], envelope_code: &str) -> String {
194 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(response_body)
195 && let Some(s_code) = payload
196 .get("data")
197 .and_then(serde_json::Value::as_array)
198 .and_then(|items| items.first())
199 .and_then(|item| item.get(OKX_FIELD_SCODE))
200 .and_then(serde_json::Value::as_str)
201 {
202 let s_code = s_code.trim();
203 if !s_code.is_empty() {
204 return s_code.to_string();
205 }
206 }
207
208 envelope_code.to_string()
209}
210
211fn resolve_okx_error_message(response_body: &[u8], top_level_msg: &str) -> String {
212 let message = top_level_msg.trim();
213 let is_generic_top_level = message.eq_ignore_ascii_case("All operations failed");
214 if !message.is_empty() && !is_generic_top_level {
215 return message.to_string();
216 }
217
218 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(response_body)
219 && let Some(first_item) = payload
220 .get("data")
221 .and_then(serde_json::Value::as_array)
222 .and_then(|items| items.first())
223 {
224 if let Some(s_msg) = first_item
225 .get(OKX_FIELD_SMSG)
226 .and_then(serde_json::Value::as_str)
227 {
228 let s_msg = s_msg.trim();
229 if !s_msg.is_empty() {
230 return s_msg.to_string();
231 }
232 }
233
234 if let Some(s_code) = first_item
235 .get(OKX_FIELD_SCODE)
236 .and_then(serde_json::Value::as_str)
237 {
238 let s_code = s_code.trim();
239 if !s_code.is_empty() {
240 return s_code.to_string();
241 }
242 }
243 }
244
245 String::new()
246}
247
248fn deserialize_okx_response<T: DeserializeOwned>(
249 response_body: &[u8],
250) -> Result<OKXResponse<T>, OKXHttpError> {
251 if response_body.is_empty() {
252 return Err(OKXHttpError::EmptyResponse);
253 }
254
255 let contains_legacy_rpi_name = [br#""elp""#.as_slice(), br#""elpMaker""#.as_slice()]
256 .iter()
257 .any(|name| {
258 response_body
259 .windows(name.len())
260 .any(|window| window == *name)
261 });
262
263 if !contains_legacy_rpi_name {
264 return serde_json::from_slice(response_body)
265 .map_err(|e| classify_response_decode_error(&e));
266 }
267
268 let mut value: serde_json::Value =
269 serde_json::from_slice(response_body).map_err(|e| classify_response_decode_error(&e))?;
270 prefer_rpi_response_fields(&mut value);
271 serde_json::from_value(value).map_err(|e| classify_response_decode_error(&e))
272}
273
274fn classify_response_decode_error(error: &serde_json::Error) -> OKXHttpError {
275 if error.is_data() {
276 OKXHttpError::ResponseDecoding(error.to_string())
277 } else {
278 OKXHttpError::MalformedResponse(error.to_string())
279 }
280}
281
282fn parse_retry_after(value: &str, now: Timestamp) -> Option<Duration> {
283 if let Ok(seconds) = value.parse::<u64>() {
284 return Some(Duration::from_secs(seconds));
285 }
286
287 let retry_at = DateTimeParser::new().parse_timestamp(value).ok()?;
288 let delay = retry_at.duration_since(now);
289 if delay.is_negative() {
290 Some(Duration::ZERO)
291 } else {
292 Some(delay.unsigned_abs())
293 }
294}
295
296fn retry_after(headers: &HashMap<String, String>, now: Timestamp) -> Option<Duration> {
297 let value = headers.get(RETRY_AFTER_HEADER)?;
298 let delay = parse_retry_after(value, now);
299 if delay.is_none() {
300 log::warn!("Invalid OKX response header {RETRY_AFTER_HEADER}={value:?}");
301 }
302 delay
303}
304
305#[cfg(test)]
306mod tests {
307 use anyhow::Context;
308 use nautilus_testkit::http::assert_http_redirect_rejected;
309 use rstest::rstest;
310 use rust_decimal::Decimal;
311 use serde::{Serialize, Serializer, ser::Error as _};
312
313 use super::{
314 Method, OKXEnvironment, OKXHttpError, OKXInstrumentDefinitionError, OKXRawHttpClient,
315 Timestamp, deserialize_okx_response, parse_retry_after, resolve_okx_error_code,
316 resolve_okx_error_message,
317 };
318 use crate::http::models::OKXFeeRate;
319
320 struct UnserializableParams;
321
322 impl Serialize for UnserializableParams {
323 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
324 where
325 S: Serializer,
326 {
327 Err(S::Error::custom("intentional serialization failure"))
328 }
329 }
330
331 #[tokio::test]
332 async fn test_authenticated_client_rejects_redirects() {
333 let client = OKXRawHttpClient::with_credentials(
334 "key".into(),
335 "secret".into(),
336 "pass".into(),
337 "http://localhost".into(),
338 3,
339 0,
340 1,
341 1,
342 OKXEnvironment::Demo,
343 None,
344 )
345 .unwrap()
346 .client;
347 assert_http_redirect_rejected(|url| async move {
348 client
349 .get(url, None, None, Some(3), None)
350 .await
351 .unwrap()
352 .status
353 .as_u16()
354 })
355 .await;
356 }
357
358 #[rstest]
359 fn test_instrument_definition_error_survives_anyhow_context_downcast() {
360 let result: anyhow::Result<()> = Err(OKXInstrumentDefinitionError::new(
361 "USDG-SGD",
362 anyhow::anyhow!("`tick_sz` is empty"),
363 )
364 .into());
365
366 let err = result.context("fetch instrument from API").unwrap_err();
367
368 assert!(err.downcast_ref::<OKXInstrumentDefinitionError>().is_some());
369 }
370
371 #[rstest]
372 fn test_resolve_okx_error_message_prefers_detailed_s_msg_over_generic_top_level() {
373 let body = br#"{
374 "code": "1",
375 "msg": "All operations failed",
376 "data": [
377 {
378 "sCode": "51046",
379 "sMsg": "Test detailed failure"
380 }
381 ]
382 }"#;
383
384 assert_eq!(
385 resolve_okx_error_message(body, "All operations failed"),
386 "Test detailed failure",
387 );
388 }
389
390 #[rstest]
391 fn test_resolve_okx_error_code_prefers_item_s_code_over_envelope() {
392 let body = br#"{
393 "code": "1",
394 "msg": "All operations failed",
395 "data": [
396 {
397 "sCode": "50013",
398 "sMsg": "System busy, please retry later"
399 }
400 ]
401 }"#;
402
403 assert_eq!(resolve_okx_error_code(body, "1"), "50013");
404 }
405
406 #[rstest]
407 fn test_rpi_response_fields_prefer_current_names_over_transition_aliases() {
408 let response = br#"{
409 "code": "0",
410 "msg": "",
411 "data": [{
412 "level": "VIP1",
413 "taker": "-0.0005",
414 "maker": "-0.0002",
415 "takerU": "-0.0005",
416 "makerU": "-0.0002",
417 "rpiMaker": "-0.00015",
418 "elpMaker": "-0.00016",
419 "instType": "SPOT",
420 "category": "1",
421 "ts": "1785406500000"
422 }]
423 }"#;
424
425 let parsed = deserialize_okx_response::<OKXFeeRate>(response).unwrap();
426
427 assert_eq!(parsed.data.len(), 1);
428 assert_eq!(parsed.data[0].rpi_maker, Some(Decimal::new(-15, 5)));
429 }
430
431 #[rstest]
432 fn test_parse_retry_after_supports_delay_seconds_and_http_date() {
433 let now: Timestamp = "1994-11-06T08:49:36Z".parse().unwrap();
434
435 assert_eq!(
436 parse_retry_after("5", now),
437 Some(std::time::Duration::from_secs(5))
438 );
439 assert_eq!(
440 parse_retry_after("Sun, 06 Nov 1994 08:51:36 GMT", now),
441 Some(std::time::Duration::from_secs(120))
442 );
443 assert_eq!(
444 parse_retry_after("Sun, 06 Nov 1994 08:47:36 GMT", now),
445 Some(std::time::Duration::ZERO)
446 );
447 assert_eq!(parse_retry_after("soon", now), None);
448 }
449
450 #[rstest]
451 fn test_empty_response_is_distinct_from_malformed_json() {
452 let empty = deserialize_okx_response::<serde_json::Value>(b"").unwrap_err();
453 let malformed = deserialize_okx_response::<serde_json::Value>(b"{").unwrap_err();
454
455 assert!(matches!(empty, OKXHttpError::EmptyResponse));
456 assert!(matches!(malformed, OKXHttpError::MalformedResponse(_)));
457 }
458
459 #[rstest]
460 #[tokio::test]
461 async fn test_request_serialization_failure_is_typed_before_transport() {
462 let client = OKXRawHttpClient::new(
463 Some("http://127.0.0.1:1".to_string()),
464 1,
465 0,
466 1,
467 1,
468 OKXEnvironment::Live,
469 None,
470 )
471 .unwrap();
472
473 let error = client
474 .send_request::<serde_json::Value, _>(
475 Method::GET,
476 "/api/v5/test",
477 Some(&UnserializableParams),
478 None,
479 false,
480 )
481 .await
482 .unwrap_err();
483
484 assert!(matches!(
485 error,
486 OKXHttpError::RequestSerialization(message)
487 if message.contains("intentional serialization failure")
488 ));
489 }
490
491 #[rstest]
492 #[case("BTC-USD")]
493 #[case("BTC-USD-241217")]
494 #[case("BTC-USD-241217-92000")]
495 fn test_option_summary_exp_time_rejects_short_symbol(#[case] symbol: &str) {
496 let result = super::OKXHttpClient::option_summary_exp_time(symbol);
497 assert!(result.is_err());
498 let err = result.unwrap_err().to_string();
499 assert!(
500 err.contains("Expected OKX option symbol with expiry"),
501 "unexpected error: {err}"
502 );
503 }
504
505 #[rstest]
506 fn test_option_summary_exp_time_extracts_expiry() {
507 let result =
508 super::OKXHttpClient::option_summary_exp_time("BTC-USD-241217-92000-C").unwrap();
509 assert_eq!(result, Some("241217".to_string()));
510 }
511}
512
513pub static OKX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
522 Quota::per_second(NonZeroU32::new(250).expect("non-zero")).expect("valid constant")
523});
524
525const OKX_GLOBAL_RATE_KEY: &str = "okx:global";
526
527const OKX_PAGE_SIZE: usize = 100;
529
530const MAX_RECONCILIATION_PAGES: usize = 50;
532
533#[derive(Clone, Copy, Debug)]
534pub(crate) struct ReportInstrumentScope<'a> {
535 pub instrument_types: &'a [OKXInstrumentType],
536 pub load_spreads: bool,
537}
538
539#[derive(Debug)]
540pub(crate) struct ReportSweep<T> {
541 pub reports: Vec<T>,
542 pub complete: bool,
543}
544
545#[derive(Clone, Copy)]
546pub(crate) enum FillHistory {
547 Recent,
548 Extended,
549}
550
551#[derive(Debug)]
552pub(crate) struct AlgoOrderReportSweep {
553 pub reports: Vec<OrderStatusReport>,
554 pub complete: bool,
555 pub ambiguous_triggered_child_ids: AHashSet<VenueOrderId>,
556}
557
558struct PageSweep<T> {
559 items: Vec<T>,
560 complete: bool,
561}
562
563impl<T> PageSweep<T> {
564 fn from_pages(items: Vec<T>, exhausted: bool) -> Self {
565 Self {
566 complete: !exhausted || items.is_empty(),
567 items,
568 }
569 }
570}
571
572enum InstrumentResolution {
573 Found(Box<InstrumentAny>),
574 Skip,
575 Incomplete,
576}
577
578#[derive(Debug, Serialize, Deserialize)]
580pub struct OKXResponse<T> {
581 pub code: String,
583 pub msg: String,
585 pub data: Vec<T>,
587}
588
589pub struct OKXRawHttpClient {
595 clock: &'static AtomicTime,
596 base_url: String,
597 client: HttpClient,
598 credential: Option<Credential>,
599 retry_manager: RetryManager<OKXHttpError>,
600 cancellation_token: CancellationToken,
601 environment: OKXEnvironment,
602}
603
604impl Default for OKXRawHttpClient {
605 fn default() -> Self {
606 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
607 .expect("Failed to create default OKXRawHttpClient")
608 }
609}
610
611impl Debug for OKXRawHttpClient {
612 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613 let credential = self.credential.as_ref().map(|_| REDACTED);
614 f.debug_struct(stringify!(OKXRawHttpClient))
615 .field("base_url", &self.base_url)
616 .field("credential", &credential)
617 .finish_non_exhaustive()
618 }
619}
620
621impl OKXRawHttpClient {
622 fn rate_limiter_quotas() -> Vec<(String, Quota)> {
623 vec![
624 (OKX_GLOBAL_RATE_KEY.to_string(), *OKX_REST_QUOTA),
625 (
626 "okx:/api/v5/account/config".to_string(),
627 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
628 ),
629 (
630 "okx:/api/v5/account/set-position-mode".to_string(),
631 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
632 ),
633 (
634 "okx:/api/v5/account/activate-feature".to_string(),
635 Quota::per_second(NonZeroU32::new(3).expect("non-zero")).expect("valid constant"),
636 ),
637 (
638 "okx:/api/v5/account/balance".to_string(),
639 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
640 ),
641 (
642 "okx:/api/v5/account/trade-fee".to_string(),
643 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
644 ),
645 (
646 "okx:/api/v5/account/instruments".to_string(),
647 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
648 ),
649 (
650 "okx:/api/v5/account/positions".to_string(),
651 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
652 ),
653 (
654 "okx:/api/v5/account/positions-history".to_string(),
655 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
656 ),
657 (
658 "okx:/api/v5/public/instruments".to_string(),
659 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
660 ),
661 (
662 "okx:/api/v5/public/position-tiers".to_string(),
663 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
664 ),
665 (
666 "okx:/api/v5/public/event-contract/series".to_string(),
667 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
668 ),
669 (
670 "okx:/api/v5/public/event-contract/events".to_string(),
671 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
672 ),
673 (
674 "okx:/api/v5/public/event-contract/markets".to_string(),
675 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
676 ),
677 (
678 "okx:/api/v5/public/opt-summary".to_string(),
679 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
680 ),
681 (
682 "okx:/api/v5/public/time".to_string(),
683 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
684 ),
685 (
686 "okx:/api/v5/public/mark-price".to_string(),
687 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
688 ),
689 (
690 "okx:/api/v5/public/price-limit".to_string(),
691 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
692 ),
693 (
694 "okx:/api/v5/sprd/spreads".to_string(),
695 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
696 ),
697 (
698 "okx:/api/v5/sprd/order".to_string(),
699 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
700 ),
701 (
702 "okx:/api/v5/sprd/cancel-order".to_string(),
703 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
704 ),
705 (
706 "okx:/api/v5/sprd/mass-cancel".to_string(),
707 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
708 ),
709 (
710 "okx:/api/v5/sprd/orders-pending".to_string(),
711 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
712 ),
713 (
714 "okx:/api/v5/sprd/orders-history".to_string(),
715 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
716 ),
717 (
718 "okx:/api/v5/sprd/trades".to_string(),
719 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
720 ),
721 (
722 "okx:/api/v5/market/index-tickers".to_string(),
723 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
724 ),
725 (
726 "okx:/api/v5/market/books".to_string(),
727 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
728 ),
729 (
730 "okx:/api/v5/market/books-rpi".to_string(),
731 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
732 ),
733 (
734 "okx:/api/v5/market/candles".to_string(),
735 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
736 ),
737 (
738 "okx:/api/v5/market/history-candles".to_string(),
739 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
740 ),
741 (
742 "okx:/api/v5/market/history-trades".to_string(),
743 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
744 ),
745 (
746 "okx:/api/v5/public/funding-rate-history".to_string(),
747 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
748 ),
749 (
750 "okx:/api/v5/trade/order".to_string(),
751 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
752 ),
753 (
754 "okx:/api/v5/trade/batch-orders".to_string(),
755 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
756 ),
757 (
758 "okx:/api/v5/trade/amend-order".to_string(),
759 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
760 ),
761 (
762 "okx:/api/v5/trade/amend-batch-orders".to_string(),
763 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
764 ),
765 (
766 "okx:/api/v5/trade/cancel-batch-orders".to_string(),
767 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
768 ),
769 (
770 "okx:/api/v5/trade/orders-pending".to_string(),
771 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
772 ),
773 (
774 "okx:/api/v5/trade/orders-history".to_string(),
775 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
776 ),
777 (
778 "okx:/api/v5/trade/fills".to_string(),
779 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
780 ),
781 (
782 "okx:/api/v5/trade/fills-history".to_string(),
783 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
784 ),
785 (
786 "okx:/api/v5/trade/order-algo".to_string(),
787 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
788 ),
789 (
790 "okx:/api/v5/trade/cancel-algos".to_string(),
791 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
792 ),
793 (
794 "okx:/api/v5/trade/cancel-advance-algos".to_string(),
795 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
796 ),
797 (
798 "okx:/api/v5/trade/amend-algos".to_string(),
799 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
800 ),
801 (
802 "okx:/api/v5/trade/orders-algo-pending".to_string(),
803 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
804 ),
805 (
806 "okx:/api/v5/trade/orders-algo-history".to_string(),
807 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
808 ),
809 ]
810 }
811
812 fn rate_limit_keys(endpoint: &str) -> Vec<Ustr> {
813 let normalized = endpoint.split('?').next().unwrap_or(endpoint);
814 let route = format!("okx:{normalized}");
815
816 vec![Ustr::from(OKX_GLOBAL_RATE_KEY), Ustr::from(route.as_str())]
817 }
818
819 pub fn cancel_all_requests(&self) {
821 self.cancellation_token.cancel();
822 }
823
824 pub fn cancellation_token(&self) -> &CancellationToken {
826 &self.cancellation_token
827 }
828
829 pub fn new(
839 base_url: Option<String>,
840 timeout_secs: u64,
841 max_retries: u32,
842 retry_delay_ms: u64,
843 retry_delay_max_ms: u64,
844 environment: OKXEnvironment,
845 proxy_url: Option<String>,
846 ) -> Result<Self, OKXHttpError> {
847 let retry_config = RetryConfig {
848 max_retries,
849 initial_delay_ms: retry_delay_ms,
850 max_delay_ms: retry_delay_max_ms,
851 backoff_factor: 2.0,
852 jitter_ms: 1000,
853 operation_timeout_ms: Some(60_000),
854 immediate_first: false,
855 max_elapsed_ms: Some(180_000),
856 };
857
858 let retry_manager = RetryManager::new(retry_config);
859
860 Ok(Self {
861 clock: get_atomic_clock_realtime(),
862 base_url: base_url.unwrap_or(OKX_HTTP_URL.to_string()),
863 client: HttpClient::builder()
864 .headers(Self::default_headers(environment))
865 .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
866 .keyed_quotas(Self::rate_limiter_quotas())
867 .default_quota(*OKX_REST_QUOTA)
868 .timeout_secs(timeout_secs)
869 .maybe_proxy_url(proxy_url)
870 .build()
871 .map_err(|e| {
872 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
873 })?,
874 credential: None,
875 retry_manager,
876 cancellation_token: CancellationToken::new(),
877 environment,
878 })
879 }
880
881 #[expect(clippy::too_many_arguments)]
888 pub fn with_credentials(
889 api_key: String,
890 api_secret: String,
891 api_passphrase: String,
892 base_url: String,
893 timeout_secs: u64,
894 max_retries: u32,
895 retry_delay_ms: u64,
896 retry_delay_max_ms: u64,
897 environment: OKXEnvironment,
898 proxy_url: Option<String>,
899 ) -> Result<Self, OKXHttpError> {
900 let retry_config = RetryConfig {
901 max_retries,
902 initial_delay_ms: retry_delay_ms,
903 max_delay_ms: retry_delay_max_ms,
904 backoff_factor: 2.0,
905 jitter_ms: 1000,
906 operation_timeout_ms: Some(60_000),
907 immediate_first: false,
908 max_elapsed_ms: Some(180_000),
909 };
910
911 let retry_manager = RetryManager::new(retry_config);
912
913 Ok(Self {
914 clock: get_atomic_clock_realtime(),
915 base_url,
916 client: HttpClient::builder()
917 .redirect_policy(HttpRedirectPolicy::Reject)
918 .headers(Self::default_headers(environment))
919 .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
920 .keyed_quotas(Self::rate_limiter_quotas())
921 .default_quota(*OKX_REST_QUOTA)
922 .timeout_secs(timeout_secs)
923 .maybe_proxy_url(proxy_url)
924 .build()
925 .map_err(|e| {
926 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
927 })?,
928 credential: Some(Credential::new(api_key, api_secret, api_passphrase)),
929 retry_manager,
930 cancellation_token: CancellationToken::new(),
931 environment,
932 })
933 }
934
935 fn default_headers(environment: OKXEnvironment) -> HashMap<String, String> {
937 let mut headers: HashMap<String, String> =
938 create_standard_nautilus_headers().into_iter().collect();
939
940 if environment == OKXEnvironment::Demo {
941 headers.insert("x-simulated-trading".to_string(), "1".to_string());
942 }
943
944 headers
945 }
946
947 fn sign_request(
954 &self,
955 method: &Method,
956 path: &str,
957 body: Option<&[u8]>,
958 now: Timestamp,
959 ) -> Result<HashMap<String, String>, OKXHttpError> {
960 let credential = match self.credential.as_ref() {
961 Some(c) => c,
962 None => return Err(OKXHttpError::MissingCredentials),
963 };
964
965 let api_key = credential.api_key().to_string();
966 let api_passphrase = credential.api_passphrase().to_string();
967
968 let timestamp = format!("{now:.3}");
970 let signature = credential.sign_bytes(×tamp, method.as_str(), path, body);
971
972 let mut headers = HashMap::new();
973 headers.insert("OK-ACCESS-KEY".to_string(), api_key);
974 headers.insert("OK-ACCESS-PASSPHRASE".to_string(), api_passphrase);
975 headers.insert("OK-ACCESS-TIMESTAMP".to_string(), timestamp);
976 headers.insert("OK-ACCESS-SIGN".to_string(), signature);
977
978 Ok(headers)
979 }
980
981 async fn send_request<T: DeserializeOwned, P: Serialize>(
997 &self,
998 method: Method,
999 path: &str,
1000 params: Option<&P>,
1001 body: Option<Vec<u8>>,
1002 authenticate: bool,
1003 ) -> Result<Vec<T>, OKXHttpError> {
1004 let query_string = params
1005 .map(serde_urlencoded::to_string)
1006 .transpose()
1007 .map_err(|e| {
1008 OKXHttpError::RequestSerialization(format!("Failed to serialize params: {e}"))
1009 })?
1010 .unwrap_or_default();
1011 let full_path = if query_string.is_empty() {
1012 path.to_string()
1013 } else {
1014 format!("{path}?{query_string}")
1015 };
1016 let url = format!("{}{full_path}", self.base_url);
1017 let accepts_partial_success = matches!(
1018 path,
1019 "/api/v5/trade/batch-orders"
1020 | "/api/v5/trade/amend-batch-orders"
1021 | "/api/v5/trade/cancel-batch-orders"
1022 );
1023
1024 let rate_keys: Vec<String> = Self::rate_limit_keys(path)
1026 .into_iter()
1027 .map(|k| k.to_string())
1028 .collect();
1029
1030 let operation = || {
1031 let url = url.clone();
1032 let method = method.clone();
1033 let body = body.clone();
1034 let rate_keys = rate_keys.clone();
1035 let full_path = full_path.clone();
1036
1037 async move {
1038 let mut headers = if authenticate {
1039 let now = self.clock.get_time_ns().to_datetime_utc();
1040 self.sign_request(&method, &full_path, body.as_deref(), now)?
1041 } else {
1042 HashMap::new()
1043 };
1044
1045 if body.is_some() {
1047 headers.insert("Content-Type".to_string(), "application/json".to_string());
1048 }
1049
1050 let resp = self
1051 .client
1052 .request_with_params::<()>(
1053 method.clone(),
1054 url,
1055 None,
1056 Some(headers),
1057 body,
1058 None,
1059 Some(rate_keys),
1060 )
1061 .await?;
1062
1063 log::trace!("Response: {resp:?}");
1064 let now = self.clock.get_time_ns().to_datetime_utc();
1065 let retry_after = retry_after(&resp.headers, now);
1066
1067 if resp.status.is_success() {
1068 let okx_response: OKXResponse<T> = deserialize_okx_response(&resp.body)
1069 .map_err(|e| {
1070 log::warn!("Failed to deserialize OKX response: {e}");
1071 e
1072 })?;
1073
1074 if okx_response.code != OKX_SUCCESS_CODE
1075 && !(accepts_partial_success
1076 && okx_response.code == OKX_PARTIAL_SUCCESS_CODE)
1077 {
1078 let error_code = resolve_okx_error_code(&resp.body, &okx_response.code);
1079 let message = resolve_okx_error_message(&resp.body, &okx_response.msg);
1080 return Err(OKXHttpError::from_venue_response(
1081 error_code,
1082 message,
1083 retry_after,
1084 ));
1085 }
1086
1087 Ok(okx_response.data)
1088 } else {
1089 let status = StatusCode::from_u16(resp.status.as_u16())
1090 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1091 let error_body = String::from_utf8_lossy(&resp.body);
1092 if status == StatusCode::NOT_FOUND {
1093 log::debug!("HTTP 404 with body: {error_body}");
1094 } else {
1095 log::warn!(
1096 "HTTP error {} with body: {error_body}",
1097 resp.status.as_str()
1098 );
1099 }
1100
1101 if let Ok(parsed_error) = deserialize_okx_response::<T>(&resp.body) {
1102 let error_code = resolve_okx_error_code(&resp.body, &parsed_error.code);
1103 let message = resolve_okx_error_message(&resp.body, &parsed_error.msg);
1104 return Err(OKXHttpError::from_venue_response(
1105 error_code,
1106 message,
1107 retry_after,
1108 ));
1109 }
1110
1111 if status.as_u16() >= 500 || status == StatusCode::TOO_MANY_REQUESTS {
1112 Err(OKXHttpError::RetryableStatus {
1113 status,
1114 body: error_body.to_string(),
1115 retry_after,
1116 })
1117 } else {
1118 Err(OKXHttpError::UnexpectedStatus {
1119 status,
1120 body: error_body.to_string(),
1121 })
1122 }
1123 }
1124 }
1125 };
1126
1127 let is_order_submit = method == Method::POST
1142 && matches!(
1143 path,
1144 "/api/v5/trade/order"
1145 | "/api/v5/trade/batch-orders"
1146 | "/api/v5/trade/order-algo"
1147 | "/api/v5/sprd/order"
1148 );
1149 let should_retry = |error: &OKXHttpError| !is_order_submit && error.is_retryable();
1150
1151 let create_error = |error: RetryError| -> OKXHttpError {
1152 match error {
1153 RetryError::Canceled => {
1154 OKXHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
1155 }
1156 RetryError::OperationTimeout { timeout_ms } => {
1157 OKXHttpError::OperationTimeout { timeout_ms }
1158 }
1159 RetryError::InvalidConfiguration { message } => {
1160 OKXHttpError::ValidationError(message)
1161 }
1162 error @ RetryError::ElapsedBudgetExceeded { .. } => {
1163 OKXHttpError::RetryBudgetExceeded(error.to_string())
1164 }
1165 }
1166 };
1167
1168 let result = self
1169 .retry_manager
1170 .invocation(path, operation, should_retry, create_error)
1171 .retry_delay(&OKXHttpError::retry_after)
1172 .cancellation_token(&self.cancellation_token)
1173 .execute()
1174 .await;
1175
1176 if let Err(ref e) = result
1177 && e.is_retryable()
1178 {
1179 log::error!("Request exhausted retries: path={path}, error={e}");
1180 }
1181
1182 result
1183 }
1184
1185 pub async fn set_position_mode(
1196 &self,
1197 params: SetPositionModeParams,
1198 ) -> Result<Vec<serde_json::Value>, OKXHttpError> {
1199 let path = "/api/v5/account/set-position-mode";
1200 let body = serde_json::to_vec(¶ms)?;
1201 self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
1202 .await
1203 }
1204
1205 pub async fn activate_feature(
1220 &self,
1221 params: ActivateFeatureParams,
1222 ) -> Result<Vec<serde_json::Value>, OKXHttpError> {
1223 let path = "/api/v5/account/activate-feature";
1224 let body = serde_json::to_vec(¶ms)?;
1225 self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
1226 .await
1227 }
1228
1229 pub async fn get_position_tiers(
1240 &self,
1241 params: GetPositionTiersParams,
1242 ) -> Result<Vec<OKXPositionTier>, OKXHttpError> {
1243 self.send_request(
1244 Method::GET,
1245 "/api/v5/public/position-tiers",
1246 Some(¶ms),
1247 None,
1248 false,
1249 )
1250 .await
1251 }
1252
1253 pub async fn get_instruments(
1264 &self,
1265 params: GetInstrumentsParams,
1266 ) -> Result<Vec<OKXInstrument>, OKXHttpError> {
1267 self.send_request(
1268 Method::GET,
1269 "/api/v5/public/instruments",
1270 Some(¶ms),
1271 None,
1272 false,
1273 )
1274 .await
1275 }
1276
1277 pub async fn get_account_instruments(
1288 &self,
1289 params: GetInstrumentsParams,
1290 ) -> Result<Vec<OKXInstrument>, OKXHttpError> {
1291 self.send_request(
1292 Method::GET,
1293 "/api/v5/account/instruments",
1294 Some(¶ms),
1295 None,
1296 true,
1297 )
1298 .await
1299 }
1300
1301 pub async fn get_spreads(
1312 &self,
1313 params: GetSpreadsParams,
1314 ) -> Result<Vec<OKXSpread>, OKXHttpError> {
1315 self.send_request(
1316 Method::GET,
1317 "/api/v5/sprd/spreads",
1318 Some(¶ms),
1319 None,
1320 false,
1321 )
1322 .await
1323 }
1324
1325 pub async fn place_spread_order(
1335 &self,
1336 request: OKXPlaceSpreadOrderRequest,
1337 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
1338 let body = serde_json::to_vec(&request)?;
1339
1340 self.send_request(
1341 Method::POST,
1342 "/api/v5/sprd/order",
1343 None::<&()>,
1344 Some(body),
1345 true,
1346 )
1347 .await
1348 }
1349
1350 pub async fn cancel_spread_order(
1360 &self,
1361 request: OKXCancelSpreadOrderRequest,
1362 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
1363 let body = serde_json::to_vec(&request)?;
1364
1365 self.send_request(
1366 Method::POST,
1367 "/api/v5/sprd/cancel-order",
1368 None::<&()>,
1369 Some(body),
1370 true,
1371 )
1372 .await
1373 }
1374
1375 pub async fn cancel_all_spread_orders(
1385 &self,
1386 request: OKXCancelAllSpreadOrdersRequest,
1387 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
1388 let body = serde_json::to_vec(&request)?;
1389
1390 self.send_request(
1391 Method::POST,
1392 "/api/v5/sprd/mass-cancel",
1393 None::<&()>,
1394 Some(body),
1395 true,
1396 )
1397 .await
1398 }
1399
1400 pub async fn get_spread_order(
1410 &self,
1411 params: GetSpreadOrderParams,
1412 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1413 self.send_request(Method::GET, "/api/v5/sprd/order", Some(¶ms), None, true)
1414 .await
1415 }
1416
1417 pub async fn get_spread_orders_pending(
1427 &self,
1428 params: GetSpreadOrdersParams,
1429 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1430 self.send_request(
1431 Method::GET,
1432 "/api/v5/sprd/orders-pending",
1433 Some(¶ms),
1434 None,
1435 true,
1436 )
1437 .await
1438 }
1439
1440 pub async fn get_spread_orders_history(
1450 &self,
1451 params: GetSpreadOrdersParams,
1452 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1453 self.send_request(
1454 Method::GET,
1455 "/api/v5/sprd/orders-history",
1456 Some(¶ms),
1457 None,
1458 true,
1459 )
1460 .await
1461 }
1462
1463 pub async fn get_spread_trades(
1473 &self,
1474 params: GetSpreadTradesParams,
1475 ) -> Result<Vec<OKXSpreadTrade>, OKXHttpError> {
1476 self.send_request(
1477 Method::GET,
1478 "/api/v5/sprd/trades",
1479 Some(¶ms),
1480 None,
1481 true,
1482 )
1483 .await
1484 }
1485
1486 pub async fn get_event_contract_series(
1496 &self,
1497 params: GetEventContractSeriesParams,
1498 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
1499 self.send_request(
1500 Method::GET,
1501 "/api/v5/public/event-contract/series",
1502 Some(¶ms),
1503 None,
1504 false,
1505 )
1506 .await
1507 }
1508
1509 pub async fn get_event_contract_events(
1519 &self,
1520 params: GetEventContractEventsParams,
1521 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
1522 self.send_request(
1523 Method::GET,
1524 "/api/v5/public/event-contract/events",
1525 Some(¶ms),
1526 None,
1527 false,
1528 )
1529 .await
1530 }
1531
1532 pub async fn get_event_contract_markets(
1542 &self,
1543 params: GetEventContractMarketsParams,
1544 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
1545 self.send_request(
1546 Method::GET,
1547 "/api/v5/public/event-contract/markets",
1548 Some(¶ms),
1549 None,
1550 false,
1551 )
1552 .await
1553 }
1554
1555 pub async fn get_option_summary(
1565 &self,
1566 params: GetOptionSummaryParams,
1567 ) -> Result<Vec<OKXOptionSummary>, OKXHttpError> {
1568 self.send_request(
1569 Method::GET,
1570 "/api/v5/public/opt-summary",
1571 Some(¶ms),
1572 None,
1573 false,
1574 )
1575 .await
1576 }
1577
1578 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
1592 let response: Vec<OKXServerTime> = self
1593 .send_request::<_, ()>(Method::GET, "/api/v5/public/time", None, None, false)
1594 .await?;
1595 response
1596 .first()
1597 .map(|t| t.ts)
1598 .ok_or(OKXHttpError::EmptyResponse)
1599 }
1600
1601 pub async fn get_mark_price(
1615 &self,
1616 params: GetMarkPriceParams,
1617 ) -> Result<Vec<OKXMarkPrice>, OKXHttpError> {
1618 self.send_request(
1619 Method::GET,
1620 "/api/v5/public/mark-price",
1621 Some(¶ms),
1622 None,
1623 false,
1624 )
1625 .await
1626 }
1627
1628 pub async fn get_price_limit(
1639 &self,
1640 params: GetPriceLimitParams,
1641 ) -> Result<Vec<OKXPriceLimit>, OKXHttpError> {
1642 self.send_request(
1643 Method::GET,
1644 "/api/v5/public/price-limit",
1645 Some(¶ms),
1646 None,
1647 false,
1648 )
1649 .await
1650 }
1651
1652 pub async fn get_index_tickers(
1662 &self,
1663 params: GetIndexTickerParams,
1664 ) -> Result<Vec<OKXIndexTicker>, OKXHttpError> {
1665 self.send_request(
1666 Method::GET,
1667 "/api/v5/market/index-tickers",
1668 Some(¶ms),
1669 None,
1670 false,
1671 )
1672 .await
1673 }
1674
1675 pub async fn get_history_trades(
1685 &self,
1686 params: GetTradesParams,
1687 ) -> Result<Vec<OKXTrade>, OKXHttpError> {
1688 self.send_request(
1689 Method::GET,
1690 "/api/v5/market/history-trades",
1691 Some(¶ms),
1692 None,
1693 false,
1694 )
1695 .await
1696 }
1697
1698 pub async fn get_order_book(
1708 &self,
1709 params: GetOrderBookParams,
1710 ) -> Result<Vec<OKXOrderBookSnapshot>, OKXHttpError> {
1711 self.send_request(
1712 Method::GET,
1713 "/api/v5/market/books",
1714 Some(¶ms),
1715 None,
1716 false,
1717 )
1718 .await
1719 }
1720
1721 pub async fn get_rpi_order_book(
1731 &self,
1732 params: GetRpiOrderBookParams,
1733 ) -> Result<Vec<OKXRpiOrderBookSnapshot>, OKXHttpError> {
1734 self.send_request(
1735 Method::GET,
1736 "/api/v5/market/books-rpi",
1737 Some(¶ms),
1738 None,
1739 false,
1740 )
1741 .await
1742 }
1743
1744 pub async fn get_funding_rate_history(
1754 &self,
1755 params: GetFundingRateHistoryParams,
1756 ) -> Result<Vec<OKXFundingRateHistory>, OKXHttpError> {
1757 self.send_request(
1758 Method::GET,
1759 "/api/v5/public/funding-rate-history",
1760 Some(¶ms),
1761 None,
1762 false,
1763 )
1764 .await
1765 }
1766
1767 pub async fn get_candles(
1777 &self,
1778 params: GetCandlesticksParams,
1779 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1780 self.send_request(
1781 Method::GET,
1782 "/api/v5/market/candles",
1783 Some(¶ms),
1784 None,
1785 false,
1786 )
1787 .await
1788 }
1789
1790 pub async fn get_history_candles(
1800 &self,
1801 params: GetCandlesticksParams,
1802 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1803 self.send_request(
1804 Method::GET,
1805 "/api/v5/market/history-candles",
1806 Some(¶ms),
1807 None,
1808 false,
1809 )
1810 .await
1811 }
1812
1813 pub async fn get_account_configuration(
1827 &self,
1828 ) -> Result<Vec<OKXAccountConfiguration>, OKXHttpError> {
1829 let path = "/api/v5/account/config";
1830 self.send_request::<_, ()>(Method::GET, path, None, None, true)
1831 .await
1832 }
1833
1834 pub async fn get_balance(&self) -> Result<Vec<OKXAccount>, OKXHttpError> {
1845 let path = "/api/v5/account/balance";
1846 self.send_request::<_, ()>(Method::GET, path, None, None, true)
1847 .await
1848 }
1849
1850 pub async fn get_trade_fee(
1862 &self,
1863 params: GetTradeFeeParams,
1864 ) -> Result<Vec<OKXFeeRate>, OKXHttpError> {
1865 self.send_request(
1866 Method::GET,
1867 "/api/v5/account/trade-fee",
1868 Some(¶ms),
1869 None,
1870 true,
1871 )
1872 .await
1873 }
1874
1875 pub async fn get_order(
1885 &self,
1886 params: GetOrderParams,
1887 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1888 self.send_request(
1889 Method::GET,
1890 "/api/v5/trade/order",
1891 Some(¶ms),
1892 None,
1893 true,
1894 )
1895 .await
1896 }
1897
1898 pub async fn get_algo_order(
1908 &self,
1909 params: GetAlgoOrderParams,
1910 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1911 self.send_request(
1912 Method::GET,
1913 "/api/v5/trade/order-algo",
1914 Some(¶ms),
1915 None,
1916 true,
1917 )
1918 .await
1919 }
1920
1921 pub async fn get_orders_pending(
1931 &self,
1932 params: GetOrderListParams,
1933 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1934 self.send_request(
1935 Method::GET,
1936 "/api/v5/trade/orders-pending",
1937 Some(¶ms),
1938 None,
1939 true,
1940 )
1941 .await
1942 }
1943
1944 pub async fn get_orders_history(
1954 &self,
1955 params: GetOrderHistoryParams,
1956 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1957 self.send_request(
1958 Method::GET,
1959 "/api/v5/trade/orders-history",
1960 Some(¶ms),
1961 None,
1962 true,
1963 )
1964 .await
1965 }
1966
1967 pub async fn get_order_algo_pending(
1973 &self,
1974 params: GetAlgoOrdersParams,
1975 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1976 self.send_request(
1977 Method::GET,
1978 "/api/v5/trade/orders-algo-pending",
1979 Some(¶ms),
1980 None,
1981 true,
1982 )
1983 .await
1984 }
1985
1986 pub async fn get_order_algo_history(
1992 &self,
1993 params: GetAlgoOrdersParams,
1994 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1995 self.send_request(
1996 Method::GET,
1997 "/api/v5/trade/orders-algo-history",
1998 Some(¶ms),
1999 None,
2000 true,
2001 )
2002 .await
2003 }
2004
2005 pub async fn get_fills(
2015 &self,
2016 params: GetTransactionDetailsParams,
2017 ) -> Result<Vec<OKXTransactionDetail>, OKXHttpError> {
2018 self.send_request(
2019 Method::GET,
2020 "/api/v5/trade/fills",
2021 Some(¶ms),
2022 None,
2023 true,
2024 )
2025 .await
2026 }
2027
2028 pub async fn get_fills_history(
2038 &self,
2039 params: GetTransactionDetailsParams,
2040 ) -> Result<Vec<OKXTransactionDetail>, OKXHttpError> {
2041 self.send_request(
2042 Method::GET,
2043 "/api/v5/trade/fills-history",
2044 Some(¶ms),
2045 None,
2046 true,
2047 )
2048 .await
2049 }
2050
2051 pub async fn get_positions(
2063 &self,
2064 params: GetPositionsParams,
2065 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
2066 self.send_request(
2067 Method::GET,
2068 "/api/v5/account/positions",
2069 Some(¶ms),
2070 None,
2071 true,
2072 )
2073 .await
2074 }
2075
2076 pub async fn get_positions_history(
2086 &self,
2087 params: GetPositionsHistoryParams,
2088 ) -> Result<Vec<OKXPositionHistory>, OKXHttpError> {
2089 self.send_request(
2090 Method::GET,
2091 "/api/v5/account/positions-history",
2092 Some(¶ms),
2093 None,
2094 true,
2095 )
2096 .await
2097 }
2098}
2099
2100#[derive(Debug)]
2105#[cfg_attr(
2106 feature = "python",
2107 pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
2108)]
2109#[cfg_attr(
2110 feature = "python",
2111 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
2112)]
2113pub struct OKXHttpClient {
2114 pub(crate) inner: Arc<OKXRawHttpClient>,
2115 pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
2116 trade_quote_ccy_lists: Arc<AtomicMap<Ustr, Vec<Ustr>>>,
2117 spot_trade_quote_ccy: Arc<Mutex<Option<Ustr>>>,
2118 cache_initialized: AtomicBool,
2119}
2120
2121impl Clone for OKXHttpClient {
2122 fn clone(&self) -> Self {
2123 let cache_initialized = AtomicBool::new(false);
2124
2125 let is_initialized = self.cache_initialized.load(Ordering::Acquire);
2126 if is_initialized {
2127 cache_initialized.store(true, Ordering::Release);
2128 }
2129
2130 Self {
2131 inner: self.inner.clone(),
2132 instruments_cache: self.instruments_cache.clone(),
2133 trade_quote_ccy_lists: self.trade_quote_ccy_lists.clone(),
2134 spot_trade_quote_ccy: self.spot_trade_quote_ccy.clone(),
2135 cache_initialized,
2136 }
2137 }
2138}
2139
2140impl Default for OKXHttpClient {
2141 fn default() -> Self {
2142 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
2143 .expect("Failed to create default OKXHttpClient")
2144 }
2145}
2146
2147impl OKXHttpClient {
2148 pub fn new(
2158 base_url: Option<String>,
2159 timeout_secs: u64,
2160 max_retries: u32,
2161 retry_delay_ms: u64,
2162 retry_delay_max_ms: u64,
2163 environment: OKXEnvironment,
2164 proxy_url: Option<String>,
2165 ) -> anyhow::Result<Self> {
2166 Ok(Self {
2167 inner: Arc::new(OKXRawHttpClient::new(
2168 base_url,
2169 timeout_secs,
2170 max_retries,
2171 retry_delay_ms,
2172 retry_delay_max_ms,
2173 environment,
2174 proxy_url,
2175 )?),
2176 instruments_cache: Arc::new(AtomicMap::new()),
2177 trade_quote_ccy_lists: Arc::new(AtomicMap::new()),
2178 spot_trade_quote_ccy: Arc::new(Mutex::new(None)),
2179 cache_initialized: AtomicBool::new(false),
2180 })
2181 }
2182
2183 fn generate_ts_init(&self) -> UnixNanos {
2185 self.inner.clock.get_time_ns()
2186 }
2187
2188 pub fn from_env() -> anyhow::Result<Self> {
2195 Self::with_credentials(
2196 None,
2197 None,
2198 None,
2199 None,
2200 60,
2201 3,
2202 1000,
2203 10_000,
2204 OKXEnvironment::Live,
2205 None,
2206 )
2207 }
2208
2209 #[expect(clippy::too_many_arguments)]
2216 pub fn with_credentials(
2217 api_key: Option<String>,
2218 api_secret: Option<String>,
2219 api_passphrase: Option<String>,
2220 base_url: Option<String>,
2221 timeout_secs: u64,
2222 max_retries: u32,
2223 retry_delay_ms: u64,
2224 retry_delay_max_ms: u64,
2225 environment: OKXEnvironment,
2226 proxy_url: Option<String>,
2227 ) -> anyhow::Result<Self> {
2228 let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
2229 let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
2230 let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
2231 let base_url = base_url.unwrap_or(OKX_HTTP_URL.to_string());
2232
2233 Ok(Self {
2234 inner: Arc::new(OKXRawHttpClient::with_credentials(
2235 api_key,
2236 api_secret,
2237 api_passphrase,
2238 base_url,
2239 timeout_secs,
2240 max_retries,
2241 retry_delay_ms,
2242 retry_delay_max_ms,
2243 environment,
2244 proxy_url,
2245 )?),
2246 instruments_cache: Arc::new(AtomicMap::new()),
2247 trade_quote_ccy_lists: Arc::new(AtomicMap::new()),
2248 spot_trade_quote_ccy: Arc::new(Mutex::new(None)),
2249 cache_initialized: AtomicBool::new(false),
2250 })
2251 }
2252
2253 fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
2259 self.instruments_cache
2260 .get_cloned(&symbol)
2261 .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not in cache"))
2262 }
2263
2264 fn instrument_from_cache_by_id(
2265 &self,
2266 instrument_id: InstrumentId,
2267 ) -> anyhow::Result<InstrumentAny> {
2268 self.instruments_cache
2269 .get_cloned(&instrument_id.symbol.inner())
2270 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
2271 }
2272
2273 pub fn cancel_all_requests(&self) {
2275 self.inner.cancel_all_requests();
2276 }
2277
2278 pub fn cancellation_token(&self) -> &CancellationToken {
2280 self.inner.cancellation_token()
2281 }
2282
2283 pub async fn get_orders_pending(
2289 &self,
2290 params: GetOrderListParams,
2291 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
2292 self.inner.get_orders_pending(params).await
2293 }
2294
2295 pub async fn get_order_algo_pending(
2301 &self,
2302 params: GetAlgoOrdersParams,
2303 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
2304 self.inner.get_order_algo_pending(params).await
2305 }
2306
2307 pub async fn get_positions(
2313 &self,
2314 params: GetPositionsParams,
2315 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
2316 self.inner.get_positions(params).await
2317 }
2318
2319 pub fn base_url(&self) -> &str {
2321 self.inner.base_url.as_str()
2322 }
2323
2324 pub fn api_key(&self) -> Option<&str> {
2326 self.inner.credential.as_ref().map(Credential::api_key)
2327 }
2328
2329 #[must_use]
2331 pub fn api_key_masked(&self) -> Option<String> {
2332 self.inner
2333 .credential
2334 .as_ref()
2335 .map(Credential::api_key_masked)
2336 }
2337
2338 #[must_use]
2340 pub fn is_demo(&self) -> bool {
2341 self.inner.environment == OKXEnvironment::Demo
2342 }
2343
2344 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
2352 self.inner.get_server_time().await
2353 }
2354
2355 #[must_use]
2359 pub fn is_initialized(&self) -> bool {
2360 self.cache_initialized.load(Ordering::Acquire)
2361 }
2362
2363 #[must_use]
2366 pub fn get_cached_symbols(&self) -> Vec<String> {
2367 self.instruments_cache
2368 .load()
2369 .keys()
2370 .map(ToString::to_string)
2371 .collect()
2372 }
2373
2374 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
2378 self.instruments_cache.rcu(|m| {
2379 for inst in instruments {
2380 m.insert(inst.raw_symbol().inner(), inst.clone());
2381 }
2382 });
2383 self.cache_initialized.store(true, Ordering::Release);
2384 }
2385
2386 pub fn cache_instrument(&self, instrument: InstrumentAny) {
2390 self.instruments_cache
2391 .insert(instrument.raw_symbol().inner(), instrument);
2392 self.cache_initialized.store(true, Ordering::Release);
2393 }
2394
2395 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
2397 self.instruments_cache.get_cloned(symbol)
2398 }
2399
2400 pub fn set_spot_trade_quote_ccy(&self, ccy: Option<String>) {
2402 *self
2403 .spot_trade_quote_ccy
2404 .lock()
2405 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2406 ccy.map(|value| Ustr::from(value.as_str()));
2407 }
2408
2409 pub fn cache_trade_quote_ccy_lists(
2411 &self,
2412 mappings: impl IntoIterator<Item = (Ustr, Vec<Ustr>)>,
2413 ) {
2414 let entries: Vec<_> = mappings.into_iter().collect();
2415 self.trade_quote_ccy_lists.rcu(|m| {
2416 for (inst_id, list) in &entries {
2417 m.insert(*inst_id, list.clone());
2418 }
2419 });
2420 }
2421
2422 #[must_use]
2424 pub fn trade_quote_ccy_lists_snapshot(&self) -> Vec<(Ustr, Vec<Ustr>)> {
2425 self.trade_quote_ccy_lists
2426 .load()
2427 .iter()
2428 .map(|(inst_id, list)| (*inst_id, list.clone()))
2429 .collect()
2430 }
2431
2432 fn cache_trade_quote_ccy_list_from_instrument(&self, instrument: &OKXInstrument) {
2433 if !instrument.trade_quote_ccy_list.is_empty() {
2434 self.trade_quote_ccy_lists
2435 .insert(instrument.inst_id, instrument.trade_quote_ccy_list.clone());
2436 }
2437 }
2438
2439 fn resolve_spot_trade_quote_ccy(
2440 &self,
2441 instrument_type: OKXInstrumentType,
2442 inst_id: Ustr,
2443 ) -> Result<Option<Ustr>, OKXHttpError> {
2444 let configured = *self
2445 .spot_trade_quote_ccy
2446 .lock()
2447 .unwrap_or_else(std::sync::PoisonError::into_inner);
2448 let available = self
2449 .trade_quote_ccy_lists
2450 .load()
2451 .get(&inst_id)
2452 .cloned()
2453 .unwrap_or_default();
2454 spot_trade_quote_ccy_wire_value(
2455 instrument_type,
2456 configured.as_ref().map(Ustr::as_str),
2457 &available,
2458 )
2459 .map_err(OKXHttpError::ValidationError)
2460 }
2461
2462 pub async fn request_account_state(
2471 &self,
2472 account_id: AccountId,
2473 account_type: AccountType,
2474 ) -> anyhow::Result<AccountState> {
2475 let resp = self
2476 .inner
2477 .get_balance()
2478 .await
2479 .map_err(|e| anyhow::anyhow!(e))?;
2480
2481 let ts_init = self.generate_ts_init();
2482 let raw = resp
2483 .first()
2484 .ok_or_else(|| anyhow::anyhow!("No account state returned from OKX"))?;
2485 let account_state = parse_account_state(raw, account_id, account_type, ts_init)?;
2486
2487 Ok(account_state)
2488 }
2489
2490 pub async fn set_position_mode(&self, position_mode: OKXPositionMode) -> anyhow::Result<()> {
2503 let mut params = SetPositionModeParamsBuilder::default();
2504 params.pos_mode(position_mode);
2505 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2506
2507 match self.inner.set_position_mode(params).await {
2508 Ok(_) => Ok(()),
2509 Err(e) => {
2510 if let OKXHttpError::OkxError {
2511 error_code,
2512 message,
2513 } = &e
2514 && error_code == "50115"
2515 {
2516 log::warn!(
2517 "Account does not support position mode setting (derivatives trading not enabled): {message}"
2518 );
2519 return Ok(()); }
2521 anyhow::bail!(e)
2522 }
2523 }
2524 }
2525
2526 pub async fn activate_feature(&self, feature: &str) -> anyhow::Result<()> {
2540 let params = ActivateFeatureParams {
2541 feature: feature.to_string(),
2542 };
2543
2544 self.inner
2545 .activate_feature(params)
2546 .await
2547 .map(|_| ())
2548 .map_err(Into::into)
2549 }
2550
2551 pub async fn refresh_account_trade_quote_ccy_lists(
2557 &self,
2558 instrument_type: OKXInstrumentType,
2559 instrument_family: Option<String>,
2560 ) -> anyhow::Result<()> {
2561 let mut params = GetInstrumentsParamsBuilder::default();
2562 params.inst_type(instrument_type);
2563 if let Some(family) = instrument_family {
2564 params.inst_family(family);
2565 }
2566
2567 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2568 let instruments = self
2569 .inner
2570 .get_account_instruments(params)
2571 .await
2572 .map_err(|e| anyhow::anyhow!(e))?;
2573 self.cache_trade_quote_ccy_lists(
2574 instruments
2575 .iter()
2576 .filter(|instrument| !instrument.trade_quote_ccy_list.is_empty())
2577 .map(|instrument| (instrument.inst_id, instrument.trade_quote_ccy_list.clone())),
2578 );
2579
2580 Ok(())
2581 }
2582
2583 pub async fn request_instruments(
2598 &self,
2599 instrument_type: OKXInstrumentType,
2600 instrument_family: Option<String>,
2601 ) -> anyhow::Result<(Vec<InstrumentAny>, Vec<(Ustr, u64)>)> {
2602 if instrument_type == OKXInstrumentType::Option && instrument_family.is_none() {
2603 anyhow::bail!(
2604 "option instruments require instrument_family (OKX instFamily), for example BTC-USD"
2605 );
2606 }
2607
2608 let resp = if instrument_type == OKXInstrumentType::Events {
2609 let series_ids = if let Some(series_id) = instrument_family.clone() {
2610 vec![series_id]
2611 } else {
2612 self.inner
2613 .get_event_contract_series(GetEventContractSeriesParams::default())
2614 .await
2615 .map_err(|e| anyhow::anyhow!(e))?
2616 .into_iter()
2617 .map(|series| series.series_id)
2618 .collect()
2619 };
2620
2621 let mut event_instruments = Vec::new();
2622
2623 for series_id in series_ids {
2624 let mut params = GetInstrumentsParamsBuilder::default();
2625 params.inst_type(OKXInstrumentType::Events);
2626 params.series_id(series_id);
2627 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2628 let mut page = self
2629 .inner
2630 .get_instruments(params)
2631 .await
2632 .map_err(|e| anyhow::anyhow!(e))?;
2633 event_instruments.append(&mut page);
2634 }
2635 event_instruments
2636 } else {
2637 let mut params = GetInstrumentsParamsBuilder::default();
2638 params.inst_type(instrument_type);
2639
2640 if let Some(family) = instrument_family.clone() {
2641 params.inst_family(family);
2642 }
2643
2644 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2645
2646 self.inner
2647 .get_instruments(params)
2648 .await
2649 .map_err(|e| anyhow::anyhow!(e))?
2650 };
2651
2652 let fee_rate_opt = {
2653 let fee_params = GetTradeFeeParams {
2654 inst_type: instrument_type,
2655 uly: None,
2656 inst_family: if instrument_type == OKXInstrumentType::Events {
2657 None
2658 } else {
2659 instrument_family
2660 },
2661 };
2662
2663 match self.inner.get_trade_fee(fee_params).await {
2664 Ok(rates) => rates.into_iter().next(),
2665 Err(OKXHttpError::MissingCredentials) => {
2666 log::debug!("Missing credentials for fee rates, using None");
2667 None
2668 }
2669 Err(e) => {
2670 log::warn!("Failed to fetch fee rates for {instrument_type}: {e}");
2671 None
2672 }
2673 }
2674 };
2675
2676 let ts_init = self.generate_ts_init();
2677
2678 let mut instruments: Vec<InstrumentAny> = Vec::new();
2679 let mut inst_id_codes: Vec<(Ustr, u64)> = Vec::new();
2680 let mut trade_quote_ccy_lists: Vec<(Ustr, Vec<Ustr>)> = Vec::new();
2681
2682 for inst in &resp {
2683 if let Some(code) = inst.inst_id_code {
2685 inst_id_codes.push((inst.inst_id, code));
2686 }
2687
2688 if !inst.trade_quote_ccy_list.is_empty() {
2689 trade_quote_ccy_lists.push((inst.inst_id, inst.trade_quote_ccy_list.clone()));
2690 }
2691
2692 if inst.state == OKXInstrumentStatus::Preopen {
2695 continue;
2696 }
2697
2698 let (maker_fee, taker_fee) = if let Some(ref fee_rate) = fee_rate_opt {
2703 let is_usdt_margined = inst.ct_type == OKXContractType::Linear;
2704 let (maker_str, taker_str) = if is_usdt_margined {
2705 (&fee_rate.maker_u, &fee_rate.taker_u)
2706 } else {
2707 (&fee_rate.maker, &fee_rate.taker)
2708 };
2709
2710 let maker = if maker_str.is_empty() {
2711 None
2712 } else {
2713 Decimal::from_str(maker_str).ok().map(|v| -v)
2714 };
2715 let taker = if taker_str.is_empty() {
2716 None
2717 } else {
2718 Decimal::from_str(taker_str).ok().map(|v| -v)
2719 };
2720
2721 (maker, taker)
2722 } else {
2723 (None, None)
2724 };
2725
2726 match parse_instrument_any(inst, None, None, maker_fee, taker_fee, ts_init) {
2727 Ok(Some(instrument_any)) => {
2728 instruments.push(instrument_any);
2729 }
2730 Ok(None) => {
2731 }
2733 Err(e) => {
2734 log::warn!("Failed to parse instrument {}: {e}", inst.inst_id);
2735 }
2736 }
2737 }
2738
2739 self.cache_trade_quote_ccy_lists(trade_quote_ccy_lists);
2740
2741 Ok((instruments, inst_id_codes))
2742 }
2743
2744 pub async fn request_spread_instruments(
2750 &self,
2751 params: GetSpreadsParams,
2752 ) -> anyhow::Result<Vec<InstrumentAny>> {
2753 let resp = self
2754 .inner
2755 .get_spreads(params)
2756 .await
2757 .map_err(|e| anyhow::anyhow!(e))?;
2758
2759 let ts_init = self.generate_ts_init();
2760 let mut instruments = Vec::new();
2761
2762 for spread in &resp {
2763 match parse_spread_instrument(spread, None, None, None, None, ts_init) {
2764 Ok(instrument) => instruments.push(instrument),
2765 Err(e) => log::warn!("Failed to parse spread {}: {e}", spread.sprd_id),
2766 }
2767 }
2768
2769 Ok(instruments)
2770 }
2771
2772 pub async fn request_instrument(
2783 &self,
2784 instrument_id: InstrumentId,
2785 ) -> anyhow::Result<InstrumentAny> {
2786 let symbol = instrument_id.symbol.as_str();
2787
2788 if is_okx_spread_symbol(symbol) {
2789 let instrument = self.request_spread_instrument(symbol).await?;
2790 self.cache_instrument(instrument.clone());
2791 return Ok(instrument);
2792 }
2793
2794 let instrument_type = okx_instrument_type_from_symbol(symbol);
2795
2796 let resp = if instrument_type == OKXInstrumentType::Events {
2797 let series = self
2798 .inner
2799 .get_event_contract_series(GetEventContractSeriesParams::default())
2800 .await
2801 .map_err(|e| anyhow::anyhow!(e))?;
2802
2803 let mut matched = Vec::new();
2804
2805 for series in series {
2806 let mut params = GetInstrumentsParamsBuilder::default();
2807 params.inst_type(OKXInstrumentType::Events);
2808 params.series_id(series.series_id);
2809 params.inst_id(symbol);
2810 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2811
2812 let mut page = self
2813 .inner
2814 .get_instruments(params)
2815 .await
2816 .map_err(|e| anyhow::anyhow!(e))?;
2817 matched.append(&mut page);
2818 if !matched.is_empty() {
2819 break;
2820 }
2821 }
2822 matched
2823 } else {
2824 let mut params = GetInstrumentsParamsBuilder::default();
2825 params.inst_type(instrument_type);
2826 params.inst_id(symbol);
2827
2828 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2829
2830 self.inner
2831 .get_instruments(params)
2832 .await
2833 .map_err(|e| anyhow::anyhow!(e))?
2834 };
2835
2836 let raw_inst = resp
2837 .first()
2838 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2839 self.cache_trade_quote_ccy_list_from_instrument(raw_inst);
2840
2841 if raw_inst.state == OKXInstrumentStatus::Preopen {
2843 return Err(OKXInstrumentDefinitionError::new(
2844 symbol,
2845 anyhow::anyhow!("instrument is in pre-open state"),
2846 )
2847 .into());
2848 }
2849
2850 let fee_rate_opt = {
2851 let fee_params = GetTradeFeeParams {
2852 inst_type: instrument_type,
2853 uly: None,
2854 inst_family: None,
2855 };
2856
2857 match self.inner.get_trade_fee(fee_params).await {
2858 Ok(rates) => rates.into_iter().next(),
2859 Err(OKXHttpError::MissingCredentials) => {
2860 log::debug!("Missing credentials for fee rates, using None");
2861 None
2862 }
2863 Err(e) => {
2864 log::warn!("Failed to fetch fee rates for {symbol}: {e}");
2865 None
2866 }
2867 }
2868 };
2869
2870 let (maker_fee, taker_fee) = if let Some(ref fee_rate) = fee_rate_opt {
2874 let is_usdt_margined = raw_inst.ct_type == OKXContractType::Linear;
2875 let (maker_str, taker_str) = if is_usdt_margined {
2876 (&fee_rate.maker_u, &fee_rate.taker_u)
2877 } else {
2878 (&fee_rate.maker, &fee_rate.taker)
2879 };
2880
2881 let maker = if maker_str.is_empty() {
2882 None
2883 } else {
2884 Decimal::from_str(maker_str).ok().map(|v| -v)
2885 };
2886 let taker = if taker_str.is_empty() {
2887 None
2888 } else {
2889 Decimal::from_str(taker_str).ok().map(|v| -v)
2890 };
2891
2892 (maker, taker)
2893 } else {
2894 (None, None)
2895 };
2896
2897 let ts_init = self.generate_ts_init();
2898 let Some(instrument) =
2899 parse_instrument_any(raw_inst, None, None, maker_fee, taker_fee, ts_init)
2900 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e))?
2901 else {
2902 return Err(OKXInstrumentDefinitionError::new(
2903 symbol,
2904 anyhow::anyhow!("unsupported instrument type"),
2905 )
2906 .into());
2907 };
2908
2909 self.cache_instrument(instrument.clone());
2910
2911 Ok(instrument)
2912 }
2913
2914 async fn request_spread_instrument(&self, symbol: &str) -> anyhow::Result<InstrumentAny> {
2915 let resp = self
2916 .inner
2917 .get_spreads(GetSpreadsParams {
2918 sprd_id: Some(symbol.to_string()),
2919 ..Default::default()
2920 })
2921 .await
2922 .map_err(|e| anyhow::anyhow!(e))?;
2923
2924 let raw_spread = resp
2925 .first()
2926 .ok_or_else(|| anyhow::anyhow!("Spread instrument {symbol} not found"))?;
2927 let ts_init = self.generate_ts_init();
2928
2929 parse_spread_instrument(raw_spread, None, None, None, None, ts_init)
2930 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e).into())
2931 }
2932
2933 pub async fn request_event_contract_series(
2939 &self,
2940 params: GetEventContractSeriesParams,
2941 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
2942 self.inner.get_event_contract_series(params).await
2943 }
2944
2945 pub async fn request_event_contract_events(
2951 &self,
2952 params: GetEventContractEventsParams,
2953 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
2954 self.inner.get_event_contract_events(params).await
2955 }
2956
2957 pub async fn request_event_contract_markets(
2963 &self,
2964 params: GetEventContractMarketsParams,
2965 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
2966 self.inner.get_event_contract_markets(params).await
2967 }
2968
2969 pub(crate) async fn request_option_chain_reference_price(
2975 &self,
2976 instrument_id: InstrumentId,
2977 ) -> anyhow::Result<Option<Price>> {
2978 let symbol = instrument_id.symbol.inner().as_str();
2979 let inst_family = extract_inst_family(symbol)?.to_string();
2980 let exp_time = Self::option_summary_exp_time(symbol)?;
2981 let summaries = self
2982 .inner
2983 .get_option_summary(GetOptionSummaryParams {
2984 inst_family,
2985 exp_time,
2986 })
2987 .await
2988 .map_err(|e| anyhow::anyhow!(e))?;
2989
2990 for summary in summaries {
2991 if summary.inst_type != OKXInstrumentType::Option || summary.inst_id != symbol {
2992 continue;
2993 }
2994
2995 let decimal = match Decimal::from_str(&summary.fwd_px) {
2996 Ok(decimal) if decimal > Decimal::ZERO => decimal,
2997 Ok(_) => return Ok(None),
2998 Err(e) => {
2999 log::warn!("Invalid OKX option-chain reference price for {instrument_id}: {e}");
3000 return Ok(None);
3001 }
3002 };
3003 return Price::from_decimal(decimal).map(Some).map_err(Into::into);
3004 }
3005
3006 Ok(None)
3007 }
3008
3009 pub async fn request_mark_price(
3015 &self,
3016 instrument_id: InstrumentId,
3017 ) -> anyhow::Result<MarkPriceUpdate> {
3018 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
3019 let mut params = GetMarkPriceParamsBuilder::default();
3020 params.inst_type(okx_instrument_type(&inst)?);
3021 params.inst_id(instrument_id.symbol.inner());
3022 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
3023
3024 let resp = self
3025 .inner
3026 .get_mark_price(params)
3027 .await
3028 .map_err(|e| anyhow::anyhow!(e))?;
3029
3030 let raw = resp
3031 .first()
3032 .ok_or_else(|| anyhow::anyhow!("No mark price returned from OKX"))?;
3033 let ts_init = self.generate_ts_init();
3034
3035 let mark_price =
3036 parse_mark_price_update(raw, instrument_id, inst.price_precision(), ts_init)
3037 .map_err(|e| anyhow::anyhow!(e))?;
3038 Ok(mark_price)
3039 }
3040
3041 pub async fn request_price_limit(
3047 &self,
3048 instrument_id: InstrumentId,
3049 ) -> anyhow::Result<OKXPriceLimit> {
3050 let mut params = GetPriceLimitParamsBuilder::default();
3051 params.inst_id(instrument_id.symbol.inner());
3052 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
3053
3054 let resp = self
3055 .inner
3056 .get_price_limit(params)
3057 .await
3058 .map_err(|e| anyhow::anyhow!(e))?;
3059
3060 resp.first()
3061 .cloned()
3062 .ok_or_else(|| anyhow::anyhow!("No price limit returned from OKX"))
3063 }
3064
3065 fn option_summary_exp_time(symbol: &str) -> anyhow::Result<Option<String>> {
3066 let parts: Vec<&str> = symbol.split('-').collect();
3067 anyhow::ensure!(
3068 parts.len() >= 5,
3069 "Expected OKX option symbol with expiry, received {symbol}"
3070 );
3071 Ok(Some(parts[2].to_string()))
3072 }
3073
3074 pub async fn request_index_price(
3080 &self,
3081 instrument_id: InstrumentId,
3082 ) -> anyhow::Result<IndexPriceUpdate> {
3083 let symbol = instrument_id.symbol.inner();
3085 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())?;
3086 let inst_id = format!("{base}-{quote}");
3087
3088 let mut params = GetIndexTickerParamsBuilder::default();
3089 params.inst_id(Ustr::from(&inst_id));
3090 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
3091
3092 let resp = self
3093 .inner
3094 .get_index_tickers(params)
3095 .await
3096 .map_err(|e| anyhow::anyhow!(e))?;
3097
3098 let raw = resp
3099 .first()
3100 .ok_or_else(|| anyhow::anyhow!("No index price returned from OKX"))?;
3101 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
3102 let ts_init = self.generate_ts_init();
3103
3104 let index_price =
3105 parse_index_price_update(raw, instrument_id, inst.price_precision(), ts_init)
3106 .map_err(|e| anyhow::anyhow!(e))?;
3107 Ok(index_price)
3108 }
3109
3110 pub async fn request_book_snapshot(
3116 &self,
3117 instrument_id: InstrumentId,
3118 depth: Option<u32>,
3119 ) -> anyhow::Result<OrderBook> {
3120 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3121 let price_precision = inst.price_precision();
3122 let size_precision = inst.size_precision();
3123
3124 let params = GetOrderBookParams {
3125 inst_id: instrument_id.symbol.to_string(),
3126 sz: depth,
3127 };
3128
3129 let resp = self
3130 .inner
3131 .get_order_book(params)
3132 .await
3133 .map_err(|e| anyhow::anyhow!(e))?;
3134
3135 let snapshot = resp
3136 .first()
3137 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
3138
3139 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
3140 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
3141
3142 for (i, level) in snapshot.bids.iter().enumerate() {
3143 let price = parse_price(&level.0, price_precision)?;
3144 let size = parse_quantity(&level.1, size_precision)?;
3145 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
3146 book.add(order, 0, i as u64, ts_event);
3147 }
3148
3149 let bids_len = snapshot.bids.len();
3150
3151 for (i, level) in snapshot.asks.iter().enumerate() {
3152 let price = parse_price(&level.0, price_precision)?;
3153 let size = parse_quantity(&level.1, size_precision)?;
3154 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
3155 book.add(order, 0, (bids_len + i) as u64, ts_event);
3156 }
3157
3158 log::debug!(
3159 "Fetched order book for {} with {} bids and {} asks",
3160 instrument_id,
3161 snapshot.bids.len(),
3162 snapshot.asks.len(),
3163 );
3164
3165 Ok(book)
3166 }
3167
3168 pub async fn request_rpi_book_snapshot(
3174 &self,
3175 instrument_id: InstrumentId,
3176 depth: Option<u32>,
3177 ) -> anyhow::Result<OrderBook> {
3178 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3179 let price_precision = inst.price_precision();
3180 let size_precision = inst.size_precision();
3181 let params = GetRpiOrderBookParams {
3182 inst_id: instrument_id.symbol.to_string(),
3183 sz: depth,
3184 };
3185 let resp = self
3186 .inner
3187 .get_rpi_order_book(params)
3188 .await
3189 .map_err(|e| anyhow::anyhow!(e))?;
3190 let snapshot = resp
3191 .first()
3192 .ok_or_else(|| anyhow::anyhow!("No RPI order book returned from OKX"))?;
3193 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
3194 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
3195
3196 for (i, level) in snapshot.bids.iter().enumerate() {
3197 let price = Price::from_decimal_dp(level.0, price_precision)?;
3198 let size = Quantity::from_decimal_dp(level.1, size_precision)?;
3199 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
3200 book.add(order, 0, i as u64, ts_event);
3201 }
3202
3203 let bids_len = snapshot.bids.len();
3204 for (i, level) in snapshot.asks.iter().enumerate() {
3205 let price = Price::from_decimal_dp(level.0, price_precision)?;
3206 let size = Quantity::from_decimal_dp(level.1, size_precision)?;
3207 let index = (bids_len + i) as u64;
3208 let order = BookOrder::new(OrderSide::Sell, price, size, index);
3209 book.add(order, 0, index, ts_event);
3210 }
3211
3212 log::debug!(
3213 "Fetched RPI order book for {} with {} bids and {} asks",
3214 instrument_id,
3215 snapshot.bids.len(),
3216 snapshot.asks.len(),
3217 );
3218
3219 Ok(book)
3220 }
3221
3222 pub async fn request_orderbook_snapshot(
3228 &self,
3229 instrument_id: InstrumentId,
3230 depth: Option<u32>,
3231 ) -> anyhow::Result<OrderBookDeltas> {
3232 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3233 let price_precision = inst.price_precision();
3234 let size_precision = inst.size_precision();
3235
3236 let params = GetOrderBookParams {
3237 inst_id: instrument_id.symbol.to_string(),
3238 sz: depth,
3239 };
3240
3241 let resp = self
3242 .inner
3243 .get_order_book(params)
3244 .await
3245 .map_err(|e| anyhow::anyhow!(e))?;
3246
3247 let snapshot = resp
3248 .first()
3249 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
3250
3251 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
3252 let total_levels = snapshot.bids.len() + snapshot.asks.len();
3253 let mut deltas = Vec::with_capacity(total_levels + 1);
3254
3255 let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_event);
3256
3257 if total_levels == 0 {
3258 clear.flags |= RecordFlag::F_LAST as u8;
3259 }
3260 deltas.push(clear);
3261
3262 let mut processed = 0_usize;
3263
3264 for (i, level) in snapshot.bids.iter().enumerate() {
3265 let price = parse_price(&level.0, price_precision)?;
3266 let size = parse_quantity(&level.1, size_precision)?;
3267 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
3268 processed += 1;
3269 let mut flags = RecordFlag::F_SNAPSHOT as u8;
3270
3271 if processed == total_levels {
3272 flags |= RecordFlag::F_LAST as u8;
3273 }
3274 deltas.push(OrderBookDelta::new(
3275 instrument_id,
3276 BookAction::Add,
3277 order,
3278 flags,
3279 0,
3280 ts_event,
3281 ts_event,
3282 ));
3283 }
3284
3285 let bids_len = snapshot.bids.len();
3286
3287 for (i, level) in snapshot.asks.iter().enumerate() {
3288 let price = parse_price(&level.0, price_precision)?;
3289 let size = parse_quantity(&level.1, size_precision)?;
3290 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
3291 processed += 1;
3292 let mut flags = RecordFlag::F_SNAPSHOT as u8;
3293
3294 if processed == total_levels {
3295 flags |= RecordFlag::F_LAST as u8;
3296 }
3297 deltas.push(OrderBookDelta::new(
3298 instrument_id,
3299 BookAction::Add,
3300 order,
3301 flags,
3302 0,
3303 ts_event,
3304 ts_event,
3305 ));
3306 }
3307
3308 log::debug!(
3309 "Fetched order book snapshot for {} with {} bids and {} asks",
3310 instrument_id,
3311 snapshot.bids.len(),
3312 snapshot.asks.len(),
3313 );
3314
3315 OrderBookDeltas::new_checked(instrument_id, deltas)
3316 .context("failed to assemble OrderBookDeltas from OKX snapshot")
3317 }
3318
3319 pub async fn request_funding_rates(
3325 &self,
3326 instrument_id: InstrumentId,
3327 start: Option<Timestamp>,
3328 end: Option<Timestamp>,
3329 limit: Option<u32>,
3330 ) -> anyhow::Result<Vec<FundingRateUpdate>> {
3331 let mut params = GetFundingRateHistoryParams {
3332 inst_id: instrument_id.symbol.to_string(),
3333 ..Default::default()
3334 };
3335
3336 if let Some(start) = start {
3338 params.before = Some(start.as_millisecond().to_string());
3339 }
3340
3341 if let Some(end) = end {
3342 params.after = Some(end.as_millisecond().to_string());
3343 }
3344
3345 params.limit = limit;
3346
3347 let resp = self
3348 .inner
3349 .get_funding_rate_history(params)
3350 .await
3351 .map_err(|e| anyhow::anyhow!(e))?;
3352
3353 let mut rates = Vec::with_capacity(resp.len());
3354
3355 for window in resp.windows(2) {
3356 let raw = &window[0];
3357 let interval_millis = raw
3358 .funding_time
3359 .checked_sub(window[1].funding_time)
3360 .context("funding interval negative, funding rates out of order")?;
3361 let rate = parse_funding_rate(raw, instrument_id, Some(interval_millis))?;
3362 rates.push(rate);
3363 }
3364
3365 if let Some(last_raw) = resp.last() {
3366 let rate = parse_funding_rate(last_raw, instrument_id, None)?;
3368 rates.push(rate);
3369 }
3370
3371 rates.reverse();
3374
3375 log::debug!(
3376 "Fetched {} funding rates for {}",
3377 rates.len(),
3378 instrument_id,
3379 );
3380
3381 Ok(rates)
3382 }
3383
3384 pub async fn request_trades(
3390 &self,
3391 instrument_id: InstrumentId,
3392 start: Option<Timestamp>,
3393 end: Option<Timestamp>,
3394 limit: Option<u32>,
3395 ) -> anyhow::Result<Vec<TradeTick>> {
3396 const OKX_TRADES_MAX_LIMIT: u32 = 100;
3397 const MAX_PAGES: usize = 500;
3398 const MAX_CONSECUTIVE_EMPTY: usize = 3;
3399
3400 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3401 enum Mode {
3402 Latest,
3403 Backward,
3404 Range,
3405 }
3406
3407 let limit = if limit == Some(0) { None } else { limit };
3408
3409 if let (Some(s), Some(e)) = (start, end) {
3410 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
3411 }
3412
3413 let now = self.inner.clock.get_time_ns().to_datetime_utc();
3414
3415 if let Some(s) = start
3416 && s > now
3417 {
3418 return Ok(Vec::new());
3419 }
3420
3421 let end = if let Some(e) = end
3422 && e > now
3423 {
3424 Some(now)
3425 } else {
3426 end
3427 };
3428
3429 let mode = match (start, end) {
3430 (None, None) => Mode::Latest,
3431 (Some(_), None) => Mode::Backward,
3432 (None, Some(_)) => Mode::Backward,
3433 (Some(_), Some(_)) => Mode::Range,
3434 };
3435
3436 let start_ms = start.map(jiff::Timestamp::as_millisecond);
3437 let end_ms = end.map(jiff::Timestamp::as_millisecond);
3438
3439 let ts_init = self.generate_ts_init();
3440 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3441
3442 if matches!(mode, Mode::Backward | Mode::Range) {
3445 let mut before_trade_id: Option<String> = None;
3446 let mut pages = 0usize;
3447 let mut page_results: Vec<Vec<TradeTick>> = Vec::new();
3448 let mut seen_trades: AHashSet<(String, i64)> = AHashSet::new();
3449 let mut unique_count = 0usize;
3450 let mut consecutive_empty_pages = 0usize;
3451
3452 let effective_limit = if start.is_some() {
3455 limit.unwrap_or(u32::MAX)
3456 } else {
3457 limit.unwrap_or(OKX_TRADES_MAX_LIMIT)
3458 };
3459
3460 log::debug!(
3461 "Starting trades pagination: mode={mode:?}, start={start:?}, end={end:?}, limit={limit:?}, effective_limit={effective_limit}"
3462 );
3463
3464 loop {
3465 if pages >= MAX_PAGES {
3466 log::warn!("Hit MAX_PAGES limit of {MAX_PAGES}");
3467 break;
3468 }
3469
3470 if effective_limit < u32::MAX && unique_count >= effective_limit as usize {
3471 log::debug!("Reached effective limit: unique_count={unique_count}");
3472 break;
3473 }
3474
3475 let remaining = (effective_limit as usize).saturating_sub(unique_count);
3476 let page_cap = remaining.min(OKX_TRADES_MAX_LIMIT as usize) as u32;
3477
3478 log::debug!(
3479 "Requesting page {}: before_id={:?}, page_cap={}, unique_count={}",
3480 pages + 1,
3481 before_trade_id,
3482 page_cap,
3483 unique_count
3484 );
3485
3486 let mut params_builder = GetTradesParamsBuilder::default();
3487 params_builder
3488 .inst_id(instrument_id.symbol.inner())
3489 .limit(page_cap)
3490 .pagination_type(1);
3491
3492 if let Some(ref before_id) = before_trade_id {
3494 params_builder.after(before_id.clone());
3495 }
3496
3497 let params = params_builder.build().map_err(anyhow::Error::new)?;
3498 let raw = self
3499 .inner
3500 .get_history_trades(params)
3501 .await
3502 .map_err(anyhow::Error::new)?;
3503
3504 log::debug!("Received {} raw trades from API", raw.len());
3505
3506 if let (Some(first), Some(last)) = (raw.first(), raw.last()) {
3507 log::debug!(
3508 "Raw response trade ID range: first={} (newest), last={} (oldest)",
3509 first.trade_id,
3510 last.trade_id,
3511 );
3512 }
3513
3514 if raw.is_empty() {
3515 log::debug!("API returned empty page, stopping pagination");
3516 break;
3517 }
3518
3519 pages += 1;
3520
3521 let mut page_trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
3522 let mut hit_start_boundary = false;
3523 let mut filtered_out = 0usize;
3524 let mut duplicates = 0usize;
3525
3526 for r in &raw {
3527 match parse_trade_tick(
3528 r,
3529 instrument_id,
3530 inst.price_precision(),
3531 inst.size_precision(),
3532 ts_init,
3533 ) {
3534 Ok(trade) => {
3535 let ts_ms = trade.ts_event.as_i64() / 1_000_000;
3536
3537 if let Some(e_ms) = end_ms
3538 && ts_ms > e_ms
3539 {
3540 filtered_out += 1;
3541 continue;
3542 }
3543
3544 if let Some(s_ms) = start_ms
3545 && ts_ms < s_ms
3546 {
3547 hit_start_boundary = true;
3548 filtered_out += 1;
3549 break;
3550 }
3551
3552 let trade_key = (trade.trade_id.to_string(), trade.ts_event.as_i64());
3553 if seen_trades.insert(trade_key) {
3554 unique_count += 1;
3555 page_trades.push(trade);
3556 } else {
3557 duplicates += 1;
3558 }
3559 }
3560 Err(e) => log::error!("{e}"),
3561 }
3562 }
3563
3564 log::debug!(
3565 "Page {} processed: {} trades kept, {} filtered out, {} duplicates, hit_start_boundary={}",
3566 pages,
3567 page_trades.len(),
3568 filtered_out,
3569 duplicates,
3570 hit_start_boundary
3571 );
3572
3573 let oldest_trade_id = if page_trades.is_empty() {
3575 if unique_count > 0 {
3578 consecutive_empty_pages += 1;
3579 if consecutive_empty_pages >= MAX_CONSECUTIVE_EMPTY {
3580 log::debug!(
3581 "Stopping: {consecutive_empty_pages} consecutive pages with no trades in range after collecting {unique_count} trades"
3582 );
3583 break;
3584 }
3585 }
3586 raw.last().map(|t| {
3588 let id = t.trade_id.to_string();
3589 log::debug!(
3590 "Setting cursor from raw response (no unique trades): oldest_id={id}"
3591 );
3592 id
3593 })
3594 } else {
3595 let oldest_id = page_trades.last().map(|t| {
3597 let id = t.trade_id.to_string();
3598 log::debug!(
3599 "Setting cursor from deduplicated trades: oldest_id={}, ts_event={}",
3600 id,
3601 t.ts_event.as_i64()
3602 );
3603 id
3604 });
3605 page_trades.reverse();
3606 page_results.push(page_trades);
3607 consecutive_empty_pages = 0;
3608 oldest_id
3609 };
3610
3611 if let Some(ref old_id) = before_trade_id
3612 && oldest_trade_id.as_ref() == Some(old_id)
3613 {
3614 break;
3615 }
3616
3617 if oldest_trade_id.is_none() {
3618 break;
3619 }
3620
3621 before_trade_id = oldest_trade_id;
3622
3623 if hit_start_boundary {
3624 break;
3625 }
3626
3627 time::sleep(time::Duration::from_millis(50)).await;
3628 }
3629
3630 log::debug!(
3631 "Pagination complete: {pages} pages, {unique_count} unique trades collected"
3632 );
3633
3634 let mut out: Vec<TradeTick> = Vec::new();
3635
3636 for page in page_results.into_iter().rev() {
3637 out.extend(page);
3638 }
3639
3640 let mut dedup_keys = AHashSet::new();
3642 let pre_dedup_len = out.len();
3643 out.retain(|trade| {
3644 dedup_keys.insert((trade.trade_id.to_string(), trade.ts_event.as_i64()))
3645 });
3646
3647 if out.len() < pre_dedup_len {
3648 log::debug!(
3649 "Removed {} duplicate trades during final dedup",
3650 pre_dedup_len - out.len()
3651 );
3652 }
3653
3654 if let Some(lim) = limit
3655 && lim > 0
3656 && out.len() > lim as usize
3657 {
3658 let excess = out.len() - lim as usize;
3659 log::debug!("Trimming {excess} oldest trades to respect limit={lim}");
3660 out.drain(0..excess);
3661 }
3662
3663 log::debug!("Returning {} trades", out.len());
3664 return Ok(out);
3665 }
3666
3667 let req_limit = limit
3668 .unwrap_or(OKX_TRADES_MAX_LIMIT)
3669 .min(OKX_TRADES_MAX_LIMIT);
3670 let params = GetTradesParamsBuilder::default()
3671 .inst_id(instrument_id.symbol.inner())
3672 .limit(req_limit)
3673 .build()
3674 .map_err(anyhow::Error::new)?;
3675
3676 let raw = self
3677 .inner
3678 .get_history_trades(params)
3679 .await
3680 .map_err(anyhow::Error::new)?;
3681
3682 let mut trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
3683
3684 for r in &raw {
3685 match parse_trade_tick(
3686 r,
3687 instrument_id,
3688 inst.price_precision(),
3689 inst.size_precision(),
3690 ts_init,
3691 ) {
3692 Ok(trade) => trades.push(trade),
3693 Err(e) => log::error!("{e}"),
3694 }
3695 }
3696
3697 trades.reverse();
3699
3700 if let Some(lim) = limit
3701 && lim > 0
3702 && trades.len() > lim as usize
3703 {
3704 trades.drain(0..trades.len() - lim as usize);
3705 }
3706
3707 Ok(trades)
3708 }
3709
3710 pub async fn request_bars(
3751 &self,
3752 bar_type: BarType,
3753 start: Option<Timestamp>,
3754 mut end: Option<Timestamp>,
3755 limit: Option<u32>,
3756 ) -> anyhow::Result<Vec<Bar>> {
3757 const HISTORY_SPLIT_DAYS: i64 = 100;
3758 const MAX_PAGES_SOFT: usize = 500;
3759
3760 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3761 enum Mode {
3762 Latest,
3763 Backward,
3764 Range,
3765 }
3766
3767 let limit = if limit == Some(0) { None } else { limit };
3768
3769 anyhow::ensure!(
3770 bar_type.aggregation_source() == AggregationSource::External,
3771 "Only EXTERNAL aggregation is supported"
3772 );
3773
3774 if let (Some(s), Some(e)) = (start, end) {
3775 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
3776 }
3777
3778 let now = self.inner.clock.get_time_ns().to_datetime_utc();
3779
3780 if let Some(s) = start
3781 && s > now
3782 {
3783 return Ok(Vec::new());
3784 }
3785
3786 if let Some(e) = end
3787 && e > now
3788 {
3789 end = Some(now);
3790 }
3791
3792 let spec = bar_type.spec();
3793 let step = spec.step.get();
3794 let bar_param = match spec.aggregation {
3795 BarAggregation::Second => format!("{step}s"),
3796 BarAggregation::Minute => format!("{step}m"),
3797 BarAggregation::Hour => format!("{step}H"),
3798 BarAggregation::Day => format!("{step}D"),
3799 BarAggregation::Week => format!("{step}W"),
3800 BarAggregation::Month => format!("{step}M"),
3801 a => anyhow::bail!("OKX does not support {a:?} aggregation"),
3802 };
3803
3804 let slot_ms: i64 = match spec.aggregation {
3805 BarAggregation::Second => (step as i64) * 1_000,
3806 BarAggregation::Minute => (step as i64) * 60_000,
3807 BarAggregation::Hour => (step as i64) * 3_600_000,
3808 BarAggregation::Day => (step as i64) * 86_400_000,
3809 BarAggregation::Week => (step as i64) * 7 * 86_400_000,
3810 BarAggregation::Month => (step as i64) * 30 * 86_400_000,
3811 _ => unreachable!("Unsupported aggregation should have been caught above"),
3812 };
3813 let slot_ns: i64 = slot_ms * 1_000_000;
3814
3815 let mode = match (start, end) {
3816 (None, None) => Mode::Latest,
3817 (Some(_), None) => Mode::Backward, (None, Some(_)) => Mode::Backward,
3819 (Some(_), Some(_)) => Mode::Range,
3820 };
3821
3822 let start_ns = start.and_then(|s| i64::try_from(s.as_nanosecond()).ok());
3823 let end_ns = end.and_then(|e| i64::try_from(e.as_nanosecond()).ok());
3824
3825 let start_ms = start.map(|s| {
3827 let ms = s.as_millisecond();
3828
3829 if slot_ms > 0 {
3830 (ms / slot_ms) * slot_ms } else {
3832 ms
3833 }
3834 });
3835 let end_ms = end.map(|e| {
3836 let ms = e.as_millisecond();
3837
3838 if slot_ms > 0 {
3839 ((ms + slot_ms - 1) / slot_ms) * slot_ms } else {
3841 ms
3842 }
3843 });
3844 let now_ms = now.as_millisecond();
3845
3846 let instrument_id = bar_type.instrument_id();
3847 let symbol = instrument_id.symbol;
3848 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3849
3850 let mut out: Vec<Bar> = Vec::new();
3851 let mut pages = 0usize;
3852
3853 let mut after_ms: Option<i64> = match mode {
3858 Mode::Range => end_ms.or(Some(now_ms)), _ => None,
3860 };
3861 let mut before_ms: Option<i64> = match mode {
3862 Mode::Backward => end_ms.map(|v| v.saturating_sub(1)),
3863 Mode::Range => start_ms, Mode::Latest => None,
3865 };
3866
3867 let mut forward_prepend_mode = matches!(mode, Mode::Range);
3869
3870 if matches!(mode, Mode::Backward | Mode::Range)
3874 && let Some(b) = before_ms
3875 {
3876 let buffer_ms = slot_ms.max(60_000); if b >= now_ms.saturating_sub(buffer_ms) {
3882 before_ms = Some(now_ms.saturating_sub(buffer_ms));
3883 }
3884 }
3885
3886 let mut have_latest_first_page = false;
3887 let mut progressless_loops = 0u8;
3888
3889 loop {
3890 if let Some(lim) = limit
3891 && lim > 0
3892 && out.len() >= lim as usize
3893 {
3894 break;
3895 }
3896
3897 if pages >= MAX_PAGES_SOFT {
3898 break;
3899 }
3900
3901 let pivot_ms = if let Some(a) = after_ms {
3902 a
3903 } else if let Some(b) = before_ms {
3904 b
3905 } else {
3906 now_ms
3907 };
3908 let age_ms = now_ms.saturating_sub(pivot_ms);
3914 let age_hours = age_ms / (60 * 60 * 1000);
3915 let using_history = age_hours > 1; let page_ceiling = if using_history { 100 } else { 300 };
3918 let remaining = limit
3919 .filter(|&l| l > 0) .map_or(page_ceiling, |l| (l as usize).saturating_sub(out.len()));
3921 let page_cap = remaining.min(page_ceiling);
3922
3923 let mut p = GetCandlesticksParamsBuilder::default();
3924 p.inst_id(symbol.as_str())
3925 .bar(&bar_param)
3926 .limit(page_cap as u32);
3927
3928 let mut req_used_before = false;
3930
3931 match mode {
3932 Mode::Latest => {
3933 if have_latest_first_page && let Some(b) = before_ms {
3934 p.before_ms(b);
3935 req_used_before = true;
3936 }
3937 }
3938 Mode::Backward => {
3939 if let Some(b) = before_ms {
3941 p.after_ms(b);
3942 }
3943 }
3944 Mode::Range => {
3945 if let Some(a) = after_ms {
3948 p.after_ms(a);
3949 }
3950
3951 if let Some(b) = before_ms {
3952 p.before_ms(b);
3953 req_used_before = true;
3954 }
3955 }
3956 }
3957
3958 let params = p.build().map_err(anyhow::Error::new)?;
3959
3960 let mut raw = if using_history {
3961 self.inner
3962 .get_history_candles(params.clone())
3963 .await
3964 .map_err(anyhow::Error::new)?
3965 } else {
3966 self.inner
3967 .get_candles(params.clone())
3968 .await
3969 .map_err(anyhow::Error::new)?
3970 };
3971
3972 if raw.is_empty() {
3974 if matches!(mode, Mode::Latest)
3976 && have_latest_first_page
3977 && !using_history
3978 && let Some(b) = before_ms
3979 {
3980 let mut p2 = GetCandlesticksParamsBuilder::default();
3981 p2.inst_id(symbol.as_str())
3982 .bar(&bar_param)
3983 .limit(page_cap as u32);
3984 p2.before_ms(b);
3985 let params2 = p2.build().map_err(anyhow::Error::new)?;
3986 let raw2 = self
3987 .inner
3988 .get_history_candles(params2)
3989 .await
3990 .map_err(anyhow::Error::new)?;
3991
3992 if raw2.is_empty() {
3993 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
3995 before_ms = Some(b.saturating_sub(jump));
3996 progressless_loops = progressless_loops.saturating_add(1);
3997 if progressless_loops >= 3 {
3998 break;
3999 }
4000 continue;
4001 } else {
4002 raw = raw2;
4003 }
4004 }
4005
4006 if raw.is_empty() && matches!(mode, Mode::Range) && pages > 0 {
4010 let backstep_ms = (page_cap as i64).saturating_mul(slot_ms.max(1));
4011 let pivot_back = after_ms.unwrap_or(now_ms).saturating_sub(backstep_ms);
4012
4013 let mut p2 = GetCandlesticksParamsBuilder::default();
4014 p2.inst_id(symbol.as_str())
4015 .bar(&bar_param)
4016 .limit(page_cap as u32)
4017 .before_ms(pivot_back);
4018 let params2 = p2.build().map_err(anyhow::Error::new)?;
4019 let raw2 = if (now_ms.saturating_sub(pivot_back)) / (24 * 60 * 60 * 1000)
4020 > HISTORY_SPLIT_DAYS
4021 {
4022 self.inner.get_history_candles(params2).await
4023 } else {
4024 self.inner.get_candles(params2).await
4025 }
4026 .map_err(anyhow::Error::new)?;
4027
4028 if raw2.is_empty() {
4029 break;
4030 } else {
4031 raw = raw2;
4032 forward_prepend_mode = true;
4033 req_used_before = true;
4034 }
4035 }
4036
4037 if raw.is_empty()
4039 && matches!(mode, Mode::Latest)
4040 && !have_latest_first_page
4041 && !using_history
4042 {
4043 let jump_days_ms = (HISTORY_SPLIT_DAYS + 1) * 86_400_000;
4044 before_ms = Some(now_ms.saturating_sub(jump_days_ms));
4045 have_latest_first_page = true;
4046 continue;
4047 }
4048
4049 if raw.is_empty() {
4051 break;
4052 }
4053 }
4054 pages += 1;
4057
4058 let ts_init = self.generate_ts_init();
4060 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
4061
4062 for r in &raw {
4063 page.push(parse_candlestick(
4064 r,
4065 bar_type,
4066 inst.price_precision(),
4067 inst.size_precision(),
4068 ts_init,
4069 )?);
4070 }
4071 page.reverse();
4072
4073 let page_oldest_ms = page.first().map(|b| b.ts_event.as_i64() / 1_000_000);
4074 let page_newest_ms = page.last().map(|b| b.ts_event.as_i64() / 1_000_000);
4075
4076 let mut filtered: Vec<Bar> = if matches!(mode, Mode::Range)
4080 && out.is_empty()
4081 && pages < 2
4082 {
4083 let tolerance_ns = slot_ns * 2; if let (Some(first), Some(last)) = (page.first(), page.last()) {
4090 log::debug!(
4091 "Range mode bootstrap page: {} bars from {} to {}, filtering with start={:?} end={:?}",
4092 page.len(),
4093 first.ts_event.as_i64() / 1_000_000,
4094 last.ts_event.as_i64() / 1_000_000,
4095 start_ms,
4096 end_ms,
4097 );
4098 }
4099
4100 let result: Vec<Bar> = page
4101 .clone()
4102 .into_iter()
4103 .filter(|b| {
4104 let ts = b.ts_event.as_i64();
4105 let ok_after =
4107 start_ns.is_none_or(|sns| ts >= sns.saturating_sub(tolerance_ns));
4108 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
4109 ok_after && ok_before
4110 })
4111 .collect();
4112
4113 result
4114 } else {
4115 page.clone()
4117 .into_iter()
4118 .filter(|b| {
4119 let ts = b.ts_event.as_i64();
4120 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
4121 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
4122 ok_after && ok_before
4123 })
4124 .collect()
4125 };
4126
4127 if !page.is_empty() && filtered.is_empty() {
4128 if matches!(mode, Mode::Range)
4130 && !forward_prepend_mode
4131 && let (Some(newest_ms), Some(start_ms)) = (page_newest_ms, start_ms)
4132 && newest_ms < start_ms.saturating_sub(slot_ms * 2)
4133 {
4134 break;
4136 }
4137 }
4138
4139 let contribution;
4141
4142 if out.is_empty() {
4143 contribution = filtered.len();
4144 out = filtered;
4145 } else {
4146 match mode {
4147 Mode::Backward | Mode::Latest => {
4148 if let Some(first) = out.first() {
4149 filtered.retain(|b| b.ts_event < first.ts_event);
4150 }
4151 contribution = filtered.len();
4152 if contribution != 0 {
4153 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
4154 new_out.extend_from_slice(&filtered);
4155 new_out.extend_from_slice(&out);
4156 out = new_out;
4157 }
4158 }
4159 Mode::Range => {
4160 if forward_prepend_mode || req_used_before {
4161 if let Some(first) = out.first() {
4163 filtered.retain(|b| b.ts_event < first.ts_event);
4164 }
4165 contribution = filtered.len();
4166 if contribution != 0 {
4167 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
4168 new_out.extend_from_slice(&filtered);
4169 new_out.extend_from_slice(&out);
4170 out = new_out;
4171 }
4172 } else {
4173 if let Some(last) = out.last() {
4175 filtered.retain(|b| b.ts_event > last.ts_event);
4176 }
4177 contribution = filtered.len();
4178 out.extend(filtered);
4179 }
4180 }
4181 }
4182 }
4183
4184 if contribution == 0
4186 && matches!(mode, Mode::Latest | Mode::Backward | Mode::Range)
4187 && let Some(b) = before_ms
4188 {
4189 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
4190 let new_b = b.saturating_sub(jump);
4191 if new_b != b {
4192 before_ms = Some(new_b);
4193 }
4194 }
4195
4196 if contribution == 0 {
4197 progressless_loops = progressless_loops.saturating_add(1);
4198 if progressless_loops >= 3 {
4199 break;
4200 }
4201 } else {
4202 progressless_loops = 0;
4203
4204 match mode {
4206 Mode::Latest | Mode::Backward => {
4207 if let Some(oldest) = page_oldest_ms {
4208 before_ms = Some(oldest.saturating_sub(1));
4209 have_latest_first_page = true;
4210 } else {
4211 break;
4212 }
4213 }
4214 Mode::Range => {
4215 if forward_prepend_mode || req_used_before {
4216 if let Some(oldest) = page_oldest_ms {
4217 let jump_back = slot_ms.max(60_000); before_ms = Some(oldest.saturating_sub(jump_back));
4220 after_ms = None;
4221 } else {
4222 break;
4223 }
4224 } else if let Some(newest) = page_newest_ms {
4225 after_ms = Some(newest.saturating_add(1));
4226 before_ms = None;
4227 } else {
4228 break;
4229 }
4230 }
4231 }
4232 }
4233
4234 if let Some(lim) = limit
4236 && lim > 0
4237 && out.len() >= lim as usize
4238 {
4239 break;
4240 }
4241
4242 if let Some(ens) = end_ns
4243 && let Some(last) = out.last()
4244 && last.ts_event.as_i64() >= ens
4245 {
4246 break;
4247 }
4248
4249 if let Some(sns) = start_ns
4250 && let Some(first) = out.first()
4251 && (matches!(mode, Mode::Backward) || forward_prepend_mode)
4252 && first.ts_event.as_i64() <= sns
4253 {
4254 if matches!(mode, Mode::Range) {
4256 if let Some(ens) = end_ns
4258 && let Some(last) = out.last()
4259 {
4260 let last_ts = last.ts_event.as_i64();
4261 if last_ts < ens {
4262 forward_prepend_mode = false;
4265 after_ms = Some((last_ts / 1_000_000).saturating_add(1));
4266 before_ms = None;
4267 continue;
4268 }
4269 }
4270 }
4271 break;
4272 }
4273
4274 time::sleep(time::Duration::from_millis(50)).await;
4275 }
4276
4277 if out.is_empty() && matches!(mode, Mode::Range) {
4279 let pivot = end_ms.unwrap_or(now_ms.saturating_sub(1));
4280 let hist = (now_ms.saturating_sub(pivot)) / (24 * 60 * 60 * 1000) > HISTORY_SPLIT_DAYS;
4281 let mut p = GetCandlesticksParamsBuilder::default();
4282 p.inst_id(symbol.as_str())
4283 .bar(&bar_param)
4284 .limit(300)
4285 .before_ms(pivot);
4286 let params = p.build().map_err(anyhow::Error::new)?;
4287 let raw = if hist {
4288 self.inner.get_history_candles(params).await
4289 } else {
4290 self.inner.get_candles(params).await
4291 }
4292 .map_err(anyhow::Error::new)?;
4293
4294 if !raw.is_empty() {
4295 let ts_init = self.generate_ts_init();
4296 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
4297
4298 for r in &raw {
4299 page.push(parse_candlestick(
4300 r,
4301 bar_type,
4302 inst.price_precision(),
4303 inst.size_precision(),
4304 ts_init,
4305 )?);
4306 }
4307 page.reverse();
4308 out = page
4309 .into_iter()
4310 .filter(|b| {
4311 let ts = b.ts_event.as_i64();
4312 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
4313 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
4314 ok_after && ok_before
4315 })
4316 .collect();
4317 }
4318 }
4319
4320 if let Some(ens) = end_ns {
4322 while out.last().is_some_and(|b| b.ts_event.as_i64() > ens) {
4323 out.pop();
4324 }
4325 }
4326
4327 if matches!(mode, Mode::Range)
4329 && !forward_prepend_mode
4330 && let Some(sns) = start_ns
4331 {
4332 let lower = sns.saturating_sub(slot_ns);
4333 while out.first().is_some_and(|b| b.ts_event.as_i64() < lower) {
4334 out.remove(0);
4335 }
4336 }
4337
4338 if let Some(lim) = limit
4340 && lim > 0
4341 && out.len() > lim as usize
4342 {
4343 let start = out.len() - lim as usize;
4344 out.drain(..start);
4345 }
4346
4347 Ok(out)
4348 }
4349
4350 #[expect(clippy::too_many_arguments)]
4361 pub async fn request_order_status_reports(
4362 &self,
4363 account_id: AccountId,
4364 instrument_type: Option<OKXInstrumentType>,
4365 instrument_id: Option<InstrumentId>,
4366 start: Option<Timestamp>,
4367 end: Option<Timestamp>,
4368 open_only: bool,
4369 limit: Option<u32>,
4370 ) -> anyhow::Result<Vec<OrderStatusReport>> {
4371 Ok(self
4372 .request_order_status_reports_scoped(
4373 account_id,
4374 instrument_type,
4375 instrument_id,
4376 start,
4377 end,
4378 open_only,
4379 limit,
4380 None,
4381 )
4382 .await?
4383 .reports)
4384 }
4385
4386 #[expect(clippy::too_many_arguments)]
4387 pub(crate) async fn request_order_status_reports_scoped(
4388 &self,
4389 account_id: AccountId,
4390 instrument_type: Option<OKXInstrumentType>,
4391 instrument_id: Option<InstrumentId>,
4392 start: Option<Timestamp>,
4393 end: Option<Timestamp>,
4394 open_only: bool,
4395 limit: Option<u32>,
4396 scope: Option<ReportInstrumentScope<'_>>,
4397 ) -> anyhow::Result<ReportSweep<OrderStatusReport>> {
4398 if instrument_id
4399 .as_ref()
4400 .is_some_and(|id| is_okx_spread_symbol(id.symbol.as_str()))
4401 || (instrument_id.is_none() && instrument_type.is_none())
4402 {
4403 return self
4404 .request_spread_order_status_reports_scoped(
4405 account_id,
4406 instrument_id,
4407 start,
4408 end,
4409 open_only,
4410 limit,
4411 scope,
4412 )
4413 .await;
4414 }
4415
4416 let instrument_type = if let Some(instrument_type) = instrument_type {
4417 instrument_type
4418 } else {
4419 let instrument_id = instrument_id.ok_or_else(|| {
4420 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
4421 })?;
4422 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4423 okx_instrument_type(&instrument)?
4424 };
4425
4426 let mut history_base = GetOrderHistoryParamsBuilder::default();
4427 history_base.inst_type(instrument_type);
4428
4429 if let Some(instrument_id) = instrument_id.as_ref() {
4430 history_base.inst_id(instrument_id.symbol.inner().to_string());
4431 }
4432 let history_base = history_base.build().map_err(|e| anyhow::anyhow!(e))?;
4433
4434 let mut pending_base = GetOrderListParamsBuilder::default();
4435 pending_base.inst_type(instrument_type);
4436
4437 if let Some(instrument_id) = instrument_id.as_ref() {
4438 pending_base.inst_id(instrument_id.symbol.inner().to_string());
4439 }
4440 let pending_base = pending_base.build().map_err(|e| anyhow::anyhow!(e))?;
4441
4442 let (combined_resp, mut complete) = if open_only {
4443 let pending = self.paginate_orders_pending(&pending_base, limit).await?;
4444 (pending.items, pending.complete)
4445 } else {
4446 let (history, pending) = Box::pin(async {
4447 tokio::try_join!(
4448 self.paginate_orders_history(&history_base, limit),
4449 self.paginate_orders_pending(&pending_base, limit),
4450 )
4451 })
4452 .await?;
4453 let mut combined_resp = history.items;
4454 combined_resp.extend(pending.items);
4455 (combined_resp, history.complete && pending.complete)
4456 };
4457
4458 let start_ns = start.map(UnixNanos::from);
4460 let end_ns = end.map(UnixNanos::from);
4461
4462 let ts_init = self.generate_ts_init();
4463 let mut reports = Vec::with_capacity(combined_resp.len());
4464
4465 let mut seen: AHashSet<String> = AHashSet::new();
4467
4468 for order in combined_resp {
4469 let seen_key = if !order.cl_ord_id.is_empty() {
4470 order.cl_ord_id.as_str().to_string()
4471 } else if let Some(algo_cl_ord_id) = order
4472 .algo_cl_ord_id
4473 .as_ref()
4474 .filter(|value| !value.as_str().is_empty())
4475 {
4476 algo_cl_ord_id.as_str().to_string()
4477 } else if let Some(algo_id) = order
4478 .algo_id
4479 .as_ref()
4480 .filter(|value| !value.as_str().is_empty())
4481 {
4482 algo_id.as_str().to_string()
4483 } else {
4484 order.ord_id.as_str().to_string()
4485 };
4486
4487 if !seen.insert(seen_key) {
4488 continue; }
4490
4491 if report_ts_outside_window(order.u_time, start_ns, end_ns)
4494 && !is_open_okx_order(order.state)
4495 {
4496 continue;
4497 }
4498
4499 let inst = match self.resolve_report_instrument(
4500 order.inst_id,
4501 order.inst_type,
4502 false,
4503 is_open_okx_order(order.state),
4504 scope,
4505 )? {
4506 InstrumentResolution::Found(inst) => inst,
4507 InstrumentResolution::Skip => continue,
4508 InstrumentResolution::Incomplete => {
4509 complete = false;
4510 continue;
4511 }
4512 };
4513
4514 let report = match parse_order_status_report(
4515 &order,
4516 account_id,
4517 inst.id(),
4518 inst.price_precision(),
4519 inst.size_precision(),
4520 ts_init,
4521 ) {
4522 Ok(report) => report,
4523 Err(e) => {
4524 log::warn!("Failed to parse order status report: {e}");
4525 complete = false;
4526 continue;
4527 }
4528 };
4529
4530 if let Some(start_ns) = start_ns
4531 && report.ts_last < start_ns
4532 && report.order_status.is_closed()
4533 {
4534 continue;
4535 }
4536
4537 if let Some(end_ns) = end_ns
4538 && report.ts_last > end_ns
4539 && report.order_status.is_closed()
4540 {
4541 continue;
4542 }
4543
4544 reports.push(report);
4545 }
4546
4547 Ok(ReportSweep { reports, complete })
4548 }
4549
4550 pub async fn request_order_status_report(
4556 &self,
4557 account_id: AccountId,
4558 instrument_id: InstrumentId,
4559 client_order_id: ClientOrderId,
4560 ) -> anyhow::Result<Option<OrderStatusReport>> {
4561 self.request_order_status_report_by_identifier(
4562 account_id,
4563 instrument_id,
4564 Some(client_order_id),
4565 None,
4566 )
4567 .await
4568 }
4569
4570 pub(crate) async fn request_order_status_report_by_venue_order_id(
4571 &self,
4572 account_id: AccountId,
4573 instrument_id: InstrumentId,
4574 venue_order_id: VenueOrderId,
4575 ) -> anyhow::Result<Option<OrderStatusReport>> {
4576 self.request_order_status_report_by_identifier(
4577 account_id,
4578 instrument_id,
4579 None,
4580 Some(venue_order_id),
4581 )
4582 .await
4583 }
4584
4585 async fn request_order_status_report_by_identifier(
4586 &self,
4587 account_id: AccountId,
4588 instrument_id: InstrumentId,
4589 client_order_id: Option<ClientOrderId>,
4590 venue_order_id: Option<VenueOrderId>,
4591 ) -> anyhow::Result<Option<OrderStatusReport>> {
4592 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4593 let mut params_builder = GetOrderParamsBuilder::default();
4594 params_builder.inst_id(instrument_id.symbol.inner().to_string());
4595
4596 match (client_order_id, venue_order_id) {
4597 (Some(client_order_id), None) => {
4598 params_builder.cl_ord_id(client_order_id.as_str().to_string());
4599 }
4600 (None, Some(venue_order_id)) => {
4601 params_builder.ord_id(venue_order_id.as_str().to_string());
4602 }
4603 _ => anyhow::bail!(
4604 "Exactly one of client_order_id or venue_order_id is required for an order detail request"
4605 ),
4606 }
4607
4608 let params = params_builder
4609 .build()
4610 .map_err(|e| anyhow::anyhow!("Failed to build order detail params: {e}"))?;
4611 let orders = match self.inner.get_order(params).await {
4612 Ok(orders) => orders,
4613 Err(e) if e.is_order_not_found() => return Ok(None),
4614 Err(e) => return Err(e.into()),
4615 };
4616 let order = match orders.as_slice() {
4617 [] => return Ok(None),
4618 [order] => order,
4619 _ => anyhow::bail!(
4620 "Order detail returned {} records for one identifier",
4621 orders.len(),
4622 ),
4623 };
4624
4625 if order.inst_id.as_str() != instrument_id.symbol.inner() {
4626 anyhow::bail!(
4627 "Order detail instrument mismatch for {instrument_id}: returned {}",
4628 order.inst_id,
4629 );
4630 }
4631
4632 if let Some(venue_order_id) = venue_order_id
4633 && order.ord_id.as_str() != venue_order_id.as_str()
4634 {
4635 anyhow::bail!(
4636 "Order detail venue order ID mismatch for {venue_order_id}: returned {}",
4637 order.ord_id,
4638 );
4639 }
4640
4641 let ts_init = self.generate_ts_init();
4642 let mut report = parse_order_status_report(
4643 order,
4644 account_id,
4645 instrument.id(),
4646 instrument.price_precision(),
4647 instrument.size_precision(),
4648 ts_init,
4649 )?;
4650 let post_only_rejected = order.state == OKXOrderStatus::Canceled
4651 && report.filled_qty.is_zero()
4652 && (order.cancel_source == OKX_POST_ONLY_CANCEL_SOURCE
4653 || order.cancel_source_reason.contains("POST_ONLY"));
4654 if post_only_rejected {
4655 report.order_status = OrderStatus::Rejected;
4656 report.post_only = true;
4657 let reason = if order.cancel_source_reason.is_empty() {
4658 OKX_POST_ONLY_CANCEL_REASON.to_string()
4659 } else {
4660 order.cancel_source_reason.clone()
4661 };
4662 report.cancel_reason = Some(reason);
4663 }
4664
4665 Ok(Some(report))
4666 }
4667
4668 pub async fn request_spread_order_status_reports(
4674 &self,
4675 account_id: AccountId,
4676 instrument_id: Option<InstrumentId>,
4677 start: Option<Timestamp>,
4678 end: Option<Timestamp>,
4679 open_only: bool,
4680 limit: Option<u32>,
4681 ) -> anyhow::Result<Vec<OrderStatusReport>> {
4682 Ok(self
4683 .request_spread_order_status_reports_scoped(
4684 account_id,
4685 instrument_id,
4686 start,
4687 end,
4688 open_only,
4689 limit,
4690 None,
4691 )
4692 .await?
4693 .reports)
4694 }
4695
4696 #[expect(clippy::too_many_arguments)]
4697 pub(crate) async fn request_spread_order_status_reports_scoped(
4698 &self,
4699 account_id: AccountId,
4700 instrument_id: Option<InstrumentId>,
4701 start: Option<Timestamp>,
4702 end: Option<Timestamp>,
4703 open_only: bool,
4704 limit: Option<u32>,
4705 scope: Option<ReportInstrumentScope<'_>>,
4706 ) -> anyhow::Result<ReportSweep<OrderStatusReport>> {
4707 let mut pending_builder = GetSpreadOrdersParamsBuilder::default();
4708 let mut history_builder = GetSpreadOrdersParamsBuilder::default();
4709
4710 if let Some(instrument_id) = instrument_id.as_ref() {
4711 let sprd_id = instrument_id.symbol.inner().to_string();
4712 pending_builder.sprd_id(sprd_id.clone());
4713 history_builder.sprd_id(sprd_id);
4714 }
4715
4716 if let Some(start) = start {
4717 history_builder.begin(start.as_millisecond().to_string());
4718 }
4719
4720 if let Some(end) = end {
4721 history_builder.end(end.as_millisecond().to_string());
4722 }
4723
4724 if let Some(limit) = spread_page_limit(limit) {
4725 pending_builder.limit(limit);
4726 history_builder.limit(limit);
4727 }
4728
4729 let pending_base = pending_builder.build().map_err(|e| anyhow::anyhow!(e))?;
4730 let history_base = history_builder.build().map_err(|e| anyhow::anyhow!(e))?;
4731
4732 let (combined_resp, mut complete) = if open_only {
4733 let pending = self
4734 .paginate_spread_orders_pending(&pending_base, limit)
4735 .await?;
4736 (pending.items, pending.complete)
4737 } else {
4738 let (history, pending) = Box::pin(async {
4739 tokio::try_join!(
4740 self.paginate_spread_orders_history(&history_base, limit),
4741 self.paginate_spread_orders_pending(&pending_base, limit),
4742 )
4743 })
4744 .await?;
4745 let mut combined_resp = history.items;
4746 combined_resp.extend(pending.items);
4747 (combined_resp, history.complete && pending.complete)
4748 };
4749
4750 let start_ns = start.map(UnixNanos::from);
4751 let end_ns = end.map(UnixNanos::from);
4752 let ts_init = self.generate_ts_init();
4753 let mut reports = Vec::with_capacity(combined_resp.len());
4754 let mut seen: AHashSet<String> = AHashSet::new();
4755
4756 for order in combined_resp {
4757 let seen_key = if order.cl_ord_id.is_empty() {
4758 order.ord_id.as_str().to_string()
4759 } else {
4760 order.cl_ord_id.as_str().to_string()
4761 };
4762
4763 if !seen.insert(seen_key) {
4764 continue;
4765 }
4766
4767 if let Some(ts) = order.u_time.or(order.c_time)
4770 && report_ts_outside_window(ts, start_ns, end_ns)
4771 && !is_open_okx_order(order.state)
4772 {
4773 continue;
4774 }
4775
4776 let inst = match self.resolve_report_instrument(
4777 order.sprd_id,
4778 OKXInstrumentType::Any,
4779 true,
4780 is_open_okx_order(order.state),
4781 scope,
4782 )? {
4783 InstrumentResolution::Found(inst) => inst,
4784 InstrumentResolution::Skip => continue,
4785 InstrumentResolution::Incomplete => {
4786 complete = false;
4787 continue;
4788 }
4789 };
4790
4791 let report = match parse_spread_order_status_report(
4792 &order,
4793 account_id,
4794 inst.id(),
4795 inst.price_precision(),
4796 inst.size_precision(),
4797 ts_init,
4798 ) {
4799 Ok(report) => report,
4800 Err(e) => {
4801 log::warn!("Failed to parse spread order status report: {e}");
4802 complete = false;
4803 continue;
4804 }
4805 };
4806
4807 if let Some(start_ns) = start_ns
4808 && report.ts_last < start_ns
4809 && report.order_status.is_closed()
4810 {
4811 continue;
4812 }
4813
4814 if let Some(end_ns) = end_ns
4815 && report.ts_last > end_ns
4816 && report.order_status.is_closed()
4817 {
4818 continue;
4819 }
4820
4821 reports.push(report);
4822 }
4823
4824 Ok(ReportSweep { reports, complete })
4825 }
4826
4827 async fn paginate_spread_orders_history(
4828 &self,
4829 base: &GetSpreadOrdersParams,
4830 limit: Option<u32>,
4831 ) -> anyhow::Result<PageSweep<OKXSpreadOrder>> {
4832 let mut all = Vec::new();
4833 let mut cursor: Option<String> = None;
4834 let mut exhausted = true;
4835
4836 for _ in 0..MAX_RECONCILIATION_PAGES {
4837 let mut params = base.clone();
4838 params.end_id = cursor.take();
4839
4840 let page = self
4841 .inner
4842 .get_spread_orders_history(params)
4843 .await
4844 .map_err(|e| anyhow::anyhow!(e))?;
4845
4846 let page_len = page.len();
4847 cursor = page.last().map(|o| o.ord_id.to_string());
4848 all.extend(page);
4849
4850 if page_len < OKX_PAGE_SIZE {
4851 exhausted = false;
4852 break;
4853 }
4854
4855 if let Some(lim) = limit
4856 && all.len() >= lim as usize
4857 {
4858 exhausted = false;
4859 break;
4860 }
4861 }
4862
4863 if exhausted && !all.is_empty() {
4864 log::warn!(
4865 "Spread order history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4866 results may be truncated ({} records)",
4867 all.len()
4868 );
4869 }
4870
4871 if let Some(lim) = limit {
4872 all.truncate(lim as usize);
4873 }
4874
4875 Ok(PageSweep::from_pages(all, exhausted))
4876 }
4877
4878 async fn paginate_spread_orders_pending(
4879 &self,
4880 base: &GetSpreadOrdersParams,
4881 limit: Option<u32>,
4882 ) -> anyhow::Result<PageSweep<OKXSpreadOrder>> {
4883 let mut all = Vec::new();
4884 let mut cursor: Option<String> = None;
4885 let mut exhausted = true;
4886
4887 for _ in 0..MAX_RECONCILIATION_PAGES {
4888 let mut params = base.clone();
4889 params.end_id = cursor.take();
4890
4891 let page = self
4892 .inner
4893 .get_spread_orders_pending(params)
4894 .await
4895 .map_err(|e| anyhow::anyhow!(e))?;
4896
4897 let page_len = page.len();
4898 cursor = page.last().map(|o| o.ord_id.to_string());
4899 all.extend(page);
4900
4901 if page_len < OKX_PAGE_SIZE {
4902 exhausted = false;
4903 break;
4904 }
4905
4906 if let Some(lim) = limit
4907 && all.len() >= lim as usize
4908 {
4909 exhausted = false;
4910 break;
4911 }
4912 }
4913
4914 if exhausted && !all.is_empty() {
4915 log::warn!(
4916 "Pending spread orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4917 results may be truncated ({} records)",
4918 all.len()
4919 );
4920 }
4921
4922 if let Some(lim) = limit {
4923 all.truncate(lim as usize);
4924 }
4925
4926 Ok(PageSweep::from_pages(all, exhausted))
4927 }
4928
4929 async fn paginate_orders_history(
4931 &self,
4932 base: &GetOrderHistoryParams,
4933 limit: Option<u32>,
4934 ) -> anyhow::Result<PageSweep<OKXOrderHistory>> {
4935 let mut all = Vec::new();
4936 let mut cursor: Option<String> = None;
4937 let mut exhausted = true;
4938
4939 for _ in 0..MAX_RECONCILIATION_PAGES {
4940 let mut params = base.clone();
4941 params.after = cursor.take();
4942
4943 let page = self
4944 .inner
4945 .get_orders_history(params)
4946 .await
4947 .map_err(|e| anyhow::anyhow!(e))?;
4948
4949 let page_len = page.len();
4950 cursor = page.last().map(|o| o.ord_id.to_string());
4951 all.extend(page);
4952
4953 if page_len < OKX_PAGE_SIZE {
4954 exhausted = false;
4955 break;
4956 }
4957
4958 if let Some(lim) = limit
4959 && all.len() >= lim as usize
4960 {
4961 exhausted = false;
4962 break;
4963 }
4964 }
4965
4966 if exhausted && !all.is_empty() {
4967 log::warn!(
4968 "Order history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4969 results may be truncated ({} records)",
4970 all.len()
4971 );
4972 }
4973
4974 if let Some(lim) = limit {
4975 all.truncate(lim as usize);
4976 }
4977
4978 Ok(PageSweep::from_pages(all, exhausted))
4979 }
4980
4981 async fn paginate_orders_pending(
4983 &self,
4984 base: &GetOrderListParams,
4985 limit: Option<u32>,
4986 ) -> anyhow::Result<PageSweep<OKXOrderHistory>> {
4987 let mut all = Vec::new();
4988 let mut cursor: Option<String> = None;
4989 let mut exhausted = true;
4990
4991 for _ in 0..MAX_RECONCILIATION_PAGES {
4992 let mut params = base.clone();
4993 params.after = cursor.take();
4994
4995 let page = self
4996 .inner
4997 .get_orders_pending(params)
4998 .await
4999 .map_err(|e| anyhow::anyhow!(e))?;
5000
5001 let page_len = page.len();
5002 cursor = page.last().map(|o| o.ord_id.to_string());
5003 all.extend(page);
5004
5005 if page_len < OKX_PAGE_SIZE {
5006 exhausted = false;
5007 break;
5008 }
5009
5010 if let Some(lim) = limit
5011 && all.len() >= lim as usize
5012 {
5013 exhausted = false;
5014 break;
5015 }
5016 }
5017
5018 if exhausted && !all.is_empty() {
5019 log::warn!(
5020 "Pending orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5021 results may be truncated ({} records)",
5022 all.len()
5023 );
5024 }
5025
5026 if let Some(lim) = limit {
5027 all.truncate(lim as usize);
5028 }
5029
5030 Ok(PageSweep::from_pages(all, exhausted))
5031 }
5032
5033 async fn paginate_fills(
5035 &self,
5036 base: &GetTransactionDetailsParams,
5037 limit: Option<u32>,
5038 history: FillHistory,
5039 ) -> anyhow::Result<PageSweep<OKXTransactionDetail>> {
5040 let mut all = Vec::new();
5041 let mut cursor: Option<String> = None;
5042 let mut exhausted = true;
5043
5044 for _ in 0..MAX_RECONCILIATION_PAGES {
5045 let mut params = base.clone();
5046 params.after = cursor.take();
5047
5048 let page = match history {
5049 FillHistory::Recent => self.inner.get_fills(params).await,
5050 FillHistory::Extended => self.inner.get_fills_history(params).await,
5051 }
5052 .map_err(|e| anyhow::anyhow!(e))?;
5053
5054 let page_len = page.len();
5055 cursor = page.last().map(|o| o.bill_id.to_string());
5056 all.extend(page);
5057
5058 if page_len < OKX_PAGE_SIZE {
5059 exhausted = false;
5060 break;
5061 }
5062
5063 if let Some(lim) = limit
5064 && all.len() >= lim as usize
5065 {
5066 exhausted = false;
5067 break;
5068 }
5069 }
5070
5071 if exhausted && !all.is_empty() {
5072 log::warn!(
5073 "Fill pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5074 results may be truncated ({} records)",
5075 all.len()
5076 );
5077 }
5078
5079 if let Some(lim) = limit {
5080 all.truncate(lim as usize);
5081 }
5082
5083 Ok(PageSweep::from_pages(all, exhausted))
5084 }
5085
5086 async fn paginate_algo_pending(
5088 &self,
5089 base: &GetAlgoOrdersParams,
5090 limit: Option<usize>,
5091 require_complete_active_coverage: bool,
5092 ) -> anyhow::Result<PageSweep<OKXOrderAlgo>> {
5093 let mut all = Vec::new();
5094 let mut cursor: Option<String> = None;
5095 let mut exhausted = true;
5096
5097 for _ in 0..MAX_RECONCILIATION_PAGES {
5098 let mut params = base.clone();
5099 params.after = cursor.take();
5100
5101 let page = match self.inner.get_order_algo_pending(params).await {
5102 Ok(result) => result,
5103 Err(OKXHttpError::UnexpectedStatus { status, .. })
5104 if status == StatusCode::NOT_FOUND && !require_complete_active_coverage =>
5105 {
5106 exhausted = false;
5107 break;
5108 }
5109 Err(e) => return Err(e.into()),
5110 };
5111
5112 let page_len = page.len();
5113 cursor = page.last().map(|o| o.algo_id.clone());
5114 all.extend(page);
5115
5116 if page_len < OKX_PAGE_SIZE {
5117 exhausted = false;
5118 break;
5119 }
5120
5121 if let Some(lim) = limit
5122 && all.len() >= lim
5123 {
5124 exhausted = false;
5125 break;
5126 }
5127 }
5128
5129 if exhausted && !all.is_empty() {
5130 log::warn!(
5131 "Algo pending pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5132 results may be truncated ({} records)",
5133 all.len()
5134 );
5135 }
5136
5137 Ok(PageSweep::from_pages(all, exhausted))
5138 }
5139
5140 async fn paginate_algo_history(
5142 &self,
5143 base: &GetAlgoOrdersParams,
5144 limit: Option<usize>,
5145 require_complete_active_coverage: bool,
5146 ) -> anyhow::Result<PageSweep<OKXOrderAlgo>> {
5147 let mut all = Vec::new();
5148 let mut cursor: Option<String> = None;
5149 let mut exhausted = true;
5150
5151 for _ in 0..MAX_RECONCILIATION_PAGES {
5152 let mut params = base.clone();
5153 params.after = cursor.take();
5154
5155 let page = match self.inner.get_order_algo_history(params).await {
5156 Ok(result) => result,
5157 Err(OKXHttpError::UnexpectedStatus { status, .. })
5158 if status == StatusCode::NOT_FOUND && !require_complete_active_coverage =>
5159 {
5160 exhausted = false;
5161 break;
5162 }
5163 Err(e) => return Err(e.into()),
5164 };
5165
5166 let page_len = page.len();
5167 cursor = page.last().map(|o| o.algo_id.clone());
5168 all.extend(page);
5169
5170 if page_len < OKX_PAGE_SIZE {
5171 exhausted = false;
5172 break;
5173 }
5174
5175 if let Some(lim) = limit
5176 && all.len() >= lim
5177 {
5178 exhausted = false;
5179 break;
5180 }
5181 }
5182
5183 if exhausted && !all.is_empty() {
5184 log::warn!(
5185 "Algo history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5186 results may be truncated ({} records)",
5187 all.len()
5188 );
5189 }
5190
5191 Ok(PageSweep::from_pages(all, exhausted))
5192 }
5193
5194 pub async fn request_fill_reports(
5204 &self,
5205 account_id: AccountId,
5206 instrument_type: Option<OKXInstrumentType>,
5207 instrument_id: Option<InstrumentId>,
5208 start: Option<Timestamp>,
5209 end: Option<Timestamp>,
5210 limit: Option<u32>,
5211 ) -> anyhow::Result<Vec<FillReport>> {
5212 Ok(self
5213 .request_fill_reports_scoped(
5214 account_id,
5215 instrument_type,
5216 instrument_id,
5217 start,
5218 end,
5219 limit,
5220 FillHistory::Recent,
5221 None,
5222 )
5223 .await?
5224 .reports)
5225 }
5226
5227 #[expect(clippy::too_many_arguments)]
5228 pub(crate) async fn request_fill_reports_scoped(
5229 &self,
5230 account_id: AccountId,
5231 instrument_type: Option<OKXInstrumentType>,
5232 instrument_id: Option<InstrumentId>,
5233 start: Option<Timestamp>,
5234 end: Option<Timestamp>,
5235 limit: Option<u32>,
5236 history: FillHistory,
5237 scope: Option<ReportInstrumentScope<'_>>,
5238 ) -> anyhow::Result<ReportSweep<FillReport>> {
5239 if instrument_id
5240 .as_ref()
5241 .is_some_and(|id| is_okx_spread_symbol(id.symbol.as_str()))
5242 || (instrument_id.is_none() && instrument_type.is_none())
5243 {
5244 return self
5245 .request_spread_fill_reports_scoped(
5246 account_id,
5247 instrument_id,
5248 start,
5249 end,
5250 limit,
5251 scope,
5252 )
5253 .await;
5254 }
5255
5256 let mut params = GetTransactionDetailsParamsBuilder::default();
5257
5258 let instrument_type = if let Some(instrument_type) = instrument_type {
5259 instrument_type
5260 } else {
5261 let instrument_id = instrument_id.ok_or_else(|| {
5262 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
5263 })?;
5264 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5265 okx_instrument_type(&instrument)?
5266 };
5267
5268 params.inst_type(instrument_type);
5269
5270 if let Some(instrument_id) = instrument_id {
5271 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5272 let instrument_type = okx_instrument_type(&instrument)?;
5273 params.inst_type(instrument_type);
5274 params.inst_id(instrument_id.symbol.inner().to_string());
5275 }
5276
5277 if let Some(start) = start {
5278 params.begin(start.as_millisecond().to_string());
5279 }
5280
5281 if let Some(end) = end {
5282 params.end(end.as_millisecond().to_string());
5283 }
5284
5285 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
5286
5287 let sweep = self.paginate_fills(¶ms, limit, history).await?;
5288 let mut complete = sweep.complete;
5289
5290 let start_ns = start.map(UnixNanos::from);
5292 let end_ns = end.map(UnixNanos::from);
5293
5294 let ts_init = self.generate_ts_init();
5295 let mut reports = Vec::with_capacity(sweep.items.len());
5296
5297 for detail in sweep.items {
5298 if detail.fill_sz.is_empty() {
5300 continue;
5301 }
5302
5303 if !fill_quantity_is_positive(&detail.fill_sz).with_context(|| {
5304 format!(
5305 "failed to parse fill quantity for instrument {}",
5306 detail.inst_id
5307 )
5308 })? {
5309 continue;
5310 }
5311
5312 if report_ts_outside_window(detail.ts, start_ns, end_ns) {
5313 continue;
5314 }
5315
5316 let inst = match self.resolve_report_instrument(
5317 detail.inst_id,
5318 detail.inst_type,
5319 false,
5320 false,
5321 scope,
5322 )? {
5323 InstrumentResolution::Found(inst) => inst,
5324 InstrumentResolution::Skip => continue,
5325 InstrumentResolution::Incomplete => {
5326 complete = false;
5327 continue;
5328 }
5329 };
5330
5331 let report = parse_fill_report(
5332 &detail,
5333 account_id,
5334 inst.id(),
5335 inst.price_precision(),
5336 inst.size_precision(),
5337 ts_init,
5338 )
5339 .with_context(|| {
5340 format!(
5341 "failed to parse fill report for instrument {}",
5342 detail.inst_id
5343 )
5344 })?;
5345
5346 if let Some(start_ns) = start_ns
5347 && report.ts_event < start_ns
5348 {
5349 continue;
5350 }
5351
5352 if let Some(end_ns) = end_ns
5353 && report.ts_event > end_ns
5354 {
5355 continue;
5356 }
5357
5358 reports.push(report);
5359 }
5360
5361 Ok(ReportSweep { reports, complete })
5362 }
5363
5364 pub async fn request_spread_fill_reports(
5370 &self,
5371 account_id: AccountId,
5372 instrument_id: Option<InstrumentId>,
5373 start: Option<Timestamp>,
5374 end: Option<Timestamp>,
5375 limit: Option<u32>,
5376 ) -> anyhow::Result<Vec<FillReport>> {
5377 Ok(self
5378 .request_spread_fill_reports_scoped(account_id, instrument_id, start, end, limit, None)
5379 .await?
5380 .reports)
5381 }
5382
5383 pub(crate) async fn request_spread_fill_reports_scoped(
5384 &self,
5385 account_id: AccountId,
5386 instrument_id: Option<InstrumentId>,
5387 start: Option<Timestamp>,
5388 end: Option<Timestamp>,
5389 limit: Option<u32>,
5390 scope: Option<ReportInstrumentScope<'_>>,
5391 ) -> anyhow::Result<ReportSweep<FillReport>> {
5392 let mut builder = GetSpreadTradesParamsBuilder::default();
5393
5394 if let Some(instrument_id) = instrument_id.as_ref() {
5395 builder.sprd_id(instrument_id.symbol.inner().to_string());
5396 }
5397
5398 if let Some(start) = start {
5399 builder.begin(start.as_millisecond().to_string());
5400 }
5401
5402 if let Some(end) = end {
5403 builder.end(end.as_millisecond().to_string());
5404 }
5405
5406 if let Some(limit) = spread_page_limit(limit) {
5407 builder.limit(limit);
5408 }
5409
5410 let params = builder.build().map_err(|e| anyhow::anyhow!(e))?;
5411 let sweep = self.paginate_spread_fills(¶ms, limit).await?;
5412 let mut complete = sweep.complete;
5413
5414 let start_ns = start.map(UnixNanos::from);
5415 let end_ns = end.map(UnixNanos::from);
5416 let ts_init = self.generate_ts_init();
5417 let mut reports = Vec::with_capacity(sweep.items.len());
5418
5419 for detail in sweep.items {
5420 if detail.fill_sz.is_empty() {
5421 continue;
5422 }
5423
5424 if !fill_quantity_is_positive(&detail.fill_sz).with_context(|| {
5425 format!(
5426 "failed to parse spread fill quantity for instrument {}",
5427 detail.sprd_id
5428 )
5429 })? {
5430 continue;
5431 }
5432
5433 if report_ts_outside_window(detail.ts, start_ns, end_ns) {
5434 continue;
5435 }
5436
5437 let inst = match self.resolve_report_instrument(
5438 detail.sprd_id,
5439 OKXInstrumentType::Any,
5440 true,
5441 false,
5442 scope,
5443 )? {
5444 InstrumentResolution::Found(inst) => inst,
5445 InstrumentResolution::Skip => continue,
5446 InstrumentResolution::Incomplete => {
5447 complete = false;
5448 continue;
5449 }
5450 };
5451
5452 let report = parse_spread_fill_report(
5453 &detail,
5454 account_id,
5455 inst.id(),
5456 inst.price_precision(),
5457 inst.size_precision(),
5458 ts_init,
5459 )
5460 .with_context(|| {
5461 format!(
5462 "failed to parse spread fill report for instrument {}",
5463 detail.sprd_id
5464 )
5465 })?;
5466
5467 if let Some(start_ns) = start_ns
5468 && report.ts_event < start_ns
5469 {
5470 continue;
5471 }
5472
5473 if let Some(end_ns) = end_ns
5474 && report.ts_event > end_ns
5475 {
5476 continue;
5477 }
5478
5479 reports.push(report);
5480 }
5481
5482 Ok(ReportSweep { reports, complete })
5483 }
5484
5485 async fn paginate_spread_fills(
5486 &self,
5487 base: &GetSpreadTradesParams,
5488 limit: Option<u32>,
5489 ) -> anyhow::Result<PageSweep<OKXSpreadTrade>> {
5490 let mut all = Vec::new();
5491 let mut cursor: Option<String> = None;
5492 let mut exhausted = true;
5493
5494 for _ in 0..MAX_RECONCILIATION_PAGES {
5495 let mut params = base.clone();
5496 params.end_id = cursor.take();
5497
5498 let page = self
5499 .inner
5500 .get_spread_trades(params)
5501 .await
5502 .map_err(|e| anyhow::anyhow!(e))?;
5503
5504 let page_len = page.len();
5505 cursor = page.last().map(|o| o.trade_id.to_string());
5506 all.extend(page);
5507
5508 if page_len < OKX_PAGE_SIZE {
5509 exhausted = false;
5510 break;
5511 }
5512
5513 if let Some(lim) = limit
5514 && all.len() >= lim as usize
5515 {
5516 exhausted = false;
5517 break;
5518 }
5519 }
5520
5521 if exhausted && !all.is_empty() {
5522 log::warn!(
5523 "Spread fill pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5524 results may be truncated ({} records)",
5525 all.len()
5526 );
5527 }
5528
5529 if let Some(lim) = limit {
5530 all.truncate(lim as usize);
5531 }
5532
5533 Ok(PageSweep::from_pages(all, exhausted))
5534 }
5535
5536 pub async fn request_position_status_reports(
5563 &self,
5564 account_id: AccountId,
5565 instrument_type: Option<OKXInstrumentType>,
5566 instrument_id: Option<InstrumentId>,
5567 ) -> anyhow::Result<Vec<PositionStatusReport>> {
5568 Ok(self
5569 .request_position_status_reports_scoped(
5570 account_id,
5571 instrument_type,
5572 instrument_id,
5573 None,
5574 )
5575 .await?
5576 .reports)
5577 }
5578
5579 pub(crate) async fn request_position_status_reports_scoped(
5580 &self,
5581 account_id: AccountId,
5582 instrument_type: Option<OKXInstrumentType>,
5583 instrument_id: Option<InstrumentId>,
5584 scope: Option<ReportInstrumentScope<'_>>,
5585 ) -> anyhow::Result<ReportSweep<PositionStatusReport>> {
5586 let mut params = GetPositionsParamsBuilder::default();
5587
5588 let instrument_type = if let Some(instrument_type) = instrument_type {
5589 instrument_type
5590 } else {
5591 let instrument_id = instrument_id.ok_or_else(|| {
5592 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
5593 })?;
5594 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5595 okx_instrument_type(&instrument)?
5596 };
5597
5598 params.inst_type(instrument_type);
5599
5600 instrument_id
5601 .as_ref()
5602 .map(|i| params.inst_id(i.symbol.inner()));
5603
5604 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
5605
5606 let resp = self
5607 .inner
5608 .get_positions(params)
5609 .await
5610 .map_err(|e| anyhow::anyhow!(e))?;
5611
5612 let ts_init = self.generate_ts_init();
5613 let mut reports = Vec::with_capacity(resp.len());
5614
5615 for position in resp {
5616 let inst = match self.resolve_report_instrument(
5617 position.inst_id,
5618 position.inst_type,
5619 false,
5620 true,
5621 scope,
5622 )? {
5623 InstrumentResolution::Found(inst) => inst,
5624 InstrumentResolution::Skip => continue,
5625 InstrumentResolution::Incomplete => {
5626 anyhow::bail!(
5627 "Instrument {} missing from cache for position report",
5628 position.inst_id
5629 );
5630 }
5631 };
5632
5633 let report = parse_position_status_report(
5634 &position,
5635 account_id,
5636 inst.id(),
5637 inst.size_precision(),
5638 ts_init,
5639 )
5640 .with_context(|| {
5641 format!(
5642 "failed to parse position status report for instrument {}",
5643 position.inst_id
5644 )
5645 })?;
5646 reports.push(report);
5647 }
5648
5649 Ok(ReportSweep {
5650 reports,
5651 complete: true,
5652 })
5653 }
5654
5655 pub async fn request_spot_margin_position_reports(
5670 &self,
5671 account_id: AccountId,
5672 ) -> anyhow::Result<Vec<PositionStatusReport>> {
5673 let accounts = self
5674 .inner
5675 .get_balance()
5676 .await
5677 .map_err(|e| anyhow::anyhow!(e))?;
5678
5679 let ts_init = self.generate_ts_init();
5680 let mut reports = Vec::new();
5681
5682 let cache_snapshot = self.instruments_cache.load();
5696 let mut candidates: Vec<&InstrumentAny> = cache_snapshot
5697 .values()
5698 .filter(|inst| matches!(inst, InstrumentAny::CurrencyPair(_)))
5699 .collect();
5700 candidates.sort_by(|a, b| {
5701 let a_sym = a.id().symbol.as_str().to_string();
5702 let b_sym = b.id().symbol.as_str().to_string();
5703 spot_quote_priority(&a_sym)
5704 .cmp(&spot_quote_priority(&b_sym))
5705 .then_with(|| a_sym.cmp(&b_sym))
5706 });
5707
5708 let mut by_base: AHashMap<Ustr, (InstrumentId, u8)> = AHashMap::new();
5709
5710 for inst in candidates {
5711 if let Some(base) = inst.base_currency() {
5712 let base_code = base.code;
5713 by_base
5714 .entry(base_code)
5715 .or_insert_with(|| (inst.id(), inst.size_precision()));
5716 }
5717 }
5718
5719 for account in accounts {
5720 for balance in account.details {
5721 let ccy_str = balance.ccy.as_str();
5722
5723 let Some((instrument_id, size_precision)) =
5724 by_base.get(&Ustr::from(ccy_str)).copied()
5725 else {
5726 log::debug!("Skipping balance for {ccy_str} - no matching instrument in cache");
5727 continue;
5728 };
5729
5730 match parse_spot_margin_position_from_balance(
5731 &balance,
5732 account_id,
5733 instrument_id,
5734 size_precision,
5735 ts_init,
5736 ) {
5737 Ok(Some(report)) => reports.push(report),
5738 Ok(None) => {} Err(e) => {
5740 log::error!(
5741 "Failed to parse spot margin position from balance for {ccy_str}: {e}"
5742 );
5743 }
5744 }
5745 }
5746 }
5747
5748 Ok(reports)
5749 }
5750
5751 pub async fn place_order(
5761 &self,
5762 request: OKXPlaceOrderRequest,
5763 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5764 let body = serde_json::to_vec(&request)?;
5765
5766 let resp: Vec<OKXPlaceOrderResponse> = self
5767 .inner
5768 .send_request::<_, ()>(Method::POST, "/api/v5/trade/order", None, Some(body), true)
5769 .await?;
5770
5771 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
5772 }
5773
5774 pub async fn place_orders(
5780 &self,
5781 requests: Vec<OKXPlaceOrderRequest>,
5782 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
5783 if requests.is_empty() {
5784 return Ok(Vec::new());
5785 }
5786
5787 let body = serde_json::to_vec(&requests)?;
5788 self.inner
5789 .send_request::<_, ()>(
5790 Method::POST,
5791 "/api/v5/trade/batch-orders",
5792 None,
5793 Some(body),
5794 true,
5795 )
5796 .await
5797 }
5798
5799 pub async fn amend_order(
5805 &self,
5806 request: OKXAmendOrderRequest,
5807 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5808 let body = serde_json::to_vec(&request)?;
5809 let resp: Vec<OKXPlaceOrderResponse> = self
5810 .inner
5811 .send_request::<_, ()>(
5812 Method::POST,
5813 "/api/v5/trade/amend-order",
5814 None,
5815 Some(body),
5816 true,
5817 )
5818 .await?;
5819
5820 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
5821 }
5822
5823 pub async fn amend_orders(
5829 &self,
5830 requests: Vec<OKXAmendOrderRequest>,
5831 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
5832 if requests.is_empty() {
5833 return Ok(Vec::new());
5834 }
5835
5836 let body = serde_json::to_vec(&requests)?;
5837 self.inner
5838 .send_request::<_, ()>(
5839 Method::POST,
5840 "/api/v5/trade/amend-batch-orders",
5841 None,
5842 Some(body),
5843 true,
5844 )
5845 .await
5846 }
5847
5848 pub async fn place_spread_order(
5854 &self,
5855 request: OKXPlaceSpreadOrderRequest,
5856 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5857 let resp = self.inner.place_spread_order(request).await?;
5858 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5859
5860 if let Some(ref code) = item.s_code
5861 && code != OKX_SUCCESS_CODE
5862 {
5863 let msg = item.s_msg.clone().unwrap_or_default();
5864 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
5865 }
5866
5867 Ok(item)
5868 }
5869
5870 pub async fn cancel_spread_order(
5876 &self,
5877 request: OKXCancelSpreadOrderRequest,
5878 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
5879 let resp = self.inner.cancel_spread_order(request).await?;
5880 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5881
5882 if let Some(ref code) = item.s_code
5883 && code != OKX_SUCCESS_CODE
5884 {
5885 let msg = item.s_msg.clone().unwrap_or_default();
5886 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
5887 }
5888
5889 Ok(item)
5890 }
5891
5892 pub async fn cancel_all_spread_orders(
5898 &self,
5899 spread_id: InstrumentId,
5900 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
5901 let request = OKXCancelAllSpreadOrdersRequest {
5902 sprd_id: spread_id.symbol.as_str().to_string(),
5903 };
5904
5905 self.inner.cancel_all_spread_orders(request).await
5906 }
5907
5908 pub async fn cancel_order(
5914 &self,
5915 instrument_id: InstrumentId,
5916 client_order_id: Option<ClientOrderId>,
5917 venue_order_id: Option<VenueOrderId>,
5918 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
5919 if client_order_id.is_none() && venue_order_id.is_none() {
5920 return Err(OKXHttpError::ValidationError(
5921 "Either `client_order_id` or `venue_order_id` is required".to_string(),
5922 ));
5923 }
5924
5925 let ord_id = venue_order_id.as_ref().map(ToString::to_string);
5926 let cl_ord_id = if venue_order_id.is_none() {
5927 client_order_id.as_ref().map(|id| id.as_str().to_string())
5928 } else {
5929 None
5930 };
5931
5932 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
5933 return self
5934 .cancel_spread_order(OKXCancelSpreadOrderRequest { ord_id, cl_ord_id })
5935 .await;
5936 }
5937
5938 let request = OKXCancelOrderRequest {
5939 inst_id: instrument_id.symbol.as_str().to_string(),
5940 inst_id_code: None,
5941 ord_id,
5942 cl_ord_id,
5943 };
5944 let mut resp = self.cancel_orders(vec![request]).await?;
5945 let item = resp.pop().ok_or(OKXHttpError::EmptyResponse)?;
5946
5947 if let Some(ref code) = item.s_code
5948 && code != OKX_SUCCESS_CODE
5949 {
5950 let msg = item.s_msg.clone().unwrap_or_default();
5951 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
5952 }
5953
5954 Ok(item)
5955 }
5956
5957 pub async fn cancel_all_orders(
5963 &self,
5964 instrument_id: InstrumentId,
5965 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
5966 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
5967 return self.cancel_all_spread_orders(instrument_id).await;
5968 }
5969
5970 let instrument = self
5971 .instrument_from_cache(instrument_id.symbol.inner())
5972 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5973 let instrument_type = okx_instrument_type(&instrument)
5974 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5975
5976 let mut pending_base = GetOrderListParamsBuilder::default();
5977 pending_base.inst_type(instrument_type);
5978 pending_base.inst_id(instrument_id.symbol.inner().to_string());
5979 let pending_base = pending_base
5980 .build()
5981 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5982
5983 let pending = self
5984 .paginate_orders_pending(&pending_base, None)
5985 .await
5986 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5987 let requests = pending
5988 .items
5989 .into_iter()
5990 .map(|order| OKXCancelOrderRequest {
5991 inst_id: order.inst_id.to_string(),
5992 inst_id_code: None,
5993 ord_id: if order.ord_id.is_empty() {
5994 None
5995 } else {
5996 Some(order.ord_id.to_string())
5997 },
5998 cl_ord_id: if order.ord_id.is_empty() && !order.cl_ord_id.is_empty() {
5999 Some(order.cl_ord_id.to_string())
6000 } else {
6001 None
6002 },
6003 })
6004 .collect();
6005
6006 self.cancel_orders(requests).await
6007 }
6008
6009 pub async fn cancel_orders(
6022 &self,
6023 requests: Vec<OKXCancelOrderRequest>,
6024 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
6025 if requests.is_empty() {
6026 return Ok(Vec::new());
6027 }
6028
6029 let body = serde_json::to_vec(&requests)?;
6030
6031 let resp: Vec<OKXCancelOrderResponse> = self
6032 .inner
6033 .send_request::<_, ()>(
6034 Method::POST,
6035 "/api/v5/trade/cancel-batch-orders",
6036 None,
6037 Some(body),
6038 true,
6039 )
6040 .await?;
6041
6042 for item in &resp {
6043 if let Some(ref code) = item.s_code
6044 && code != OKX_SUCCESS_CODE
6045 {
6046 let msg = item.s_msg.as_deref().unwrap_or("");
6047 log::warn!(
6048 "Order cancel rejected: ord_id={} sCode={code} sMsg={msg}",
6049 item.ord_id
6050 );
6051 }
6052 }
6053
6054 Ok(resp)
6055 }
6056
6057 pub async fn place_algo_order(
6067 &self,
6068 request: OKXPlaceAlgoOrderRequest,
6069 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
6070 let body = serde_json::to_vec(&request)?;
6071
6072 let resp: Vec<OKXPlaceAlgoOrderResponse> = self
6073 .inner
6074 .send_request::<_, ()>(
6075 Method::POST,
6076 "/api/v5/trade/order-algo",
6077 None,
6078 Some(body),
6079 true,
6080 )
6081 .await?;
6082
6083 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
6084
6085 if let Some(ref code) = item.s_code
6086 && code != "0"
6087 {
6088 let msg = item.s_msg.clone().unwrap_or_default();
6089 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
6090 }
6091
6092 Ok(item)
6093 }
6094
6095 pub async fn cancel_algo_order(
6105 &self,
6106 request: OKXCancelAlgoOrderRequest,
6107 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
6108 let body = serde_json::to_vec(&[request])?;
6111
6112 let resp: Vec<OKXCancelAlgoOrderResponse> = self
6113 .inner
6114 .send_request::<_, ()>(
6115 Method::POST,
6116 "/api/v5/trade/cancel-algos",
6117 None,
6118 Some(body),
6119 true,
6120 )
6121 .await?;
6122
6123 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
6124
6125 if let Some(ref code) = item.s_code
6126 && code != "0"
6127 {
6128 let msg = item.s_msg.clone().unwrap_or_default();
6129 return Err(OKXHttpError::from_venue_response(code.clone(), msg, None));
6130 }
6131
6132 Ok(item)
6133 }
6134
6135 pub async fn cancel_algo_orders(
6148 &self,
6149 requests: Vec<OKXCancelAlgoOrderRequest>,
6150 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
6151 if requests.is_empty() {
6152 return Ok(Vec::new());
6153 }
6154
6155 let body = serde_json::to_vec(&requests)?;
6156
6157 let resp: Vec<OKXCancelAlgoOrderResponse> = self
6158 .inner
6159 .send_request::<_, ()>(
6160 Method::POST,
6161 "/api/v5/trade/cancel-algos",
6162 None,
6163 Some(body),
6164 true,
6165 )
6166 .await?;
6167
6168 for item in &resp {
6169 if let Some(ref code) = item.s_code
6170 && code != "0"
6171 {
6172 let msg = item.s_msg.as_deref().unwrap_or("");
6173 log::warn!(
6174 "Algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
6175 item.algo_id
6176 );
6177 }
6178 }
6179
6180 Ok(resp)
6181 }
6182
6183 pub async fn cancel_advance_algo_orders(
6196 &self,
6197 requests: Vec<OKXCancelAlgoOrderRequest>,
6198 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
6199 if requests.is_empty() {
6200 return Ok(Vec::new());
6201 }
6202
6203 let body = serde_json::to_vec(&requests)?;
6204
6205 let resp: Vec<OKXCancelAlgoOrderResponse> = self
6206 .inner
6207 .send_request::<_, ()>(
6208 Method::POST,
6209 "/api/v5/trade/cancel-advance-algos",
6210 None,
6211 Some(body),
6212 true,
6213 )
6214 .await?;
6215
6216 for item in &resp {
6217 if let Some(ref code) = item.s_code
6218 && code != "0"
6219 {
6220 let msg = item.s_msg.as_deref().unwrap_or("");
6221 log::warn!(
6222 "Advance algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
6223 item.algo_id
6224 );
6225 }
6226 }
6227
6228 Ok(resp)
6229 }
6230
6231 pub async fn amend_algo_order(
6241 &self,
6242 request: OKXAmendAlgoOrderRequest,
6243 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
6244 let body = serde_json::to_vec(&request)?;
6245
6246 let resp: Vec<OKXAmendAlgoOrderResponse> = self
6247 .inner
6248 .send_request::<_, ()>(
6249 Method::POST,
6250 "/api/v5/trade/amend-algos",
6251 None,
6252 Some(body),
6253 true,
6254 )
6255 .await?;
6256
6257 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
6258 }
6259
6260 #[expect(clippy::too_many_arguments)]
6269 pub async fn amend_algo_order_with_domain_types(
6270 &self,
6271 instrument_id: InstrumentId,
6272 algo_id: String,
6273 new_trigger_price: Option<Price>,
6274 new_sl_trigger_price: Option<Price>,
6275 new_limit_price: Option<Price>,
6276 new_quantity: Option<Quantity>,
6277 new_callback_ratio: Option<String>,
6278 new_callback_spread: Option<String>,
6279 new_activation_price: Option<Price>,
6280 new_tp_trigger_price: Option<Price>,
6281 new_tp_order_price: Option<String>,
6282 new_tp_trigger_px_type: Option<String>,
6283 new_sl_order_price: Option<String>,
6284 new_sl_trigger_px_type: Option<String>,
6285 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
6286 let request = OKXAmendAlgoOrderRequest {
6287 inst_id: instrument_id.symbol.as_str().to_string(),
6288 algo_id,
6289 algo_cl_ord_id: None,
6290 new_sz: new_quantity.map(|q| q.to_string()),
6291 new_trigger_px: new_trigger_price.map(|p| p.to_string()),
6292 new_tp_trigger_px: new_tp_trigger_price.map(|p| p.to_string()),
6293 new_tp_ord_px: new_tp_order_price,
6294 new_tp_trigger_px_type,
6295 new_sl_trigger_px: new_sl_trigger_price.map(|p| p.to_string()),
6296 new_sl_ord_px: new_sl_order_price,
6297 new_sl_trigger_px_type,
6298 new_order_px: new_limit_price.map(|p| p.to_string()),
6299 new_callback_ratio,
6300 new_callback_spread,
6301 new_active_px: new_activation_price.map(|p| p.to_string()),
6302 };
6303
6304 self.amend_algo_order(request).await
6305 }
6306
6307 #[expect(clippy::too_many_arguments)]
6316 pub async fn place_order_with_domain_types(
6317 &self,
6318 instrument_id: InstrumentId,
6319 td_mode: OKXTradeMode,
6320 client_order_id: ClientOrderId,
6321 order_side: OrderSide,
6322 order_type: OrderType,
6323 quantity: Quantity,
6324 time_in_force: Option<TimeInForce>,
6325 price: Option<Price>,
6326 post_only: Option<bool>,
6327 reduce_only: Option<bool>,
6328 quote_quantity: Option<bool>,
6329 position_side: Option<PositionSide>,
6330 attach_algo_ords: Option<Vec<OKXAttachAlgoOrdRequest>>,
6331 px_usd: Option<String>,
6332 px_vol: Option<String>,
6333 outcome: Option<String>,
6334 slippage_pct: Option<String>,
6335 rpi: Option<bool>,
6336 rpi_taker_access: Option<bool>,
6337 rpi_px_round: Option<bool>,
6338 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
6339 let rpi = rpi.unwrap_or(false);
6340
6341 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
6342 if reduce_only.unwrap_or(false)
6343 || quote_quantity.unwrap_or(false)
6344 || attach_algo_ords
6345 .as_ref()
6346 .is_some_and(|orders| !orders.is_empty())
6347 || px_usd.is_some()
6348 || px_vol.is_some()
6349 || outcome.is_some()
6350 || slippage_pct.is_some()
6351 || rpi
6352 || rpi_taker_access.is_some()
6353 || rpi_px_round.is_some()
6354 {
6355 return Err(OKXHttpError::ValidationError(
6356 "OKX spread orders do not support regular order extensions".to_string(),
6357 ));
6358 }
6359
6360 return self
6361 .place_spread_order_with_domain_types(
6362 instrument_id,
6363 client_order_id,
6364 order_side,
6365 order_type,
6366 quantity,
6367 time_in_force,
6368 price,
6369 post_only,
6370 )
6371 .await;
6372 }
6373
6374 if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
6375 return Err(OKXHttpError::ValidationError(format!(
6376 "Unsupported order type: {order_type:?}",
6377 )));
6378 }
6379
6380 if matches!(
6381 order_type,
6382 OrderType::StopMarket
6383 | OrderType::StopLimit
6384 | OrderType::MarketIfTouched
6385 | OrderType::LimitIfTouched
6386 | OrderType::TrailingStopMarket
6387 ) {
6388 return Err(OKXHttpError::ValidationError(
6389 "Conditional order types must use OKX algo order placement".to_string(),
6390 ));
6391 }
6392
6393 if let Some(tif) = time_in_force
6394 && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
6395 {
6396 return Err(OKXHttpError::ValidationError(format!(
6397 "Unsupported time in force: {tif:?}",
6398 )));
6399 }
6400
6401 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6402 return Err(OKXHttpError::ValidationError(
6403 "Invalid order side".to_string(),
6404 ));
6405 }
6406
6407 let instrument = self
6408 .instrument_from_cache(instrument_id.symbol.inner())
6409 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6410 let instrument_type = okx_instrument_type(&instrument)
6411 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6412
6413 if instrument_type == OKXInstrumentType::Option
6415 && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
6416 {
6417 return Err(OKXHttpError::ValidationError(
6418 "Market orders are not supported for OKX options, use Limit orders instead"
6419 .to_string(),
6420 ));
6421 }
6422
6423 let side = OKXSide::from(order_side);
6424 let pos_side = position_side.map(Into::into).or({
6425 if matches!(
6426 instrument_type,
6427 OKXInstrumentType::Swap | OKXInstrumentType::Futures | OKXInstrumentType::Option
6428 ) {
6429 Some(OKXPositionSide::Net)
6430 } else {
6431 None
6432 }
6433 });
6434
6435 let tgt_ccy = if instrument_type == OKXInstrumentType::Spot
6436 && order_type == OrderType::Market
6437 && td_mode == OKXTradeMode::Cash
6438 {
6439 match quote_quantity {
6440 Some(true) => Some(OKXTargetCurrency::QuoteCcy),
6441 Some(false) if order_side == OrderSide::Buy => Some(OKXTargetCurrency::BaseCcy),
6442 _ => None,
6443 }
6444 } else {
6445 None
6446 };
6447
6448 if rpi && order_type != OrderType::Limit {
6449 return Err(OKXHttpError::ValidationError(
6450 "OKX RPI orders require a limit order".to_string(),
6451 ));
6452 }
6453
6454 let (ord_type, px) = if rpi {
6455 (OKXOrderType::Rpi, price)
6456 } else if post_only.unwrap_or(false) {
6457 (OKXOrderType::PostOnly, price)
6458 } else if let Some(tif) = time_in_force {
6459 match (order_type, tif) {
6460 (OrderType::Market, TimeInForce::Fok) => {
6461 return Err(OKXHttpError::ValidationError(
6462 "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string(),
6463 ));
6464 }
6465 (OrderType::Market, TimeInForce::Ioc) => {
6466 if matches!(
6468 instrument_type,
6469 OKXInstrumentType::Spot | OKXInstrumentType::Option
6470 ) {
6471 (OKXOrderType::Market, price)
6472 } else {
6473 (OKXOrderType::OptimalLimitIoc, price)
6474 }
6475 }
6476 (OrderType::Limit, TimeInForce::Fok) => {
6477 if instrument_type == OKXInstrumentType::Option {
6479 (OKXOrderType::OpFok, price)
6480 } else {
6481 (OKXOrderType::Fok, price)
6482 }
6483 }
6484 (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
6485 _ => (OKXOrderType::from(order_type), price),
6486 }
6487 } else {
6488 (OKXOrderType::from(order_type), price)
6489 };
6490
6491 if instrument_type == OKXInstrumentType::Events && outcome.is_none() {
6492 return Err(OKXHttpError::ValidationError(
6493 "OKX event contract orders require `outcome`".to_string(),
6494 ));
6495 }
6496
6497 let reduce_only = if reduce_only == Some(true) {
6498 okx_reduce_only_wire_value(
6499 instrument_type,
6500 td_mode,
6501 order_side,
6502 position_side,
6503 reduce_only,
6504 )
6505 .map_err(OKXHttpError::ValidationError)?
6506 } else {
6507 reduce_only
6508 };
6509
6510 let (px, px_usd, px_vol) = if px_usd.is_some() {
6512 (None, px_usd, None)
6513 } else if px_vol.is_some() {
6514 (None, None, px_vol)
6515 } else {
6516 (px.map(|p| p.to_string()), None, None)
6517 };
6518
6519 let trade_quote_ccy =
6520 self.resolve_spot_trade_quote_ccy(instrument_type, instrument_id.symbol.inner())?;
6521
6522 let request = OKXPlaceOrderRequest {
6523 inst_id: instrument_id.symbol.as_str().to_string(),
6524 td_mode,
6525 ccy: None,
6526 cl_ord_id: Some(client_order_id.as_str().to_string()),
6527 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6528 side,
6529 pos_side,
6530 ord_type,
6531 sz: quantity.to_string(),
6532 px,
6533 px_usd,
6534 px_vol,
6535 reduce_only,
6536 tgt_ccy,
6537 trade_quote_ccy,
6538 attach_algo_ords,
6539 outcome,
6540 slippage_pct,
6541 rpi_taker_access,
6542 rpi_px_round,
6543 };
6544
6545 self.place_order(request).await
6546 }
6547
6548 #[expect(clippy::too_many_arguments)]
6555 pub async fn place_spread_order_with_domain_types(
6556 &self,
6557 instrument_id: InstrumentId,
6558 client_order_id: ClientOrderId,
6559 order_side: OrderSide,
6560 order_type: OrderType,
6561 quantity: Quantity,
6562 time_in_force: Option<TimeInForce>,
6563 price: Option<Price>,
6564 post_only: Option<bool>,
6565 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
6566 if !is_okx_spread_symbol(instrument_id.symbol.as_str()) {
6567 return Err(OKXHttpError::ValidationError(format!(
6568 "Instrument is not an OKX spread: {instrument_id}",
6569 )));
6570 }
6571
6572 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6573 return Err(OKXHttpError::ValidationError(
6574 "Invalid order side".to_string(),
6575 ));
6576 }
6577
6578 if !matches!(order_type, OrderType::Limit) {
6579 return Err(OKXHttpError::ValidationError(
6580 "OKX spread orders support Limit orders only".to_string(),
6581 ));
6582 }
6583
6584 let ord_type = if post_only.unwrap_or(false) {
6585 OKXOrderType::PostOnly
6586 } else if matches!(time_in_force, Some(TimeInForce::Ioc)) {
6587 OKXOrderType::Ioc
6588 } else if matches!(time_in_force, Some(TimeInForce::Fok)) {
6589 return Err(OKXHttpError::ValidationError(
6590 "OKX spread orders do not support FOK time-in-force".to_string(),
6591 ));
6592 } else {
6593 OKXOrderType::Limit
6594 };
6595
6596 let price = price.ok_or_else(|| {
6597 OKXHttpError::ValidationError("OKX spread orders require a limit price".to_string())
6598 })?;
6599
6600 let request = OKXPlaceSpreadOrderRequest {
6601 sprd_id: instrument_id.symbol.as_str().to_string(),
6602 cl_ord_id: Some(client_order_id.as_str().to_string()),
6603 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6604 side: OKXSide::from(order_side),
6605 ord_type,
6606 sz: quantity.to_string(),
6607 px: Some(price.to_string()),
6608 };
6609
6610 self.place_spread_order(request).await
6611 }
6612
6613 #[expect(clippy::too_many_arguments)]
6622 pub async fn place_algo_order_with_domain_types(
6623 &self,
6624 instrument_id: InstrumentId,
6625 td_mode: OKXTradeMode,
6626 client_order_id: ClientOrderId,
6627 order_side: OrderSide,
6628 order_type: OrderType,
6629 quantity: Quantity,
6630 trigger_price: Option<Price>,
6631 trigger_type: Option<TriggerType>,
6632 limit_price: Option<Price>,
6633 reduce_only: Option<bool>,
6634 close_fraction: Option<String>,
6635 callback_ratio: Option<String>,
6636 callback_spread: Option<String>,
6637 activation_price: Option<Price>,
6638 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
6639 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6640 return Err(OKXHttpError::ValidationError(
6641 "Invalid order side".to_string(),
6642 ));
6643 }
6644
6645 let okx_side = OKXSide::from(order_side);
6646
6647 let trigger_px_type_enum = trigger_type.map_or(OKXTriggerType::Last, Into::into);
6649
6650 let uses_close_fraction = close_fraction.is_some();
6651 let (
6652 algo_type,
6653 sz,
6654 trigger_px,
6655 order_px,
6656 trigger_px_type,
6657 sl_trigger_px,
6658 sl_ord_px,
6659 sl_trigger_px_type,
6660 tp_trigger_px,
6661 tp_ord_px,
6662 tp_trigger_px_type,
6663 pos_side,
6664 reduce_only,
6665 ) = if uses_close_fraction {
6666 if order_type == OrderType::TrailingStopMarket {
6667 return Err(OKXHttpError::ValidationError(
6668 "OKX close_fraction does not support TrailingStopMarket".to_string(),
6669 ));
6670 }
6671
6672 let trigger_px = trigger_price.map(|p| p.to_string()).ok_or_else(|| {
6673 OKXHttpError::ValidationError(
6674 "OKX close_fraction orders require trigger_price".to_string(),
6675 )
6676 })?;
6677
6678 let close_order_px =
6679 if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched) {
6680 limit_price.map(|p| p.to_string()).ok_or_else(|| {
6681 OKXHttpError::ValidationError(format!(
6682 "OKX {order_type:?} close_fraction orders require limit_price"
6683 ))
6684 })?
6685 } else {
6686 "-1".to_string()
6687 };
6688
6689 let (
6690 sl_trigger_px,
6691 sl_ord_px,
6692 sl_trigger_px_type,
6693 tp_trigger_px,
6694 tp_ord_px,
6695 tp_trigger_px_type,
6696 ) = match order_type {
6697 OrderType::StopMarket | OrderType::StopLimit => (
6698 Some(trigger_px),
6699 Some(close_order_px),
6700 Some(trigger_px_type_enum),
6701 None,
6702 None,
6703 None,
6704 ),
6705 OrderType::MarketIfTouched | OrderType::LimitIfTouched => (
6706 None,
6707 None,
6708 None,
6709 Some(trigger_px),
6710 Some(close_order_px),
6711 Some(trigger_px_type_enum),
6712 ),
6713 _ => {
6714 return Err(OKXHttpError::ValidationError(format!(
6715 "OKX close_fraction is only supported for stop/touched conditional orders, received {order_type:?}"
6716 )));
6717 }
6718 };
6719
6720 (
6721 OKXAlgoOrderType::Conditional,
6722 None,
6723 None,
6724 None,
6725 None,
6726 sl_trigger_px,
6727 sl_ord_px,
6728 sl_trigger_px_type,
6729 tp_trigger_px,
6730 tp_ord_px,
6731 tp_trigger_px_type,
6732 Some(OKXPositionSide::Net),
6733 Some(true),
6734 )
6735 } else {
6736 let algo_type = conditional_order_to_algo_type(order_type)
6737 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6738
6739 let order_px = if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
6740 {
6741 limit_price.map(|p| p.to_string())
6742 } else if order_type == OrderType::TrailingStopMarket {
6743 None
6744 } else {
6745 Some("-1".to_string())
6746 };
6747
6748 (
6749 algo_type,
6750 Some(quantity.to_string()),
6751 trigger_price.map(|p| p.to_string()),
6752 order_px,
6753 Some(trigger_px_type_enum),
6754 None,
6755 None,
6756 None,
6757 None,
6758 None,
6759 None,
6760 None,
6761 reduce_only,
6762 )
6763 };
6764
6765 let request = OKXPlaceAlgoOrderRequest {
6766 inst_id: instrument_id.symbol.as_str().to_string(),
6767 inst_id_code: None,
6768 td_mode,
6769 side: okx_side,
6770 ord_type: algo_type,
6771 sz,
6772 algo_cl_ord_id: Some(client_order_id.as_str().to_string()),
6773 trigger_px,
6774 order_px,
6775 trigger_px_type,
6776 sl_trigger_px,
6777 sl_ord_px,
6778 sl_trigger_px_type,
6779 tp_trigger_px,
6780 tp_ord_px,
6781 tp_trigger_px_type,
6782 tgt_ccy: None,
6783 pos_side,
6784 close_position: None,
6785 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6786 reduce_only,
6787 close_fraction,
6788 callback_ratio,
6789 callback_spread,
6790 active_px: activation_price.map(|p| p.to_string()),
6791 };
6792
6793 self.place_algo_order(request).await
6794 }
6795
6796 pub async fn cancel_algo_order_with_domain_types(
6805 &self,
6806 instrument_id: InstrumentId,
6807 algo_id: String,
6808 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
6809 let request = OKXCancelAlgoOrderRequest {
6810 inst_id: instrument_id.symbol.to_string(),
6811 inst_id_code: None,
6812 algo_id: Some(algo_id),
6813 algo_cl_ord_id: None,
6814 };
6815
6816 self.cancel_algo_order(request).await
6817 }
6818
6819 #[expect(clippy::too_many_arguments)]
6825 pub async fn request_algo_order_status_reports(
6826 &self,
6827 account_id: AccountId,
6828 instrument_type: Option<OKXInstrumentType>,
6829 instrument_id: Option<InstrumentId>,
6830 algo_id: Option<String>,
6831 algo_client_order_id: Option<ClientOrderId>,
6832 state: Option<OKXAlgoOrderStatus>,
6833 limit: Option<u32>,
6834 ) -> anyhow::Result<Vec<OrderStatusReport>> {
6835 Ok(self
6836 .request_algo_order_status_reports_sweep(
6837 account_id,
6838 instrument_type,
6839 instrument_id,
6840 algo_id,
6841 algo_client_order_id,
6842 state,
6843 limit,
6844 None,
6845 None,
6846 false,
6847 )
6848 .await?
6849 .reports)
6850 }
6851
6852 #[expect(clippy::too_many_arguments)]
6853 pub(crate) async fn request_algo_order_status_reports_sweep(
6854 &self,
6855 account_id: AccountId,
6856 instrument_type: Option<OKXInstrumentType>,
6857 instrument_id: Option<InstrumentId>,
6858 algo_id: Option<String>,
6859 algo_client_order_id: Option<ClientOrderId>,
6860 state: Option<OKXAlgoOrderStatus>,
6861 limit: Option<u32>,
6862 start: Option<Timestamp>,
6863 end: Option<Timestamp>,
6864 require_complete_active_coverage: bool,
6865 ) -> anyhow::Result<AlgoOrderReportSweep> {
6866 let mut instruments_cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
6867 let mut ambiguous_triggered_child_ids = AHashSet::new();
6868 let has_specific_lookup = algo_id.is_some() || algo_client_order_id.is_some();
6869 let start_ns = start.map(UnixNanos::from);
6870 let end_ns = end.map(UnixNanos::from);
6871 let mut complete = true;
6872
6873 let inst_type = if let Some(inst_type) = instrument_type {
6874 inst_type
6875 } else if let Some(inst_id) = instrument_id {
6876 let instrument = self.instrument_from_cache(inst_id.symbol.inner())?;
6877 let inst_type = okx_instrument_type(&instrument)?;
6878 instruments_cache.insert(inst_id.symbol.inner(), instrument);
6879 inst_type
6880 } else {
6881 anyhow::bail!("instrument_type or instrument_id required for algo order query")
6882 };
6883
6884 let ts_init = self.generate_ts_init();
6885 let mut reports = Vec::new();
6886 let mut seen: AHashMap<(String, String), usize> = AHashMap::new();
6887
6888 if has_specific_lookup {
6889 let mut params_builder = GetAlgoOrderParamsBuilder::default();
6890
6891 if let Some(algo_id) = algo_id {
6892 params_builder.algo_id(algo_id);
6893 }
6894
6895 if let Some(client_order_id) = algo_client_order_id {
6896 params_builder.algo_cl_ord_id(client_order_id.as_str().to_string());
6897 }
6898
6899 let params = params_builder
6900 .build()
6901 .map_err(|e| anyhow::anyhow!(format!("Failed to build algo order params: {e}")))?;
6902 let mut orders = match self.inner.get_algo_order(params).await {
6903 Ok(orders) => orders,
6904 Err(e) if e.is_order_not_found() => {
6905 return Ok(AlgoOrderReportSweep {
6906 reports,
6907 complete,
6908 ambiguous_triggered_child_ids,
6909 });
6910 }
6911 Err(e) => return Err(e.into()),
6912 };
6913
6914 if let Some(state) = state {
6915 orders.retain(|order| order.state == state);
6916 }
6917
6918 complete &= self.collect_algo_reports(
6919 account_id,
6920 &orders,
6921 false,
6922 &mut instruments_cache,
6923 ts_init,
6924 start_ns,
6925 end_ns,
6926 &mut seen,
6927 &mut reports,
6928 &mut ambiguous_triggered_child_ids,
6929 )?;
6930
6931 if let Some(limit) = limit {
6932 reports.truncate(limit as usize);
6933 }
6934
6935 return Ok(AlgoOrderReportSweep {
6936 reports,
6937 complete,
6938 ambiguous_triggered_child_ids,
6939 });
6940 }
6941
6942 let query_pending = state.is_none()
6943 || matches!(
6944 state,
6945 Some(OKXAlgoOrderStatus::Live | OKXAlgoOrderStatus::Pause)
6946 );
6947 let history_states: &[OKXAlgoOrderStatus] = match state {
6948 None => &[
6949 OKXAlgoOrderStatus::Effective,
6950 OKXAlgoOrderStatus::Canceled,
6951 OKXAlgoOrderStatus::OrderFailed,
6952 ],
6953 Some(OKXAlgoOrderStatus::Unknown) => &[],
6955 Some(OKXAlgoOrderStatus::Live | OKXAlgoOrderStatus::Pause) => &[],
6956 Some(
6957 OKXAlgoOrderStatus::Effective
6958 | OKXAlgoOrderStatus::OrderPlaced
6959 | OKXAlgoOrderStatus::PartiallyEffective
6960 | OKXAlgoOrderStatus::Filled,
6961 ) => &[OKXAlgoOrderStatus::Effective],
6962 Some(OKXAlgoOrderStatus::Canceled) => &[OKXAlgoOrderStatus::Canceled],
6963 Some(OKXAlgoOrderStatus::OrderFailed | OKXAlgoOrderStatus::PartiallyFailed) => {
6964 &[OKXAlgoOrderStatus::OrderFailed]
6965 }
6966 };
6967
6968 for ord_type in [
6969 OKXAlgoOrderType::Oco,
6970 OKXAlgoOrderType::Conditional,
6971 OKXAlgoOrderType::Trigger,
6972 OKXAlgoOrderType::MoveOrderStop,
6973 ] {
6974 let mut params_builder = GetAlgoOrdersParamsBuilder::default();
6975 params_builder.inst_type(inst_type);
6976 params_builder.ord_type(ord_type);
6977
6978 if let Some(inst_id) = instrument_id {
6979 params_builder.inst_id(inst_id.symbol.inner().to_string());
6980 }
6981
6982 let mut params = params_builder
6983 .build()
6984 .map_err(|e| anyhow::anyhow!(format!("Failed to build algo order params: {e}")))?;
6985
6986 if query_pending {
6987 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
6988 let pending_sweep = match self
6989 .paginate_algo_pending(¶ms, remaining, require_complete_active_coverage)
6990 .await
6991 {
6992 Ok(sweep) => sweep,
6993 Err(e) if require_complete_active_coverage => {
6994 return Err(OKXPendingAlgoOrderReportsError::new(e).into());
6995 }
6996 Err(e) => return Err(e),
6997 };
6998
6999 if require_complete_active_coverage && !pending_sweep.complete {
7000 return Err(OKXPendingAlgoOrderReportsError::new(anyhow::anyhow!(
7001 "Pending {ord_type:?} algo order pagination for {inst_type:?} did not establish complete coverage"
7002 ))
7003 .into());
7004 }
7005 complete &= pending_sweep.complete;
7006 let mut pending = pending_sweep.items;
7007
7008 if let Some(state) = state {
7009 pending.retain(|order| order.state == state);
7010 }
7011
7012 let pending_reports_complete = match self.collect_algo_reports(
7013 account_id,
7014 &pending,
7015 require_complete_active_coverage,
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 Ok(complete) => complete,
7025 Err(e) if require_complete_active_coverage => {
7026 return Err(OKXPendingAlgoOrderReportsError::new(e).into());
7027 }
7028 Err(e) => return Err(e),
7029 };
7030
7031 if require_complete_active_coverage && !pending_reports_complete {
7032 return Err(OKXPendingAlgoOrderReportsError::new(anyhow::anyhow!(
7033 "Pending {ord_type:?} algo order reports for {inst_type:?} could not be completely converted"
7034 ))
7035 .into());
7036 }
7037 complete &= pending_reports_complete;
7038
7039 if let Some(lim) = limit
7040 && reports.len() >= lim as usize
7041 {
7042 reports.truncate(lim as usize);
7043 return Ok(AlgoOrderReportSweep {
7044 reports,
7045 complete,
7046 ambiguous_triggered_child_ids,
7047 });
7048 }
7049 }
7050
7051 for history_state in history_states {
7052 params.state = Some(*history_state);
7053 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
7054 let history_sweep = match self
7055 .paginate_algo_history(¶ms, remaining, require_complete_active_coverage)
7056 .await
7057 {
7058 Ok(sweep) => sweep,
7059 Err(e) if require_complete_active_coverage => {
7060 log::warn!(
7061 "Failed to fetch {history_state:?} {ord_type:?} algo order history for {inst_type:?}: {e}"
7062 );
7063 complete = false;
7064 continue;
7065 }
7066 Err(e) => return Err(e),
7067 };
7068 complete &= history_sweep.complete;
7069 let mut history = history_sweep.items;
7070
7071 if let Some(state) = state {
7072 history.retain(|order| order.state == state);
7073 }
7074
7075 complete &= self.collect_algo_reports(
7076 account_id,
7077 &history,
7078 false,
7079 &mut instruments_cache,
7080 ts_init,
7081 start_ns,
7082 end_ns,
7083 &mut seen,
7084 &mut reports,
7085 &mut ambiguous_triggered_child_ids,
7086 )?;
7087
7088 if let Some(lim) = limit
7089 && reports.len() >= lim as usize
7090 {
7091 reports.truncate(lim as usize);
7092 return Ok(AlgoOrderReportSweep {
7093 reports,
7094 complete,
7095 ambiguous_triggered_child_ids,
7096 });
7097 }
7098 }
7099 }
7100
7101 Ok(AlgoOrderReportSweep {
7102 reports,
7103 complete,
7104 ambiguous_triggered_child_ids,
7105 })
7106 }
7107
7108 pub async fn request_algo_order_status_report(
7114 &self,
7115 account_id: AccountId,
7116 instrument_id: InstrumentId,
7117 algo_client_order_id: ClientOrderId,
7118 ) -> anyhow::Result<Option<OrderStatusReport>> {
7119 let reports = self
7120 .request_algo_order_status_reports(
7121 account_id,
7122 None,
7123 Some(instrument_id),
7124 None,
7125 Some(algo_client_order_id),
7126 None,
7127 Some(50_u32),
7128 )
7129 .await?;
7130
7131 Ok(reports.into_iter().next())
7132 }
7133
7134 pub fn raw_client(&self) -> &Arc<OKXRawHttpClient> {
7136 &self.inner
7137 }
7138
7139 #[expect(clippy::too_many_arguments)]
7140 fn collect_algo_reports(
7141 &self,
7142 account_id: AccountId,
7143 orders: &[OKXOrderAlgo],
7144 from_pending_source: bool,
7145 instruments_cache: &mut AHashMap<Ustr, InstrumentAny>,
7146 ts_init: UnixNanos,
7147 start_ns: Option<UnixNanos>,
7148 end_ns: Option<UnixNanos>,
7149 seen: &mut AHashMap<(String, String), usize>,
7150 reports: &mut Vec<OrderStatusReport>,
7151 ambiguous_triggered_child_ids: &mut AHashSet<VenueOrderId>,
7152 ) -> anyhow::Result<bool> {
7153 let mut complete = true;
7154
7155 for order in orders {
7156 let key = (order.algo_id.clone(), order.algo_cl_ord_id.clone());
7157
7158 let has_ambiguous_children = order.ord_id_list.len() > 1
7159 || !order.sub_algo_id_list.is_empty()
7160 || matches!(
7161 order.ord_id_list.as_slice(),
7162 [child_order_id]
7163 if !order.ord_id.is_empty() && child_order_id != &order.ord_id
7164 );
7165
7166 if !from_pending_source
7169 && report_ts_outside_window(order.u_time, start_ns, end_ns)
7170 && !is_open_okx_algo(order.state)
7171 {
7172 continue;
7173 }
7174
7175 let instrument = if let Some(instrument) = instruments_cache.get(&order.inst_id) {
7176 instrument.clone()
7177 } else {
7178 match self.resolve_report_instrument(
7179 order.inst_id,
7180 order.inst_type,
7181 false,
7182 is_open_okx_algo(order.state),
7183 None,
7184 )? {
7185 InstrumentResolution::Found(instrument) => {
7186 instruments_cache.insert(order.inst_id, (*instrument).clone());
7187 *instrument
7188 }
7189 InstrumentResolution::Skip => continue,
7190 InstrumentResolution::Incomplete => {
7191 complete = false;
7192 continue;
7193 }
7194 }
7195 };
7196
7197 match parse_http_algo_order(order, account_id, &instrument, ts_init) {
7198 Ok(report) => {
7199 if has_ambiguous_children && report.order_status == OrderStatus::Triggered {
7200 log::warn!(
7201 "Algo order {} has ambiguous child order identifiers",
7202 order.algo_id,
7203 );
7204 ambiguous_triggered_child_ids.insert(report.venue_order_id);
7205 }
7206
7207 if let Some(index) = seen.get(&key).copied() {
7208 if is_order_status_report_more_advanced(&report, &reports[index]) {
7209 reports[index] = report;
7210 }
7211 } else {
7212 seen.insert(key, reports.len());
7213 reports.push(report);
7214 }
7215 }
7216 Err(e) => {
7217 log::warn!("Failed to parse algo order report: {e}");
7218 complete = false;
7219 }
7220 }
7221 }
7222
7223 Ok(complete)
7224 }
7225
7226 fn resolve_report_instrument(
7227 &self,
7228 symbol: Ustr,
7229 inst_type: OKXInstrumentType,
7230 is_spread: bool,
7231 is_open_or_position: bool,
7232 scope: Option<ReportInstrumentScope<'_>>,
7233 ) -> anyhow::Result<InstrumentResolution> {
7234 if let Ok(instrument) = self.instrument_from_cache(symbol) {
7235 return Ok(InstrumentResolution::Found(Box::new(instrument)));
7236 }
7237
7238 let in_scope = match scope {
7239 None => true,
7240 Some(scope) if is_spread => scope.load_spreads,
7241 Some(scope) => {
7242 scope.instrument_types.contains(&inst_type)
7243 || scope.instrument_types.contains(&OKXInstrumentType::Any)
7244 }
7245 };
7246
7247 if !in_scope {
7248 log::debug!("Skipping report for out-of-scope instrument: symbol={symbol}");
7249 return Ok(InstrumentResolution::Skip);
7250 }
7251
7252 if is_open_or_position {
7253 anyhow::bail!("Instrument {symbol} missing from cache");
7254 }
7255
7256 log::warn!("Instrument {symbol} missing from cache");
7257 Ok(InstrumentResolution::Incomplete)
7258 }
7259
7260 pub(crate) async fn request_spread_order_status_report(
7261 &self,
7262 account_id: AccountId,
7263 instrument_id: InstrumentId,
7264 client_order_id: Option<ClientOrderId>,
7265 venue_order_id: Option<VenueOrderId>,
7266 ) -> anyhow::Result<Option<OrderStatusReport>> {
7267 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
7268 let mut params_builder = GetSpreadOrderParamsBuilder::default();
7269
7270 match (client_order_id, venue_order_id) {
7271 (Some(client_order_id), None) => {
7272 params_builder.cl_ord_id(client_order_id.as_str().to_string());
7273 }
7274 (None, Some(venue_order_id)) => {
7275 params_builder.ord_id(venue_order_id.as_str().to_string());
7276 }
7277 _ => anyhow::bail!(
7278 "Exactly one of client_order_id or venue_order_id is required for a spread order detail request"
7279 ),
7280 }
7281
7282 let params = params_builder
7283 .build()
7284 .map_err(|e| anyhow::anyhow!("Failed to build spread order detail params: {e}"))?;
7285 let orders = match self.inner.get_spread_order(params).await {
7286 Ok(orders) => orders,
7287 Err(e) if e.is_order_not_found() => return Ok(None),
7288 Err(e) => return Err(e.into()),
7289 };
7290 let Some(order) = orders.into_iter().next() else {
7291 return Ok(None);
7292 };
7293 let ts_init = self.generate_ts_init();
7294 let report = parse_spread_order_status_report(
7295 &order,
7296 account_id,
7297 instrument.id(),
7298 instrument.price_precision(),
7299 instrument.size_precision(),
7300 ts_init,
7301 )?;
7302
7303 Ok(Some(report))
7304 }
7305}
7306
7307fn is_open_okx_order(state: OKXOrderStatus) -> bool {
7308 matches!(
7309 state,
7310 OKXOrderStatus::Live | OKXOrderStatus::PartiallyFilled
7311 )
7312}
7313
7314fn fill_quantity_is_positive(value: &str) -> Result<bool, rust_decimal::Error> {
7315 Ok(Decimal::from_str(value)? > Decimal::ZERO)
7316}
7317
7318fn report_ts_outside_window(
7319 timestamp_ms: u64,
7320 start_ns: Option<UnixNanos>,
7321 end_ns: Option<UnixNanos>,
7322) -> bool {
7323 if start_ns.is_none() && end_ns.is_none() {
7324 return false;
7325 }
7326
7327 let ts = parse_millisecond_timestamp(timestamp_ms);
7328 start_ns.is_some_and(|start| ts < start) || end_ns.is_some_and(|end| ts > end)
7329}
7330
7331fn is_open_okx_algo(state: OKXAlgoOrderStatus) -> bool {
7332 matches!(
7333 state,
7334 OKXAlgoOrderStatus::Live
7335 | OKXAlgoOrderStatus::Pause
7336 | OKXAlgoOrderStatus::OrderPlaced
7337 | OKXAlgoOrderStatus::PartiallyEffective
7338 )
7339}
7340
7341fn spread_page_limit(limit: Option<u32>) -> Option<u32> {
7342 limit.map(|limit| limit.min(OKX_PAGE_SIZE as u32))
7343}
7344
7345fn parse_http_algo_order(
7346 order: &OKXOrderAlgo,
7347 account_id: AccountId,
7348 instrument: &InstrumentAny,
7349 ts_init: UnixNanos,
7350) -> anyhow::Result<OrderStatusReport> {
7351 let ord_id = if order.ord_id.is_empty() {
7352 match order.ord_id_list.as_slice() {
7353 [child_order_id] => child_order_id.clone(),
7354 _ => String::new(),
7355 }
7356 } else {
7357 order.ord_id.clone()
7358 };
7359 let ord_px = if order.ord_px.is_empty() {
7360 "-1".to_string()
7361 } else {
7362 order.ord_px.clone()
7363 };
7364
7365 let reduce_only = if order.reduce_only.is_empty() {
7366 "false".to_string()
7367 } else {
7368 order.reduce_only.clone()
7369 };
7370
7371 let msg = OKXAlgoOrderMsg {
7372 algo_id: order.algo_id.clone(),
7373 algo_cl_ord_id: order.algo_cl_ord_id.clone(),
7374 cl_ord_id: order.cl_ord_id.clone(),
7375 ord_id,
7376 ord_id_list: order.ord_id_list.clone(),
7377 inst_id: order.inst_id,
7378 inst_type: order.inst_type,
7379 ord_type: order.ord_type,
7380 state: order.state,
7381 side: order.side,
7382 pos_side: order.pos_side,
7383 sz: order.sz.clone(),
7384 trigger_px: order.trigger_px.clone(),
7385 trigger_px_type: order.trigger_px_type.unwrap_or(OKXTriggerType::None),
7386 sl_trigger_px: order.sl_trigger_px.clone(),
7387 sl_ord_px: order.sl_ord_px.clone(),
7388 sl_trigger_px_type: order.sl_trigger_px_type.unwrap_or(OKXTriggerType::None),
7389 tp_trigger_px: order.tp_trigger_px.clone(),
7390 tp_ord_px: order.tp_ord_px.clone(),
7391 tp_trigger_px_type: order.tp_trigger_px_type.unwrap_or(OKXTriggerType::None),
7392 ord_px,
7393 td_mode: order.td_mode,
7394 lever: order.lever.clone(),
7395 reduce_only,
7396 close_fraction: order.close_fraction.clone(),
7397 actual_px: order.actual_px.clone(),
7398 actual_sz: order.actual_sz.clone(),
7399 notional_usd: order.notional_usd.clone(),
7400 c_time: order.c_time,
7401 u_time: order.u_time,
7402 trigger_time: order.trigger_time.clone(),
7403 fail_code: String::new(),
7404 tag: order.tag.clone(),
7405 callback_ratio: order.callback_ratio.clone(),
7406 callback_spread: order.callback_spread.clone(),
7407 active_px: order.active_px.clone(),
7408 ccy: None,
7409 tgt_ccy: None,
7410 fee: None,
7411 fee_ccy: None,
7412 advance_ord_type: None,
7413 };
7414
7415 parse_algo_order_status_report(&msg, instrument, account_id, ts_init)
7416}