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