1use std::{
37 collections::HashMap,
38 fmt::Debug,
39 num::NonZeroU32,
40 str::FromStr,
41 sync::{
42 Arc, LazyLock,
43 atomic::{AtomicBool, Ordering},
44 },
45};
46
47use ahash::{AHashMap, AHashSet};
48use anyhow::Context;
49use jiff::Timestamp;
50use nautilus_common::cache::InstrumentLookupError;
51use nautilus_core::{
52 AtomicMap, AtomicTime, UnixNanos, consts::NAUTILUS_USER_AGENT,
53 datetime::NANOSECONDS_IN_MILLISECOND, env::get_or_env_var, string::secret::REDACTED,
54 time::get_atomic_clock_realtime,
55};
56use nautilus_model::{
57 data::{
58 Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
59 OrderBookDelta, OrderBookDeltas, TradeTick, forward::ForwardPrice,
60 },
61 enums::{
62 AggregationSource, BarAggregation, BookAction, BookType, OrderSide, OrderStatus, OrderType,
63 PositionSide, RecordFlag, TimeInForce, TriggerType,
64 },
65 events::AccountState,
66 identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
67 instruments::{Instrument, InstrumentAny},
68 orderbook::OrderBook,
69 reports::{FillReport, OrderStatusReport, PositionStatusReport},
70 types::{Price, Quantity},
71};
72use nautilus_network::{
73 http::{HttpClient, Method, StatusCode, USER_AGENT},
74 ratelimiter::quota::Quota,
75 retry::{RetryConfig, RetryError, RetryManager},
76};
77use rust_decimal::Decimal;
78use serde::{Deserialize, Serialize, de::DeserializeOwned};
79use thiserror::Error;
80use tokio_util::sync::CancellationToken;
81use ustr::Ustr;
82
83use super::{
84 error::OKXHttpError,
85 models::{
86 OKXAccount, OKXAmendAlgoOrderRequest, OKXAmendAlgoOrderResponse, OKXAmendOrderRequest,
87 OKXAttachAlgoOrdRequest, OKXCancelAlgoOrderRequest, OKXCancelAlgoOrderResponse,
88 OKXCancelAllSpreadOrdersRequest, OKXCancelOrderRequest, OKXCancelOrderResponse,
89 OKXCancelSpreadOrderRequest, OKXEventContractEvent, OKXEventContractMarket,
90 OKXEventContractSeries, OKXFeeRate, OKXFundingRateHistory, OKXIndexTicker, OKXMarkPrice,
91 OKXOptionSummary, OKXOrderAlgo, OKXOrderBookSnapshot, OKXOrderHistory,
92 OKXPlaceAlgoOrderRequest, OKXPlaceAlgoOrderResponse, OKXPlaceOrderRequest,
93 OKXPlaceOrderResponse, OKXPlaceSpreadOrderRequest, OKXPosition, OKXPositionHistory,
94 OKXPositionTier, OKXPriceLimit, OKXRpiOrderBookSnapshot, OKXServerTime, OKXSpread,
95 OKXSpreadOrder, OKXSpreadTrade, OKXTransactionDetail,
96 },
97 query::{
98 GetAlgoOrderParams, GetAlgoOrderParamsBuilder, GetAlgoOrdersParams,
99 GetAlgoOrdersParamsBuilder, GetCandlesticksParams, GetCandlesticksParamsBuilder,
100 GetEventContractEventsParams, GetEventContractMarketsParams, GetEventContractSeriesParams,
101 GetFundingRateHistoryParams, GetIndexTickerParams, GetIndexTickerParamsBuilder,
102 GetInstrumentsParams, GetInstrumentsParamsBuilder, GetMarkPriceParams,
103 GetMarkPriceParamsBuilder, GetOptionSummaryParams, GetOrderBookParams,
104 GetOrderHistoryParams, GetOrderHistoryParamsBuilder, GetOrderListParams,
105 GetOrderListParamsBuilder, GetOrderParams, GetOrderParamsBuilder, GetPositionTiersParams,
106 GetPositionsHistoryParams, GetPositionsParams, GetPositionsParamsBuilder,
107 GetPriceLimitParams, GetPriceLimitParamsBuilder, GetRpiOrderBookParams,
108 GetSpreadOrderParams, GetSpreadOrderParamsBuilder, GetSpreadOrdersParams,
109 GetSpreadOrdersParamsBuilder, GetSpreadTradesParams, GetSpreadTradesParamsBuilder,
110 GetSpreadsParams, GetTradeFeeParams, GetTradesParams, GetTradesParamsBuilder,
111 GetTransactionDetailsParams, GetTransactionDetailsParamsBuilder, SetPositionModeParams,
112 SetPositionModeParamsBuilder,
113 },
114};
115use crate::{
116 common::{
117 consts::{
118 OKX_FIELD_SCODE, OKX_FIELD_SMSG, OKX_HTTP_URL, OKX_NAUTILUS_BROKER_ID,
119 OKX_POST_ONLY_CANCEL_REASON, OKX_POST_ONLY_CANCEL_SOURCE, OKX_SUPPORTED_ORDER_TYPES,
120 OKX_SUPPORTED_TIME_IN_FORCE,
121 },
122 credential::Credential,
123 enums::{
124 OKXAlgoOrderStatus, OKXAlgoOrderType, OKXContractType, OKXEnvironment,
125 OKXInstrumentStatus, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionMode,
126 OKXPositionSide, OKXSide, OKXTargetCurrency, OKXTradeMode, OKXTriggerType,
127 conditional_order_to_algo_type,
128 },
129 models::OKXInstrument,
130 parse::{
131 extract_inst_family, is_okx_spread_symbol, is_order_status_report_more_advanced,
132 okx_instrument_type, okx_instrument_type_from_symbol, parse_account_state,
133 parse_base_quote_from_symbol, parse_candlestick, parse_fill_report, parse_funding_rate,
134 parse_index_price_update, parse_instrument_any, parse_instrument_id,
135 parse_mark_price_update, parse_millisecond_timestamp, parse_order_status_report,
136 parse_position_status_report, parse_price, parse_quantity,
137 parse_spot_margin_position_from_balance, parse_spread_fill_report,
138 parse_spread_instrument, parse_spread_order_status_report, parse_trade_tick,
139 prefer_rpi_response_fields,
140 },
141 },
142 http::models::{OKXCandlestick, OKXTrade},
143 websocket::{messages::OKXAlgoOrderMsg, parse::parse_algo_order_status_report},
144};
145
146const OKX_SUCCESS_CODE: &str = "0";
147const OKX_PARTIAL_SUCCESS_CODE: &str = "2";
148
149#[derive(Debug, Error)]
150#[error("Failed to parse instrument {symbol}: {source}")]
151pub(crate) struct OKXInstrumentDefinitionError {
152 symbol: String,
153 #[source]
154 source: anyhow::Error,
155}
156
157impl OKXInstrumentDefinitionError {
158 fn new(symbol: &str, source: anyhow::Error) -> Self {
159 Self {
160 symbol: symbol.to_string(),
161 source,
162 }
163 }
164}
165
166fn spot_quote_priority(symbol: &str) -> u8 {
171 symbol.rsplit_once('-').map_or(4, |(_, quote)| match quote {
172 "USDT" => 0,
173 "USDC" => 1,
174 "USD" => 2,
175 _ => 3,
176 })
177}
178
179fn resolve_okx_error_code(response_body: &[u8], envelope_code: &str) -> String {
180 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(response_body)
181 && let Some(s_code) = payload
182 .get("data")
183 .and_then(serde_json::Value::as_array)
184 .and_then(|items| items.first())
185 .and_then(|item| item.get(OKX_FIELD_SCODE))
186 .and_then(serde_json::Value::as_str)
187 {
188 let s_code = s_code.trim();
189 if !s_code.is_empty() {
190 return s_code.to_string();
191 }
192 }
193
194 envelope_code.to_string()
195}
196
197fn resolve_okx_error_message(response_body: &[u8], top_level_msg: &str) -> String {
198 let message = top_level_msg.trim();
199 let is_generic_top_level = message.eq_ignore_ascii_case("All operations failed");
200 if !message.is_empty() && !is_generic_top_level {
201 return message.to_string();
202 }
203
204 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(response_body)
205 && let Some(first_item) = payload
206 .get("data")
207 .and_then(serde_json::Value::as_array)
208 .and_then(|items| items.first())
209 {
210 if let Some(s_msg) = first_item
211 .get(OKX_FIELD_SMSG)
212 .and_then(serde_json::Value::as_str)
213 {
214 let s_msg = s_msg.trim();
215 if !s_msg.is_empty() {
216 return s_msg.to_string();
217 }
218 }
219
220 if let Some(s_code) = first_item
221 .get(OKX_FIELD_SCODE)
222 .and_then(serde_json::Value::as_str)
223 {
224 let s_code = s_code.trim();
225 if !s_code.is_empty() {
226 return s_code.to_string();
227 }
228 }
229 }
230
231 String::new()
232}
233
234fn deserialize_okx_response<T: DeserializeOwned>(
235 response_body: &[u8],
236) -> Result<OKXResponse<T>, serde_json::Error> {
237 let contains_legacy_rpi_name = [br#""elp""#.as_slice(), br#""elpMaker""#.as_slice()]
238 .iter()
239 .any(|name| {
240 response_body
241 .windows(name.len())
242 .any(|window| window == *name)
243 });
244
245 if !contains_legacy_rpi_name {
246 return serde_json::from_slice(response_body);
247 }
248
249 let mut value: serde_json::Value = serde_json::from_slice(response_body)?;
250 prefer_rpi_response_fields(&mut value);
251 serde_json::from_value(value)
252}
253
254#[cfg(test)]
255mod tests {
256 use anyhow::Context;
257 use rstest::rstest;
258 use rust_decimal::Decimal;
259
260 use super::{
261 OKXInstrumentDefinitionError, deserialize_okx_response, resolve_okx_error_code,
262 resolve_okx_error_message,
263 };
264 use crate::http::models::OKXFeeRate;
265
266 #[rstest]
267 fn test_instrument_definition_error_survives_anyhow_context_downcast() {
268 let result: anyhow::Result<()> = Err(OKXInstrumentDefinitionError::new(
269 "USDG-SGD",
270 anyhow::anyhow!("`tick_sz` is empty"),
271 )
272 .into());
273
274 let err = result.context("fetch instrument from API").unwrap_err();
275
276 assert!(err.downcast_ref::<OKXInstrumentDefinitionError>().is_some());
277 }
278
279 #[rstest]
280 fn test_resolve_okx_error_message_prefers_detailed_s_msg_over_generic_top_level() {
281 let body = br#"{
282 "code": "1",
283 "msg": "All operations failed",
284 "data": [
285 {
286 "sCode": "51046",
287 "sMsg": "Test detailed failure"
288 }
289 ]
290 }"#;
291
292 assert_eq!(
293 resolve_okx_error_message(body, "All operations failed"),
294 "Test detailed failure",
295 );
296 }
297
298 #[rstest]
299 fn test_resolve_okx_error_code_prefers_item_s_code_over_envelope() {
300 let body = br#"{
301 "code": "1",
302 "msg": "All operations failed",
303 "data": [
304 {
305 "sCode": "50013",
306 "sMsg": "System busy, please retry later"
307 }
308 ]
309 }"#;
310
311 assert_eq!(resolve_okx_error_code(body, "1"), "50013");
312 }
313
314 #[rstest]
315 fn test_rpi_response_fields_prefer_current_names_over_transition_aliases() {
316 let response = br#"{
317 "code": "0",
318 "msg": "",
319 "data": [{
320 "level": "VIP1",
321 "taker": "-0.0005",
322 "maker": "-0.0002",
323 "takerU": "-0.0005",
324 "makerU": "-0.0002",
325 "rpiMaker": "-0.00015",
326 "elpMaker": "-0.00016",
327 "instType": "SPOT",
328 "category": "1",
329 "ts": "1785406500000"
330 }]
331 }"#;
332
333 let parsed = deserialize_okx_response::<OKXFeeRate>(response).unwrap();
334
335 assert_eq!(parsed.data.len(), 1);
336 assert_eq!(parsed.data[0].rpi_maker, Some(Decimal::new(-15, 5)));
337 }
338
339 #[rstest]
340 #[case("BTC-USD")]
341 #[case("BTC-USD-241217")]
342 #[case("BTC-USD-241217-92000")]
343 fn test_option_summary_expiry_key_rejects_short_symbol(#[case] symbol: &str) {
344 let result = super::OKXHttpClient::option_summary_expiry_key(symbol);
345 assert!(result.is_err());
346 let err = result.unwrap_err().to_string();
347 assert!(
348 err.contains("Expected OKX option symbol with expiry"),
349 "unexpected error: {err}"
350 );
351 }
352
353 #[rstest]
354 fn test_option_summary_expiry_key_extracts_base_quote_expiry() {
355 let result =
356 super::OKXHttpClient::option_summary_expiry_key("BTC-USD-241217-92000-C").unwrap();
357 assert_eq!(result, "BTC-USD-241217");
358 }
359
360 #[rstest]
361 #[case("BTC-USD")]
362 #[case("BTC-USD-241217")]
363 #[case("BTC-USD-241217-92000")]
364 fn test_option_summary_exp_time_rejects_short_symbol(#[case] symbol: &str) {
365 let result = super::OKXHttpClient::option_summary_exp_time(symbol);
366 assert!(result.is_err());
367 let err = result.unwrap_err().to_string();
368 assert!(
369 err.contains("Expected OKX option symbol with expiry"),
370 "unexpected error: {err}"
371 );
372 }
373
374 #[rstest]
375 fn test_option_summary_exp_time_extracts_expiry() {
376 let result =
377 super::OKXHttpClient::option_summary_exp_time("BTC-USD-241217-92000-C").unwrap();
378 assert_eq!(result, Some("241217".to_string()));
379 }
380}
381
382pub static OKX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
391 Quota::per_second(NonZeroU32::new(250).expect("non-zero")).expect("valid constant")
392});
393
394const OKX_GLOBAL_RATE_KEY: &str = "okx:global";
395
396const OKX_PAGE_SIZE: usize = 100;
398
399const MAX_RECONCILIATION_PAGES: usize = 50;
401
402#[derive(Clone, Copy, Debug)]
403pub(crate) struct ReportInstrumentScope<'a> {
404 pub instrument_types: &'a [OKXInstrumentType],
405 pub load_spreads: bool,
406}
407
408#[derive(Debug)]
409pub(crate) struct ReportSweep<T> {
410 pub reports: Vec<T>,
411 pub complete: bool,
412}
413
414#[derive(Clone, Copy)]
415pub(crate) enum FillHistory {
416 Recent,
417 Extended,
418}
419
420#[derive(Debug)]
421pub(crate) struct AlgoOrderReportSweep {
422 pub reports: Vec<OrderStatusReport>,
423 pub complete: bool,
424 pub ambiguous_triggered_child_ids: AHashSet<VenueOrderId>,
425}
426
427struct PageSweep<T> {
428 items: Vec<T>,
429 complete: bool,
430}
431
432impl<T> PageSweep<T> {
433 fn from_pages(items: Vec<T>, exhausted: bool) -> Self {
434 Self {
435 complete: !(exhausted && !items.is_empty()),
436 items,
437 }
438 }
439}
440
441enum InstrumentResolution {
442 Found(Box<InstrumentAny>),
443 Skip,
444 Incomplete,
445}
446
447#[derive(Debug, Serialize, Deserialize)]
449pub struct OKXResponse<T> {
450 pub code: String,
452 pub msg: String,
454 pub data: Vec<T>,
456}
457
458pub struct OKXRawHttpClient {
464 base_url: String,
465 client: HttpClient,
466 credential: Option<Credential>,
467 retry_manager: RetryManager<OKXHttpError>,
468 cancellation_token: CancellationToken,
469 environment: OKXEnvironment,
470}
471
472impl Default for OKXRawHttpClient {
473 fn default() -> Self {
474 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
475 .expect("Failed to create default OKXRawHttpClient")
476 }
477}
478
479impl Debug for OKXRawHttpClient {
480 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481 let credential = self.credential.as_ref().map(|_| REDACTED);
482 f.debug_struct(stringify!(OKXRawHttpClient))
483 .field("base_url", &self.base_url)
484 .field("credential", &credential)
485 .finish_non_exhaustive()
486 }
487}
488
489impl OKXRawHttpClient {
490 fn rate_limiter_quotas() -> Vec<(String, Quota)> {
491 vec![
492 (OKX_GLOBAL_RATE_KEY.to_string(), *OKX_REST_QUOTA),
493 (
494 "okx:/api/v5/account/set-position-mode".to_string(),
495 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
496 ),
497 (
498 "okx:/api/v5/account/balance".to_string(),
499 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
500 ),
501 (
502 "okx:/api/v5/account/trade-fee".to_string(),
503 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
504 ),
505 (
506 "okx:/api/v5/account/instruments".to_string(),
507 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
508 ),
509 (
510 "okx:/api/v5/account/positions".to_string(),
511 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
512 ),
513 (
514 "okx:/api/v5/account/positions-history".to_string(),
515 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
516 ),
517 (
518 "okx:/api/v5/public/instruments".to_string(),
519 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
520 ),
521 (
522 "okx:/api/v5/public/position-tiers".to_string(),
523 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
524 ),
525 (
526 "okx:/api/v5/public/event-contract/series".to_string(),
527 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
528 ),
529 (
530 "okx:/api/v5/public/event-contract/events".to_string(),
531 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
532 ),
533 (
534 "okx:/api/v5/public/event-contract/markets".to_string(),
535 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
536 ),
537 (
538 "okx:/api/v5/public/opt-summary".to_string(),
539 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
540 ),
541 (
542 "okx:/api/v5/public/time".to_string(),
543 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
544 ),
545 (
546 "okx:/api/v5/public/mark-price".to_string(),
547 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
548 ),
549 (
550 "okx:/api/v5/public/price-limit".to_string(),
551 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
552 ),
553 (
554 "okx:/api/v5/sprd/spreads".to_string(),
555 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
556 ),
557 (
558 "okx:/api/v5/sprd/order".to_string(),
559 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
560 ),
561 (
562 "okx:/api/v5/sprd/cancel-order".to_string(),
563 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
564 ),
565 (
566 "okx:/api/v5/sprd/mass-cancel".to_string(),
567 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
568 ),
569 (
570 "okx:/api/v5/sprd/orders-pending".to_string(),
571 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
572 ),
573 (
574 "okx:/api/v5/sprd/orders-history".to_string(),
575 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
576 ),
577 (
578 "okx:/api/v5/sprd/trades".to_string(),
579 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
580 ),
581 (
582 "okx:/api/v5/market/index-tickers".to_string(),
583 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
584 ),
585 (
586 "okx:/api/v5/market/books".to_string(),
587 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
588 ),
589 (
590 "okx:/api/v5/market/books-rpi".to_string(),
591 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
592 ),
593 (
594 "okx:/api/v5/market/candles".to_string(),
595 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
596 ),
597 (
598 "okx:/api/v5/market/history-candles".to_string(),
599 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
600 ),
601 (
602 "okx:/api/v5/market/history-trades".to_string(),
603 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
604 ),
605 (
606 "okx:/api/v5/public/funding-rate-history".to_string(),
607 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
608 ),
609 (
610 "okx:/api/v5/trade/order".to_string(),
611 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
612 ),
613 (
614 "okx:/api/v5/trade/batch-orders".to_string(),
615 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
616 ),
617 (
618 "okx:/api/v5/trade/amend-order".to_string(),
619 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
620 ),
621 (
622 "okx:/api/v5/trade/amend-batch-orders".to_string(),
623 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
624 ),
625 (
626 "okx:/api/v5/trade/cancel-batch-orders".to_string(),
627 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
628 ),
629 (
630 "okx:/api/v5/trade/orders-pending".to_string(),
631 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
632 ),
633 (
634 "okx:/api/v5/trade/orders-history".to_string(),
635 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
636 ),
637 (
638 "okx:/api/v5/trade/fills".to_string(),
639 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
640 ),
641 (
642 "okx:/api/v5/trade/fills-history".to_string(),
643 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
644 ),
645 (
646 "okx:/api/v5/trade/order-algo".to_string(),
647 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
648 ),
649 (
650 "okx:/api/v5/trade/cancel-algos".to_string(),
651 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
652 ),
653 (
654 "okx:/api/v5/trade/cancel-advance-algos".to_string(),
655 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
656 ),
657 (
658 "okx:/api/v5/trade/amend-algos".to_string(),
659 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
660 ),
661 (
662 "okx:/api/v5/trade/orders-algo-pending".to_string(),
663 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
664 ),
665 (
666 "okx:/api/v5/trade/orders-algo-history".to_string(),
667 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
668 ),
669 ]
670 }
671
672 fn rate_limit_keys(endpoint: &str) -> Vec<Ustr> {
673 let normalized = endpoint.split('?').next().unwrap_or(endpoint);
674 let route = format!("okx:{normalized}");
675
676 vec![Ustr::from(OKX_GLOBAL_RATE_KEY), Ustr::from(route.as_str())]
677 }
678
679 pub fn cancel_all_requests(&self) {
681 self.cancellation_token.cancel();
682 }
683
684 pub fn cancellation_token(&self) -> &CancellationToken {
686 &self.cancellation_token
687 }
688
689 pub fn new(
699 base_url: Option<String>,
700 timeout_secs: u64,
701 max_retries: u32,
702 retry_delay_ms: u64,
703 retry_delay_max_ms: u64,
704 environment: OKXEnvironment,
705 proxy_url: Option<String>,
706 ) -> Result<Self, OKXHttpError> {
707 let retry_config = RetryConfig {
708 max_retries,
709 initial_delay_ms: retry_delay_ms,
710 max_delay_ms: retry_delay_max_ms,
711 backoff_factor: 2.0,
712 jitter_ms: 1000,
713 operation_timeout_ms: Some(60_000),
714 immediate_first: false,
715 max_elapsed_ms: Some(180_000),
716 };
717
718 let retry_manager = RetryManager::new(retry_config);
719
720 Ok(Self {
721 base_url: base_url.unwrap_or(OKX_HTTP_URL.to_string()),
722 client: HttpClient::builder()
723 .headers(Self::default_headers(environment))
724 .keyed_quotas(Self::rate_limiter_quotas())
725 .default_quota(*OKX_REST_QUOTA)
726 .timeout_secs(timeout_secs)
727 .maybe_proxy_url(proxy_url)
728 .build()
729 .map_err(|e| {
730 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
731 })?,
732 credential: None,
733 retry_manager,
734 cancellation_token: CancellationToken::new(),
735 environment,
736 })
737 }
738
739 #[expect(clippy::too_many_arguments)]
746 pub fn with_credentials(
747 api_key: String,
748 api_secret: String,
749 api_passphrase: String,
750 base_url: String,
751 timeout_secs: u64,
752 max_retries: u32,
753 retry_delay_ms: u64,
754 retry_delay_max_ms: u64,
755 environment: OKXEnvironment,
756 proxy_url: Option<String>,
757 ) -> Result<Self, OKXHttpError> {
758 let retry_config = RetryConfig {
759 max_retries,
760 initial_delay_ms: retry_delay_ms,
761 max_delay_ms: retry_delay_max_ms,
762 backoff_factor: 2.0,
763 jitter_ms: 1000,
764 operation_timeout_ms: Some(60_000),
765 immediate_first: false,
766 max_elapsed_ms: Some(180_000),
767 };
768
769 let retry_manager = RetryManager::new(retry_config);
770
771 Ok(Self {
772 base_url,
773 client: HttpClient::builder()
774 .headers(Self::default_headers(environment))
775 .keyed_quotas(Self::rate_limiter_quotas())
776 .default_quota(*OKX_REST_QUOTA)
777 .timeout_secs(timeout_secs)
778 .maybe_proxy_url(proxy_url)
779 .build()
780 .map_err(|e| {
781 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
782 })?,
783 credential: Some(Credential::new(api_key, api_secret, api_passphrase)),
784 retry_manager,
785 cancellation_token: CancellationToken::new(),
786 environment,
787 })
788 }
789
790 fn default_headers(environment: OKXEnvironment) -> HashMap<String, String> {
792 let mut headers =
793 HashMap::from([(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())]);
794
795 if environment == OKXEnvironment::Demo {
796 headers.insert("x-simulated-trading".to_string(), "1".to_string());
797 }
798
799 headers
800 }
801
802 fn sign_request(
809 &self,
810 method: &Method,
811 path: &str,
812 body: Option<&[u8]>,
813 ) -> Result<HashMap<String, String>, OKXHttpError> {
814 let credential = match self.credential.as_ref() {
815 Some(c) => c,
816 None => return Err(OKXHttpError::MissingCredentials),
817 };
818
819 let api_key = credential.api_key().to_string();
820 let api_passphrase = credential.api_passphrase().to_string();
821
822 let now = Timestamp::now();
824 let timestamp = format!("{now:.3}");
825 let signature = credential.sign_bytes(×tamp, method.as_str(), path, body);
826
827 let mut headers = HashMap::new();
828 headers.insert("OK-ACCESS-KEY".to_string(), api_key);
829 headers.insert("OK-ACCESS-PASSPHRASE".to_string(), api_passphrase);
830 headers.insert("OK-ACCESS-TIMESTAMP".to_string(), timestamp);
831 headers.insert("OK-ACCESS-SIGN".to_string(), signature);
832
833 Ok(headers)
834 }
835
836 async fn send_request<T: DeserializeOwned, P: Serialize>(
852 &self,
853 method: Method,
854 path: &str,
855 params: Option<&P>,
856 body: Option<Vec<u8>>,
857 authenticate: bool,
858 ) -> Result<Vec<T>, OKXHttpError> {
859 let url = format!("{}{path}", self.base_url);
860 let accepts_partial_success = matches!(
861 path,
862 "/api/v5/trade/batch-orders"
863 | "/api/v5/trade/amend-batch-orders"
864 | "/api/v5/trade/cancel-batch-orders"
865 );
866
867 let rate_keys: Vec<String> = Self::rate_limit_keys(path)
869 .into_iter()
870 .map(|k| k.to_string())
871 .collect();
872
873 let operation = || {
874 let url = url.clone();
875 let method = method.clone();
876 let body = body.clone();
877 let rate_keys = rate_keys.clone();
878
879 async move {
880 let query_string = if let Some(p) = params {
882 serde_urlencoded::to_string(p).map_err(|e| {
883 OKXHttpError::JsonError(format!("Failed to serialize params: {e}"))
884 })?
885 } else {
886 String::new()
887 };
888
889 let full_path = if query_string.is_empty() {
891 path.to_string()
892 } else {
893 format!("{path}?{query_string}")
894 };
895
896 let mut headers = if authenticate {
897 self.sign_request(&method, &full_path, body.as_deref())?
898 } else {
899 HashMap::new()
900 };
901
902 if body.is_some() {
904 headers.insert("Content-Type".to_string(), "application/json".to_string());
905 }
906
907 let resp = self
908 .client
909 .request_with_params(
910 method.clone(),
911 url,
912 params,
913 Some(headers),
914 body,
915 None,
916 Some(rate_keys),
917 )
918 .await?;
919
920 log::trace!("Response: {resp:?}");
921
922 if resp.status.is_success() {
923 let okx_response: OKXResponse<T> = deserialize_okx_response(&resp.body)
924 .map_err(|e| {
925 log::warn!("Failed to deserialize OKXResponse: {e}");
926 OKXHttpError::JsonError(e.to_string())
927 })?;
928
929 if okx_response.code != OKX_SUCCESS_CODE
930 && !(accepts_partial_success
931 && okx_response.code == OKX_PARTIAL_SUCCESS_CODE)
932 {
933 return Err(OKXHttpError::OkxError {
934 error_code: resolve_okx_error_code(&resp.body, &okx_response.code),
935 message: resolve_okx_error_message(&resp.body, &okx_response.msg),
936 });
937 }
938
939 Ok(okx_response.data)
940 } else {
941 let error_body = String::from_utf8_lossy(&resp.body);
942 if resp.status.as_u16() == StatusCode::NOT_FOUND.as_u16() {
943 log::debug!("HTTP 404 with body: {error_body}");
944 } else {
945 log::warn!(
946 "HTTP error {} with body: {error_body}",
947 resp.status.as_str()
948 );
949 }
950
951 if let Ok(parsed_error) = deserialize_okx_response::<T>(&resp.body) {
952 return Err(OKXHttpError::OkxError {
953 error_code: resolve_okx_error_code(&resp.body, &parsed_error.code),
954 message: resolve_okx_error_message(&resp.body, &parsed_error.msg),
955 });
956 }
957
958 Err(OKXHttpError::UnexpectedStatus {
959 status: StatusCode::from_u16(resp.status.as_u16())
962 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
963 body: error_body.to_string(),
964 })
965 }
966 }
967 };
968
969 let is_order_submit = method == Method::POST
984 && matches!(
985 path,
986 "/api/v5/trade/order"
987 | "/api/v5/trade/batch-orders"
988 | "/api/v5/trade/order-algo"
989 | "/api/v5/sprd/order"
990 );
991 let should_retry = |error: &OKXHttpError| !is_order_submit && error.is_retryable();
992
993 let create_error = |error: RetryError| -> OKXHttpError {
994 match error {
995 RetryError::Canceled => {
996 OKXHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
997 }
998 RetryError::OperationTimeout { timeout_ms } => {
999 OKXHttpError::OperationTimeout { timeout_ms }
1000 }
1001 RetryError::InvalidConfiguration { message } => {
1002 OKXHttpError::ValidationError(message)
1003 }
1004 error @ RetryError::ElapsedBudgetExceeded { .. } => {
1005 OKXHttpError::RetryBudgetExceeded(error.to_string())
1006 }
1007 }
1008 };
1009
1010 let result = self
1011 .retry_manager
1012 .execute_with_retry_with_cancel(
1013 path,
1014 operation,
1015 should_retry,
1016 create_error,
1017 &self.cancellation_token,
1018 )
1019 .await;
1020
1021 if let Err(ref e) = result
1022 && e.is_retryable()
1023 {
1024 log::error!("Request exhausted retries: path={path}, error={e}");
1025 }
1026
1027 result
1028 }
1029
1030 pub async fn set_position_mode(
1041 &self,
1042 params: SetPositionModeParams,
1043 ) -> Result<Vec<serde_json::Value>, OKXHttpError> {
1044 let path = "/api/v5/account/set-position-mode";
1045 let body = serde_json::to_vec(¶ms)?;
1046 self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
1047 .await
1048 }
1049
1050 pub async fn get_position_tiers(
1061 &self,
1062 params: GetPositionTiersParams,
1063 ) -> Result<Vec<OKXPositionTier>, OKXHttpError> {
1064 self.send_request(
1065 Method::GET,
1066 "/api/v5/public/position-tiers",
1067 Some(¶ms),
1068 None,
1069 false,
1070 )
1071 .await
1072 }
1073
1074 pub async fn get_instruments(
1085 &self,
1086 params: GetInstrumentsParams,
1087 ) -> Result<Vec<OKXInstrument>, OKXHttpError> {
1088 self.send_request(
1089 Method::GET,
1090 "/api/v5/public/instruments",
1091 Some(¶ms),
1092 None,
1093 false,
1094 )
1095 .await
1096 }
1097
1098 pub async fn get_account_instruments(
1109 &self,
1110 params: GetInstrumentsParams,
1111 ) -> Result<Vec<OKXInstrument>, OKXHttpError> {
1112 self.send_request(
1113 Method::GET,
1114 "/api/v5/account/instruments",
1115 Some(¶ms),
1116 None,
1117 true,
1118 )
1119 .await
1120 }
1121
1122 pub async fn get_spreads(
1133 &self,
1134 params: GetSpreadsParams,
1135 ) -> Result<Vec<OKXSpread>, OKXHttpError> {
1136 self.send_request(
1137 Method::GET,
1138 "/api/v5/sprd/spreads",
1139 Some(¶ms),
1140 None,
1141 false,
1142 )
1143 .await
1144 }
1145
1146 pub async fn place_spread_order(
1156 &self,
1157 request: OKXPlaceSpreadOrderRequest,
1158 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
1159 let body =
1160 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
1161
1162 self.send_request(
1163 Method::POST,
1164 "/api/v5/sprd/order",
1165 None::<&()>,
1166 Some(body),
1167 true,
1168 )
1169 .await
1170 }
1171
1172 pub async fn cancel_spread_order(
1182 &self,
1183 request: OKXCancelSpreadOrderRequest,
1184 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
1185 let body =
1186 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
1187
1188 self.send_request(
1189 Method::POST,
1190 "/api/v5/sprd/cancel-order",
1191 None::<&()>,
1192 Some(body),
1193 true,
1194 )
1195 .await
1196 }
1197
1198 pub async fn cancel_all_spread_orders(
1208 &self,
1209 request: OKXCancelAllSpreadOrdersRequest,
1210 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
1211 let body =
1212 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
1213
1214 self.send_request(
1215 Method::POST,
1216 "/api/v5/sprd/mass-cancel",
1217 None::<&()>,
1218 Some(body),
1219 true,
1220 )
1221 .await
1222 }
1223
1224 pub async fn get_spread_order(
1234 &self,
1235 params: GetSpreadOrderParams,
1236 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1237 self.send_request(Method::GET, "/api/v5/sprd/order", Some(¶ms), None, true)
1238 .await
1239 }
1240
1241 pub async fn get_spread_orders_pending(
1251 &self,
1252 params: GetSpreadOrdersParams,
1253 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1254 self.send_request(
1255 Method::GET,
1256 "/api/v5/sprd/orders-pending",
1257 Some(¶ms),
1258 None,
1259 true,
1260 )
1261 .await
1262 }
1263
1264 pub async fn get_spread_orders_history(
1274 &self,
1275 params: GetSpreadOrdersParams,
1276 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1277 self.send_request(
1278 Method::GET,
1279 "/api/v5/sprd/orders-history",
1280 Some(¶ms),
1281 None,
1282 true,
1283 )
1284 .await
1285 }
1286
1287 pub async fn get_spread_trades(
1297 &self,
1298 params: GetSpreadTradesParams,
1299 ) -> Result<Vec<OKXSpreadTrade>, OKXHttpError> {
1300 self.send_request(
1301 Method::GET,
1302 "/api/v5/sprd/trades",
1303 Some(¶ms),
1304 None,
1305 true,
1306 )
1307 .await
1308 }
1309
1310 pub async fn get_event_contract_series(
1320 &self,
1321 params: GetEventContractSeriesParams,
1322 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
1323 self.send_request(
1324 Method::GET,
1325 "/api/v5/public/event-contract/series",
1326 Some(¶ms),
1327 None,
1328 false,
1329 )
1330 .await
1331 }
1332
1333 pub async fn get_event_contract_events(
1343 &self,
1344 params: GetEventContractEventsParams,
1345 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
1346 self.send_request(
1347 Method::GET,
1348 "/api/v5/public/event-contract/events",
1349 Some(¶ms),
1350 None,
1351 false,
1352 )
1353 .await
1354 }
1355
1356 pub async fn get_event_contract_markets(
1366 &self,
1367 params: GetEventContractMarketsParams,
1368 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
1369 self.send_request(
1370 Method::GET,
1371 "/api/v5/public/event-contract/markets",
1372 Some(¶ms),
1373 None,
1374 false,
1375 )
1376 .await
1377 }
1378
1379 pub async fn get_option_summary(
1389 &self,
1390 params: GetOptionSummaryParams,
1391 ) -> Result<Vec<OKXOptionSummary>, OKXHttpError> {
1392 self.send_request(
1393 Method::GET,
1394 "/api/v5/public/opt-summary",
1395 Some(¶ms),
1396 None,
1397 false,
1398 )
1399 .await
1400 }
1401
1402 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
1416 let response: Vec<OKXServerTime> = self
1417 .send_request::<_, ()>(Method::GET, "/api/v5/public/time", None, None, false)
1418 .await?;
1419 response
1420 .first()
1421 .map(|t| t.ts)
1422 .ok_or_else(|| OKXHttpError::JsonError("Empty server time response".to_string()))
1423 }
1424
1425 pub async fn get_mark_price(
1439 &self,
1440 params: GetMarkPriceParams,
1441 ) -> Result<Vec<OKXMarkPrice>, OKXHttpError> {
1442 self.send_request(
1443 Method::GET,
1444 "/api/v5/public/mark-price",
1445 Some(¶ms),
1446 None,
1447 false,
1448 )
1449 .await
1450 }
1451
1452 pub async fn get_price_limit(
1463 &self,
1464 params: GetPriceLimitParams,
1465 ) -> Result<Vec<OKXPriceLimit>, OKXHttpError> {
1466 self.send_request(
1467 Method::GET,
1468 "/api/v5/public/price-limit",
1469 Some(¶ms),
1470 None,
1471 false,
1472 )
1473 .await
1474 }
1475
1476 pub async fn get_index_tickers(
1486 &self,
1487 params: GetIndexTickerParams,
1488 ) -> Result<Vec<OKXIndexTicker>, OKXHttpError> {
1489 self.send_request(
1490 Method::GET,
1491 "/api/v5/market/index-tickers",
1492 Some(¶ms),
1493 None,
1494 false,
1495 )
1496 .await
1497 }
1498
1499 pub async fn get_history_trades(
1509 &self,
1510 params: GetTradesParams,
1511 ) -> Result<Vec<OKXTrade>, OKXHttpError> {
1512 self.send_request(
1513 Method::GET,
1514 "/api/v5/market/history-trades",
1515 Some(¶ms),
1516 None,
1517 false,
1518 )
1519 .await
1520 }
1521
1522 pub async fn get_order_book(
1532 &self,
1533 params: GetOrderBookParams,
1534 ) -> Result<Vec<OKXOrderBookSnapshot>, OKXHttpError> {
1535 self.send_request(
1536 Method::GET,
1537 "/api/v5/market/books",
1538 Some(¶ms),
1539 None,
1540 false,
1541 )
1542 .await
1543 }
1544
1545 pub async fn get_rpi_order_book(
1555 &self,
1556 params: GetRpiOrderBookParams,
1557 ) -> Result<Vec<OKXRpiOrderBookSnapshot>, OKXHttpError> {
1558 self.send_request(
1559 Method::GET,
1560 "/api/v5/market/books-rpi",
1561 Some(¶ms),
1562 None,
1563 false,
1564 )
1565 .await
1566 }
1567
1568 pub async fn get_funding_rate_history(
1578 &self,
1579 params: GetFundingRateHistoryParams,
1580 ) -> Result<Vec<OKXFundingRateHistory>, OKXHttpError> {
1581 self.send_request(
1582 Method::GET,
1583 "/api/v5/public/funding-rate-history",
1584 Some(¶ms),
1585 None,
1586 false,
1587 )
1588 .await
1589 }
1590
1591 pub async fn get_candles(
1601 &self,
1602 params: GetCandlesticksParams,
1603 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1604 self.send_request(
1605 Method::GET,
1606 "/api/v5/market/candles",
1607 Some(¶ms),
1608 None,
1609 false,
1610 )
1611 .await
1612 }
1613
1614 pub async fn get_history_candles(
1624 &self,
1625 params: GetCandlesticksParams,
1626 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1627 self.send_request(
1628 Method::GET,
1629 "/api/v5/market/history-candles",
1630 Some(¶ms),
1631 None,
1632 false,
1633 )
1634 .await
1635 }
1636
1637 pub async fn get_balance(&self) -> Result<Vec<OKXAccount>, OKXHttpError> {
1648 let path = "/api/v5/account/balance";
1649 self.send_request::<_, ()>(Method::GET, path, None, None, true)
1650 .await
1651 }
1652
1653 pub async fn get_trade_fee(
1665 &self,
1666 params: GetTradeFeeParams,
1667 ) -> Result<Vec<OKXFeeRate>, OKXHttpError> {
1668 self.send_request(
1669 Method::GET,
1670 "/api/v5/account/trade-fee",
1671 Some(¶ms),
1672 None,
1673 true,
1674 )
1675 .await
1676 }
1677
1678 pub async fn get_order(
1688 &self,
1689 params: GetOrderParams,
1690 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1691 self.send_request(
1692 Method::GET,
1693 "/api/v5/trade/order",
1694 Some(¶ms),
1695 None,
1696 true,
1697 )
1698 .await
1699 }
1700
1701 pub async fn get_algo_order(
1711 &self,
1712 params: GetAlgoOrderParams,
1713 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1714 self.send_request(
1715 Method::GET,
1716 "/api/v5/trade/order-algo",
1717 Some(¶ms),
1718 None,
1719 true,
1720 )
1721 .await
1722 }
1723
1724 pub async fn get_orders_pending(
1734 &self,
1735 params: GetOrderListParams,
1736 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1737 self.send_request(
1738 Method::GET,
1739 "/api/v5/trade/orders-pending",
1740 Some(¶ms),
1741 None,
1742 true,
1743 )
1744 .await
1745 }
1746
1747 pub async fn get_orders_history(
1757 &self,
1758 params: GetOrderHistoryParams,
1759 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1760 self.send_request(
1761 Method::GET,
1762 "/api/v5/trade/orders-history",
1763 Some(¶ms),
1764 None,
1765 true,
1766 )
1767 .await
1768 }
1769
1770 pub async fn get_order_algo_pending(
1776 &self,
1777 params: GetAlgoOrdersParams,
1778 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1779 self.send_request(
1780 Method::GET,
1781 "/api/v5/trade/orders-algo-pending",
1782 Some(¶ms),
1783 None,
1784 true,
1785 )
1786 .await
1787 }
1788
1789 pub async fn get_order_algo_history(
1795 &self,
1796 params: GetAlgoOrdersParams,
1797 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1798 self.send_request(
1799 Method::GET,
1800 "/api/v5/trade/orders-algo-history",
1801 Some(¶ms),
1802 None,
1803 true,
1804 )
1805 .await
1806 }
1807
1808 pub async fn get_fills(
1818 &self,
1819 params: GetTransactionDetailsParams,
1820 ) -> Result<Vec<OKXTransactionDetail>, OKXHttpError> {
1821 self.send_request(
1822 Method::GET,
1823 "/api/v5/trade/fills",
1824 Some(¶ms),
1825 None,
1826 true,
1827 )
1828 .await
1829 }
1830
1831 pub async fn get_fills_history(
1841 &self,
1842 params: GetTransactionDetailsParams,
1843 ) -> Result<Vec<OKXTransactionDetail>, OKXHttpError> {
1844 self.send_request(
1845 Method::GET,
1846 "/api/v5/trade/fills-history",
1847 Some(¶ms),
1848 None,
1849 true,
1850 )
1851 .await
1852 }
1853
1854 pub async fn get_positions(
1866 &self,
1867 params: GetPositionsParams,
1868 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
1869 self.send_request(
1870 Method::GET,
1871 "/api/v5/account/positions",
1872 Some(¶ms),
1873 None,
1874 true,
1875 )
1876 .await
1877 }
1878
1879 pub async fn get_positions_history(
1889 &self,
1890 params: GetPositionsHistoryParams,
1891 ) -> Result<Vec<OKXPositionHistory>, OKXHttpError> {
1892 self.send_request(
1893 Method::GET,
1894 "/api/v5/account/positions-history",
1895 Some(¶ms),
1896 None,
1897 true,
1898 )
1899 .await
1900 }
1901}
1902
1903#[derive(Debug)]
1908#[cfg_attr(
1909 feature = "python",
1910 pyo3::pyclass(module = "nautilus_trader.adapters.okx", from_py_object)
1911)]
1912#[cfg_attr(
1913 feature = "python",
1914 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
1915)]
1916pub struct OKXHttpClient {
1917 pub(crate) inner: Arc<OKXRawHttpClient>,
1918 pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1919 clock: &'static AtomicTime,
1920 cache_initialized: AtomicBool,
1921}
1922
1923impl Clone for OKXHttpClient {
1924 fn clone(&self) -> Self {
1925 let cache_initialized = AtomicBool::new(false);
1926
1927 let is_initialized = self.cache_initialized.load(Ordering::Acquire);
1928 if is_initialized {
1929 cache_initialized.store(true, Ordering::Release);
1930 }
1931
1932 Self {
1933 inner: self.inner.clone(),
1934 instruments_cache: self.instruments_cache.clone(),
1935 cache_initialized,
1936 clock: self.clock,
1937 }
1938 }
1939}
1940
1941impl Default for OKXHttpClient {
1942 fn default() -> Self {
1943 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
1944 .expect("Failed to create default OKXHttpClient")
1945 }
1946}
1947
1948impl OKXHttpClient {
1949 pub fn new(
1959 base_url: Option<String>,
1960 timeout_secs: u64,
1961 max_retries: u32,
1962 retry_delay_ms: u64,
1963 retry_delay_max_ms: u64,
1964 environment: OKXEnvironment,
1965 proxy_url: Option<String>,
1966 ) -> anyhow::Result<Self> {
1967 Ok(Self {
1968 inner: Arc::new(OKXRawHttpClient::new(
1969 base_url,
1970 timeout_secs,
1971 max_retries,
1972 retry_delay_ms,
1973 retry_delay_max_ms,
1974 environment,
1975 proxy_url,
1976 )?),
1977 instruments_cache: Arc::new(AtomicMap::new()),
1978 cache_initialized: AtomicBool::new(false),
1979 clock: get_atomic_clock_realtime(),
1980 })
1981 }
1982
1983 fn generate_ts_init(&self) -> UnixNanos {
1985 self.clock.get_time_ns()
1986 }
1987
1988 pub fn from_env() -> anyhow::Result<Self> {
1995 Self::with_credentials(
1996 None,
1997 None,
1998 None,
1999 None,
2000 60,
2001 3,
2002 1000,
2003 10_000,
2004 OKXEnvironment::Live,
2005 None,
2006 )
2007 }
2008
2009 #[expect(clippy::too_many_arguments)]
2016 pub fn with_credentials(
2017 api_key: Option<String>,
2018 api_secret: Option<String>,
2019 api_passphrase: Option<String>,
2020 base_url: Option<String>,
2021 timeout_secs: u64,
2022 max_retries: u32,
2023 retry_delay_ms: u64,
2024 retry_delay_max_ms: u64,
2025 environment: OKXEnvironment,
2026 proxy_url: Option<String>,
2027 ) -> anyhow::Result<Self> {
2028 let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
2029 let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
2030 let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
2031 let base_url = base_url.unwrap_or(OKX_HTTP_URL.to_string());
2032
2033 Ok(Self {
2034 inner: Arc::new(OKXRawHttpClient::with_credentials(
2035 api_key,
2036 api_secret,
2037 api_passphrase,
2038 base_url,
2039 timeout_secs,
2040 max_retries,
2041 retry_delay_ms,
2042 retry_delay_max_ms,
2043 environment,
2044 proxy_url,
2045 )?),
2046 instruments_cache: Arc::new(AtomicMap::new()),
2047 cache_initialized: AtomicBool::new(false),
2048 clock: get_atomic_clock_realtime(),
2049 })
2050 }
2051
2052 fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
2058 self.instruments_cache
2059 .get_cloned(&symbol)
2060 .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not in cache"))
2061 }
2062
2063 fn instrument_from_cache_by_id(
2064 &self,
2065 instrument_id: InstrumentId,
2066 ) -> anyhow::Result<InstrumentAny> {
2067 self.instruments_cache
2068 .get_cloned(&instrument_id.symbol.inner())
2069 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
2070 }
2071
2072 pub fn cancel_all_requests(&self) {
2074 self.inner.cancel_all_requests();
2075 }
2076
2077 pub fn cancellation_token(&self) -> &CancellationToken {
2079 self.inner.cancellation_token()
2080 }
2081
2082 pub async fn get_orders_pending(
2088 &self,
2089 params: GetOrderListParams,
2090 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
2091 self.inner.get_orders_pending(params).await
2092 }
2093
2094 pub async fn get_order_algo_pending(
2100 &self,
2101 params: GetAlgoOrdersParams,
2102 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
2103 self.inner.get_order_algo_pending(params).await
2104 }
2105
2106 pub async fn get_positions(
2112 &self,
2113 params: GetPositionsParams,
2114 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
2115 self.inner.get_positions(params).await
2116 }
2117
2118 pub fn base_url(&self) -> &str {
2120 self.inner.base_url.as_str()
2121 }
2122
2123 pub fn api_key(&self) -> Option<&str> {
2125 self.inner.credential.as_ref().map(|c| c.api_key())
2126 }
2127
2128 #[must_use]
2130 pub fn api_key_masked(&self) -> Option<String> {
2131 self.inner.credential.as_ref().map(|c| c.api_key_masked())
2132 }
2133
2134 #[must_use]
2136 pub fn is_demo(&self) -> bool {
2137 self.inner.environment == OKXEnvironment::Demo
2138 }
2139
2140 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
2148 self.inner.get_server_time().await
2149 }
2150
2151 #[must_use]
2155 pub fn is_initialized(&self) -> bool {
2156 self.cache_initialized.load(Ordering::Acquire)
2157 }
2158
2159 #[must_use]
2162 pub fn get_cached_symbols(&self) -> Vec<String> {
2163 self.instruments_cache
2164 .load()
2165 .keys()
2166 .map(|k| k.to_string())
2167 .collect()
2168 }
2169
2170 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
2174 self.instruments_cache.rcu(|m| {
2175 for inst in instruments {
2176 m.insert(inst.raw_symbol().inner(), inst.clone());
2177 }
2178 });
2179 self.cache_initialized.store(true, Ordering::Release);
2180 }
2181
2182 pub fn cache_instrument(&self, instrument: InstrumentAny) {
2186 self.instruments_cache
2187 .insert(instrument.raw_symbol().inner(), instrument);
2188 self.cache_initialized.store(true, Ordering::Release);
2189 }
2190
2191 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
2193 self.instruments_cache.get_cloned(symbol)
2194 }
2195
2196 pub async fn request_account_state(
2202 &self,
2203 account_id: AccountId,
2204 ) -> anyhow::Result<AccountState> {
2205 let resp = self
2206 .inner
2207 .get_balance()
2208 .await
2209 .map_err(|e| anyhow::anyhow!(e))?;
2210
2211 let ts_init = self.generate_ts_init();
2212 let raw = resp
2213 .first()
2214 .ok_or_else(|| anyhow::anyhow!("No account state returned from OKX"))?;
2215 let account_state = parse_account_state(raw, account_id, ts_init)?;
2216
2217 Ok(account_state)
2218 }
2219
2220 pub async fn set_position_mode(&self, position_mode: OKXPositionMode) -> anyhow::Result<()> {
2233 let mut params = SetPositionModeParamsBuilder::default();
2234 params.pos_mode(position_mode);
2235 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2236
2237 match self.inner.set_position_mode(params).await {
2238 Ok(_) => Ok(()),
2239 Err(e) => {
2240 if let OKXHttpError::OkxError {
2241 error_code,
2242 message,
2243 } = &e
2244 && error_code == "50115"
2245 {
2246 log::warn!(
2247 "Account does not support position mode setting (derivatives trading not enabled): {message}"
2248 );
2249 return Ok(()); }
2251 anyhow::bail!(e)
2252 }
2253 }
2254 }
2255
2256 pub async fn request_instruments(
2271 &self,
2272 instrument_type: OKXInstrumentType,
2273 instrument_family: Option<String>,
2274 ) -> anyhow::Result<(Vec<InstrumentAny>, Vec<(Ustr, u64)>)> {
2275 if instrument_type == OKXInstrumentType::Option && instrument_family.is_none() {
2276 anyhow::bail!(
2277 "option instruments require instrument_family (OKX instFamily), for example BTC-USD"
2278 );
2279 }
2280
2281 let resp = if instrument_type == OKXInstrumentType::Events {
2282 let series_ids = if let Some(series_id) = instrument_family.clone() {
2283 vec![series_id]
2284 } else {
2285 self.inner
2286 .get_event_contract_series(GetEventContractSeriesParams::default())
2287 .await
2288 .map_err(|e| anyhow::anyhow!(e))?
2289 .into_iter()
2290 .map(|series| series.series_id)
2291 .collect()
2292 };
2293
2294 let mut event_instruments = Vec::new();
2295
2296 for series_id in series_ids {
2297 let mut params = GetInstrumentsParamsBuilder::default();
2298 params.inst_type(OKXInstrumentType::Events);
2299 params.series_id(series_id);
2300 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2301 let mut page = self
2302 .inner
2303 .get_instruments(params)
2304 .await
2305 .map_err(|e| anyhow::anyhow!(e))?;
2306 event_instruments.append(&mut page);
2307 }
2308 event_instruments
2309 } else {
2310 let mut params = GetInstrumentsParamsBuilder::default();
2311 params.inst_type(instrument_type);
2312
2313 if let Some(family) = instrument_family.clone() {
2314 params.inst_family(family);
2315 }
2316
2317 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2318
2319 self.inner
2320 .get_instruments(params)
2321 .await
2322 .map_err(|e| anyhow::anyhow!(e))?
2323 };
2324
2325 let fee_rate_opt = {
2326 let fee_params = GetTradeFeeParams {
2327 inst_type: instrument_type,
2328 uly: None,
2329 inst_family: if instrument_type == OKXInstrumentType::Events {
2330 None
2331 } else {
2332 instrument_family
2333 },
2334 };
2335
2336 match self.inner.get_trade_fee(fee_params).await {
2337 Ok(rates) => rates.into_iter().next(),
2338 Err(OKXHttpError::MissingCredentials) => {
2339 log::debug!("Missing credentials for fee rates, using None");
2340 None
2341 }
2342 Err(e) => {
2343 log::warn!("Failed to fetch fee rates for {instrument_type}: {e}");
2344 None
2345 }
2346 }
2347 };
2348
2349 let ts_init = self.generate_ts_init();
2350
2351 let mut instruments: Vec<InstrumentAny> = Vec::new();
2352 let mut inst_id_codes: Vec<(Ustr, u64)> = Vec::new();
2353
2354 for inst in &resp {
2355 if let Some(code) = inst.inst_id_code {
2357 inst_id_codes.push((inst.inst_id, code));
2358 }
2359 if inst.state == OKXInstrumentStatus::Preopen {
2362 continue;
2363 }
2364
2365 let (maker_fee, taker_fee) = if let Some(ref fee_rate) = fee_rate_opt {
2370 let is_usdt_margined = inst.ct_type == OKXContractType::Linear;
2371 let (maker_str, taker_str) = if is_usdt_margined {
2372 (&fee_rate.maker_u, &fee_rate.taker_u)
2373 } else {
2374 (&fee_rate.maker, &fee_rate.taker)
2375 };
2376
2377 let maker = if maker_str.is_empty() {
2378 None
2379 } else {
2380 Decimal::from_str(maker_str).ok().map(|v| -v)
2381 };
2382 let taker = if taker_str.is_empty() {
2383 None
2384 } else {
2385 Decimal::from_str(taker_str).ok().map(|v| -v)
2386 };
2387
2388 (maker, taker)
2389 } else {
2390 (None, None)
2391 };
2392
2393 match parse_instrument_any(inst, None, None, maker_fee, taker_fee, ts_init) {
2394 Ok(Some(instrument_any)) => {
2395 instruments.push(instrument_any);
2396 }
2397 Ok(None) => {
2398 }
2400 Err(e) => {
2401 log::warn!("Failed to parse instrument {}: {e}", inst.inst_id);
2402 }
2403 }
2404 }
2405
2406 Ok((instruments, inst_id_codes))
2407 }
2408
2409 pub async fn request_spread_instruments(
2415 &self,
2416 params: GetSpreadsParams,
2417 ) -> anyhow::Result<Vec<InstrumentAny>> {
2418 let resp = self
2419 .inner
2420 .get_spreads(params)
2421 .await
2422 .map_err(|e| anyhow::anyhow!(e))?;
2423
2424 let ts_init = self.generate_ts_init();
2425 let mut instruments = Vec::new();
2426
2427 for spread in &resp {
2428 match parse_spread_instrument(spread, None, None, None, None, ts_init) {
2429 Ok(instrument) => instruments.push(instrument),
2430 Err(e) => log::warn!("Failed to parse spread {}: {e}", spread.sprd_id),
2431 }
2432 }
2433
2434 Ok(instruments)
2435 }
2436
2437 pub async fn request_instrument(
2448 &self,
2449 instrument_id: InstrumentId,
2450 ) -> anyhow::Result<InstrumentAny> {
2451 let symbol = instrument_id.symbol.as_str();
2452
2453 if is_okx_spread_symbol(symbol) {
2454 let instrument = self.request_spread_instrument(symbol).await?;
2455 self.cache_instrument(instrument.clone());
2456 return Ok(instrument);
2457 }
2458
2459 let instrument_type = okx_instrument_type_from_symbol(symbol);
2460
2461 let resp = if instrument_type == OKXInstrumentType::Events {
2462 let series = self
2463 .inner
2464 .get_event_contract_series(GetEventContractSeriesParams::default())
2465 .await
2466 .map_err(|e| anyhow::anyhow!(e))?;
2467
2468 let mut matched = Vec::new();
2469
2470 for series in series {
2471 let mut params = GetInstrumentsParamsBuilder::default();
2472 params.inst_type(OKXInstrumentType::Events);
2473 params.series_id(series.series_id);
2474 params.inst_id(symbol);
2475 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2476
2477 let mut page = self
2478 .inner
2479 .get_instruments(params)
2480 .await
2481 .map_err(|e| anyhow::anyhow!(e))?;
2482 matched.append(&mut page);
2483 if !matched.is_empty() {
2484 break;
2485 }
2486 }
2487 matched
2488 } else {
2489 let mut params = GetInstrumentsParamsBuilder::default();
2490 params.inst_type(instrument_type);
2491 params.inst_id(symbol);
2492
2493 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2494
2495 self.inner
2496 .get_instruments(params)
2497 .await
2498 .map_err(|e| anyhow::anyhow!(e))?
2499 };
2500
2501 let raw_inst = resp
2502 .first()
2503 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2504
2505 if raw_inst.state == OKXInstrumentStatus::Preopen {
2507 return Err(OKXInstrumentDefinitionError::new(
2508 symbol,
2509 anyhow::anyhow!("instrument is in pre-open state"),
2510 )
2511 .into());
2512 }
2513
2514 let fee_rate_opt = {
2515 let fee_params = GetTradeFeeParams {
2516 inst_type: instrument_type,
2517 uly: None,
2518 inst_family: None,
2519 };
2520
2521 match self.inner.get_trade_fee(fee_params).await {
2522 Ok(rates) => rates.into_iter().next(),
2523 Err(OKXHttpError::MissingCredentials) => {
2524 log::debug!("Missing credentials for fee rates, using None");
2525 None
2526 }
2527 Err(e) => {
2528 log::warn!("Failed to fetch fee rates for {symbol}: {e}");
2529 None
2530 }
2531 }
2532 };
2533
2534 let (maker_fee, taker_fee) = if let Some(ref fee_rate) = fee_rate_opt {
2538 let is_usdt_margined = raw_inst.ct_type == OKXContractType::Linear;
2539 let (maker_str, taker_str) = if is_usdt_margined {
2540 (&fee_rate.maker_u, &fee_rate.taker_u)
2541 } else {
2542 (&fee_rate.maker, &fee_rate.taker)
2543 };
2544
2545 let maker = if maker_str.is_empty() {
2546 None
2547 } else {
2548 Decimal::from_str(maker_str).ok().map(|v| -v)
2549 };
2550 let taker = if taker_str.is_empty() {
2551 None
2552 } else {
2553 Decimal::from_str(taker_str).ok().map(|v| -v)
2554 };
2555
2556 (maker, taker)
2557 } else {
2558 (None, None)
2559 };
2560
2561 let ts_init = self.generate_ts_init();
2562 let Some(instrument) =
2563 parse_instrument_any(raw_inst, None, None, maker_fee, taker_fee, ts_init)
2564 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e))?
2565 else {
2566 return Err(OKXInstrumentDefinitionError::new(
2567 symbol,
2568 anyhow::anyhow!("unsupported instrument type"),
2569 )
2570 .into());
2571 };
2572
2573 self.cache_instrument(instrument.clone());
2574
2575 Ok(instrument)
2576 }
2577
2578 async fn request_spread_instrument(&self, symbol: &str) -> anyhow::Result<InstrumentAny> {
2579 let resp = self
2580 .inner
2581 .get_spreads(GetSpreadsParams {
2582 sprd_id: Some(symbol.to_string()),
2583 ..Default::default()
2584 })
2585 .await
2586 .map_err(|e| anyhow::anyhow!(e))?;
2587
2588 let raw_spread = resp
2589 .first()
2590 .ok_or_else(|| anyhow::anyhow!("Spread instrument {symbol} not found"))?;
2591 let ts_init = self.generate_ts_init();
2592
2593 parse_spread_instrument(raw_spread, None, None, None, None, ts_init)
2594 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e).into())
2595 }
2596
2597 pub async fn request_event_contract_series(
2603 &self,
2604 params: GetEventContractSeriesParams,
2605 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
2606 self.inner.get_event_contract_series(params).await
2607 }
2608
2609 pub async fn request_event_contract_events(
2615 &self,
2616 params: GetEventContractEventsParams,
2617 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
2618 self.inner.get_event_contract_events(params).await
2619 }
2620
2621 pub async fn request_event_contract_markets(
2627 &self,
2628 params: GetEventContractMarketsParams,
2629 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
2630 self.inner.get_event_contract_markets(params).await
2631 }
2632
2633 pub async fn request_forward_prices(
2639 &self,
2640 underlying: &str,
2641 instrument_id: Option<InstrumentId>,
2642 ) -> anyhow::Result<Vec<ForwardPrice>> {
2643 let requests = self.resolve_forward_price_requests(underlying, instrument_id.as_ref())?;
2644 let requested_symbol = instrument_id.as_ref().map(|id| id.symbol.inner());
2645 let requested_instrument_id = instrument_id.as_ref();
2646 let ts_init = self.generate_ts_init();
2647 let mut forward_prices = Vec::new();
2648 let mut seen_expiries = AHashSet::new();
2649
2650 for (inst_family, exp_time) in requests {
2651 let summaries = self
2652 .inner
2653 .get_option_summary(GetOptionSummaryParams {
2654 inst_family,
2655 exp_time,
2656 })
2657 .await
2658 .map_err(|e| anyhow::anyhow!(e))?;
2659
2660 for summary in summaries {
2661 if summary.inst_type != OKXInstrumentType::Option {
2662 continue;
2663 }
2664
2665 if let Some(symbol) = requested_symbol
2666 && summary.inst_id != symbol
2667 {
2668 continue;
2669 }
2670
2671 let forward_price = match Decimal::from_str(&summary.fwd_px) {
2672 Ok(price) if !price.is_zero() => price,
2673 Ok(_) => continue,
2674 Err(e) => {
2675 log::warn!(
2676 "Skipping invalid OKX forward price for {}: {e}",
2677 summary.inst_id
2678 );
2679 continue;
2680 }
2681 };
2682
2683 if requested_symbol.is_none() {
2684 let expiry_key = Self::option_summary_expiry_key(summary.inst_id.as_str())?;
2685 if !seen_expiries.insert(expiry_key) {
2686 continue;
2687 }
2688 }
2689
2690 let ts_event =
2691 UnixNanos::from(summary.ts.saturating_mul(NANOSECONDS_IN_MILLISECOND));
2692 let instrument_id = if let Some(inst_id) = requested_instrument_id {
2693 *inst_id
2694 } else {
2695 parse_instrument_id(summary.inst_id)
2696 };
2697
2698 forward_prices.push(ForwardPrice::new(
2699 instrument_id,
2700 forward_price,
2701 Some(summary.uly.to_string()),
2702 ts_event,
2703 ts_init,
2704 ));
2705 }
2706 }
2707
2708 Ok(forward_prices)
2709 }
2710
2711 pub async fn request_mark_price(
2717 &self,
2718 instrument_id: InstrumentId,
2719 ) -> anyhow::Result<MarkPriceUpdate> {
2720 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
2721 let mut params = GetMarkPriceParamsBuilder::default();
2722 params.inst_type(okx_instrument_type(&inst)?);
2723 params.inst_id(instrument_id.symbol.inner());
2724 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2725
2726 let resp = self
2727 .inner
2728 .get_mark_price(params)
2729 .await
2730 .map_err(|e| anyhow::anyhow!(e))?;
2731
2732 let raw = resp
2733 .first()
2734 .ok_or_else(|| anyhow::anyhow!("No mark price returned from OKX"))?;
2735 let ts_init = self.generate_ts_init();
2736
2737 let mark_price =
2738 parse_mark_price_update(raw, instrument_id, inst.price_precision(), ts_init)
2739 .map_err(|e| anyhow::anyhow!(e))?;
2740 Ok(mark_price)
2741 }
2742
2743 pub async fn request_price_limit(
2749 &self,
2750 instrument_id: InstrumentId,
2751 ) -> anyhow::Result<OKXPriceLimit> {
2752 let mut params = GetPriceLimitParamsBuilder::default();
2753 params.inst_id(instrument_id.symbol.inner());
2754 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2755
2756 let resp = self
2757 .inner
2758 .get_price_limit(params)
2759 .await
2760 .map_err(|e| anyhow::anyhow!(e))?;
2761
2762 resp.first()
2763 .cloned()
2764 .ok_or_else(|| anyhow::anyhow!("No price limit returned from OKX"))
2765 }
2766
2767 fn resolve_forward_price_requests(
2768 &self,
2769 underlying: &str,
2770 instrument_id: Option<&InstrumentId>,
2771 ) -> anyhow::Result<Vec<(String, Option<String>)>> {
2772 if let Some(inst_id) = instrument_id {
2773 let symbol = inst_id.symbol.inner().as_str();
2774 let inst_family = extract_inst_family(symbol)?.to_string();
2775 let exp_time = Self::option_summary_exp_time(symbol)?;
2776 return Ok(vec![(inst_family, exp_time)]);
2777 }
2778
2779 let underlying = Ustr::from(underlying);
2780 let mut families = AHashSet::new();
2781
2782 for instrument in self.instruments_cache.load().values() {
2783 let InstrumentAny::CryptoOption(option) = instrument else {
2784 continue;
2785 };
2786
2787 if option.underlying.code != underlying {
2788 continue;
2789 }
2790
2791 let inst_family = extract_inst_family(option.id.symbol.inner().as_str())?;
2792 families.insert(inst_family.to_string());
2793 }
2794
2795 let mut families: Vec<String> = families.into_iter().collect();
2796 families.sort_unstable();
2797
2798 anyhow::ensure!(
2799 !families.is_empty(),
2800 "No cached OKX option families for underlying {underlying}; provide a sample instrument or pre-load option instruments"
2801 );
2802
2803 Ok(families.into_iter().map(|family| (family, None)).collect())
2804 }
2805
2806 fn option_summary_expiry_key(symbol: &str) -> anyhow::Result<String> {
2807 let parts: Vec<&str> = symbol.split('-').collect();
2808 anyhow::ensure!(
2809 parts.len() >= 5,
2810 "Expected OKX option symbol with expiry, received {symbol}"
2811 );
2812 Ok(format!("{}-{}-{}", parts[0], parts[1], parts[2]))
2813 }
2814
2815 fn option_summary_exp_time(symbol: &str) -> anyhow::Result<Option<String>> {
2816 let parts: Vec<&str> = symbol.split('-').collect();
2817 anyhow::ensure!(
2818 parts.len() >= 5,
2819 "Expected OKX option symbol with expiry, received {symbol}"
2820 );
2821 Ok(Some(parts[2].to_string()))
2822 }
2823
2824 pub async fn request_index_price(
2830 &self,
2831 instrument_id: InstrumentId,
2832 ) -> anyhow::Result<IndexPriceUpdate> {
2833 let symbol = instrument_id.symbol.inner();
2835 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())?;
2836 let inst_id = format!("{base}-{quote}");
2837
2838 let mut params = GetIndexTickerParamsBuilder::default();
2839 params.inst_id(Ustr::from(&inst_id));
2840 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2841
2842 let resp = self
2843 .inner
2844 .get_index_tickers(params)
2845 .await
2846 .map_err(|e| anyhow::anyhow!(e))?;
2847
2848 let raw = resp
2849 .first()
2850 .ok_or_else(|| anyhow::anyhow!("No index price returned from OKX"))?;
2851 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
2852 let ts_init = self.generate_ts_init();
2853
2854 let index_price =
2855 parse_index_price_update(raw, instrument_id, inst.price_precision(), ts_init)
2856 .map_err(|e| anyhow::anyhow!(e))?;
2857 Ok(index_price)
2858 }
2859
2860 pub async fn request_book_snapshot(
2866 &self,
2867 instrument_id: InstrumentId,
2868 depth: Option<u32>,
2869 ) -> anyhow::Result<OrderBook> {
2870 let inst = self.instrument_from_cache_by_id(instrument_id)?;
2871 let price_precision = inst.price_precision();
2872 let size_precision = inst.size_precision();
2873
2874 let params = GetOrderBookParams {
2875 inst_id: instrument_id.symbol.to_string(),
2876 sz: depth,
2877 };
2878
2879 let resp = self
2880 .inner
2881 .get_order_book(params)
2882 .await
2883 .map_err(|e| anyhow::anyhow!(e))?;
2884
2885 let snapshot = resp
2886 .first()
2887 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
2888
2889 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
2890 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
2891
2892 for (i, level) in snapshot.bids.iter().enumerate() {
2893 let price = parse_price(&level.0, price_precision)?;
2894 let size = parse_quantity(&level.1, size_precision)?;
2895 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
2896 book.add(order, 0, i as u64, ts_event);
2897 }
2898
2899 let bids_len = snapshot.bids.len();
2900
2901 for (i, level) in snapshot.asks.iter().enumerate() {
2902 let price = parse_price(&level.0, price_precision)?;
2903 let size = parse_quantity(&level.1, size_precision)?;
2904 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
2905 book.add(order, 0, (bids_len + i) as u64, ts_event);
2906 }
2907
2908 log::debug!(
2909 "Fetched order book for {} with {} bids and {} asks",
2910 instrument_id,
2911 snapshot.bids.len(),
2912 snapshot.asks.len(),
2913 );
2914
2915 Ok(book)
2916 }
2917
2918 pub async fn request_rpi_book_snapshot(
2924 &self,
2925 instrument_id: InstrumentId,
2926 depth: Option<u32>,
2927 ) -> anyhow::Result<OrderBook> {
2928 let inst = self.instrument_from_cache_by_id(instrument_id)?;
2929 let price_precision = inst.price_precision();
2930 let size_precision = inst.size_precision();
2931 let params = GetRpiOrderBookParams {
2932 inst_id: instrument_id.symbol.to_string(),
2933 sz: depth,
2934 };
2935 let resp = self
2936 .inner
2937 .get_rpi_order_book(params)
2938 .await
2939 .map_err(|e| anyhow::anyhow!(e))?;
2940 let snapshot = resp
2941 .first()
2942 .ok_or_else(|| anyhow::anyhow!("No RPI order book returned from OKX"))?;
2943 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
2944 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
2945
2946 for (i, level) in snapshot.bids.iter().enumerate() {
2947 let price = Price::from_decimal_dp(level.0, price_precision)?;
2948 let size = Quantity::from_decimal_dp(level.1, size_precision)?;
2949 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
2950 book.add(order, 0, i as u64, ts_event);
2951 }
2952
2953 let bids_len = snapshot.bids.len();
2954 for (i, level) in snapshot.asks.iter().enumerate() {
2955 let price = Price::from_decimal_dp(level.0, price_precision)?;
2956 let size = Quantity::from_decimal_dp(level.1, size_precision)?;
2957 let index = (bids_len + i) as u64;
2958 let order = BookOrder::new(OrderSide::Sell, price, size, index);
2959 book.add(order, 0, index, ts_event);
2960 }
2961
2962 log::debug!(
2963 "Fetched RPI order book for {} with {} bids and {} asks",
2964 instrument_id,
2965 snapshot.bids.len(),
2966 snapshot.asks.len(),
2967 );
2968
2969 Ok(book)
2970 }
2971
2972 pub async fn request_orderbook_snapshot(
2978 &self,
2979 instrument_id: InstrumentId,
2980 depth: Option<u32>,
2981 ) -> anyhow::Result<OrderBookDeltas> {
2982 let inst = self.instrument_from_cache_by_id(instrument_id)?;
2983 let price_precision = inst.price_precision();
2984 let size_precision = inst.size_precision();
2985
2986 let params = GetOrderBookParams {
2987 inst_id: instrument_id.symbol.to_string(),
2988 sz: depth,
2989 };
2990
2991 let resp = self
2992 .inner
2993 .get_order_book(params)
2994 .await
2995 .map_err(|e| anyhow::anyhow!(e))?;
2996
2997 let snapshot = resp
2998 .first()
2999 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
3000
3001 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
3002 let total_levels = snapshot.bids.len() + snapshot.asks.len();
3003 let mut deltas = Vec::with_capacity(total_levels + 1);
3004
3005 let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_event);
3006
3007 if total_levels == 0 {
3008 clear.flags |= RecordFlag::F_LAST as u8;
3009 }
3010 deltas.push(clear);
3011
3012 let mut processed = 0_usize;
3013
3014 for (i, level) in snapshot.bids.iter().enumerate() {
3015 let price = parse_price(&level.0, price_precision)?;
3016 let size = parse_quantity(&level.1, size_precision)?;
3017 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
3018 processed += 1;
3019 let mut flags = RecordFlag::F_SNAPSHOT as u8;
3020
3021 if processed == total_levels {
3022 flags |= RecordFlag::F_LAST as u8;
3023 }
3024 deltas.push(OrderBookDelta::new(
3025 instrument_id,
3026 BookAction::Add,
3027 order,
3028 flags,
3029 0,
3030 ts_event,
3031 ts_event,
3032 ));
3033 }
3034
3035 let bids_len = snapshot.bids.len();
3036
3037 for (i, level) in snapshot.asks.iter().enumerate() {
3038 let price = parse_price(&level.0, price_precision)?;
3039 let size = parse_quantity(&level.1, size_precision)?;
3040 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
3041 processed += 1;
3042 let mut flags = RecordFlag::F_SNAPSHOT as u8;
3043
3044 if processed == total_levels {
3045 flags |= RecordFlag::F_LAST as u8;
3046 }
3047 deltas.push(OrderBookDelta::new(
3048 instrument_id,
3049 BookAction::Add,
3050 order,
3051 flags,
3052 0,
3053 ts_event,
3054 ts_event,
3055 ));
3056 }
3057
3058 log::debug!(
3059 "Fetched order book snapshot for {} with {} bids and {} asks",
3060 instrument_id,
3061 snapshot.bids.len(),
3062 snapshot.asks.len(),
3063 );
3064
3065 OrderBookDeltas::new_checked(instrument_id, deltas)
3066 .context("failed to assemble OrderBookDeltas from OKX snapshot")
3067 }
3068
3069 pub async fn request_funding_rates(
3075 &self,
3076 instrument_id: InstrumentId,
3077 start: Option<Timestamp>,
3078 end: Option<Timestamp>,
3079 limit: Option<u32>,
3080 ) -> anyhow::Result<Vec<FundingRateUpdate>> {
3081 let mut params = GetFundingRateHistoryParams {
3082 inst_id: instrument_id.symbol.to_string(),
3083 ..Default::default()
3084 };
3085
3086 if let Some(start) = start {
3088 params.before = Some(start.as_millisecond().to_string());
3089 }
3090
3091 if let Some(end) = end {
3092 params.after = Some(end.as_millisecond().to_string());
3093 }
3094
3095 params.limit = limit;
3096
3097 let resp = self
3098 .inner
3099 .get_funding_rate_history(params)
3100 .await
3101 .map_err(|e| anyhow::anyhow!(e))?;
3102
3103 let mut rates = Vec::with_capacity(resp.len());
3104
3105 for window in resp.windows(2) {
3106 let raw = &window[0];
3107 let interval_millis = raw
3108 .funding_time
3109 .checked_sub(window[1].funding_time)
3110 .context("funding interval negative, funding rates out of order")?;
3111 let rate = parse_funding_rate(raw, instrument_id, Some(interval_millis))?;
3112 rates.push(rate);
3113 }
3114
3115 if let Some(last_raw) = resp.last() {
3116 let rate = parse_funding_rate(last_raw, instrument_id, None)?;
3118 rates.push(rate);
3119 }
3120
3121 rates.reverse();
3124
3125 log::debug!(
3126 "Fetched {} funding rates for {}",
3127 rates.len(),
3128 instrument_id,
3129 );
3130
3131 Ok(rates)
3132 }
3133
3134 pub async fn request_trades(
3140 &self,
3141 instrument_id: InstrumentId,
3142 start: Option<Timestamp>,
3143 end: Option<Timestamp>,
3144 limit: Option<u32>,
3145 ) -> anyhow::Result<Vec<TradeTick>> {
3146 const OKX_TRADES_MAX_LIMIT: u32 = 100;
3147 const MAX_PAGES: usize = 500;
3148 const MAX_CONSECUTIVE_EMPTY: usize = 3;
3149
3150 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3151 enum Mode {
3152 Latest,
3153 Backward,
3154 Range,
3155 }
3156
3157 let limit = if limit == Some(0) { None } else { limit };
3158
3159 if let (Some(s), Some(e)) = (start, end) {
3160 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
3161 }
3162
3163 let now = Timestamp::now();
3164
3165 if let Some(s) = start
3166 && s > now
3167 {
3168 return Ok(Vec::new());
3169 }
3170
3171 let end = if let Some(e) = end
3172 && e > now
3173 {
3174 Some(now)
3175 } else {
3176 end
3177 };
3178
3179 let mode = match (start, end) {
3180 (None, None) => Mode::Latest,
3181 (Some(_), None) => Mode::Backward,
3182 (None, Some(_)) => Mode::Backward,
3183 (Some(_), Some(_)) => Mode::Range,
3184 };
3185
3186 let start_ms = start.map(|s| s.as_millisecond());
3187 let end_ms = end.map(|e| e.as_millisecond());
3188
3189 let ts_init = self.generate_ts_init();
3190 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3191
3192 if matches!(mode, Mode::Backward | Mode::Range) {
3195 let mut before_trade_id: Option<String> = None;
3196 let mut pages = 0usize;
3197 let mut page_results: Vec<Vec<TradeTick>> = Vec::new();
3198 let mut seen_trades: AHashSet<(String, i64)> = AHashSet::new();
3199 let mut unique_count = 0usize;
3200 let mut consecutive_empty_pages = 0usize;
3201
3202 let effective_limit = if start.is_some() {
3205 limit.unwrap_or(u32::MAX)
3206 } else {
3207 limit.unwrap_or(OKX_TRADES_MAX_LIMIT)
3208 };
3209
3210 log::debug!(
3211 "Starting trades pagination: mode={mode:?}, start={start:?}, end={end:?}, limit={limit:?}, effective_limit={effective_limit}"
3212 );
3213
3214 loop {
3215 if pages >= MAX_PAGES {
3216 log::warn!("Hit MAX_PAGES limit of {MAX_PAGES}");
3217 break;
3218 }
3219
3220 if effective_limit < u32::MAX && unique_count >= effective_limit as usize {
3221 log::debug!("Reached effective limit: unique_count={unique_count}");
3222 break;
3223 }
3224
3225 let remaining = (effective_limit as usize).saturating_sub(unique_count);
3226 let page_cap = remaining.min(OKX_TRADES_MAX_LIMIT as usize) as u32;
3227
3228 log::debug!(
3229 "Requesting page {}: before_id={:?}, page_cap={}, unique_count={}",
3230 pages + 1,
3231 before_trade_id,
3232 page_cap,
3233 unique_count
3234 );
3235
3236 let mut params_builder = GetTradesParamsBuilder::default();
3237 params_builder
3238 .inst_id(instrument_id.symbol.inner())
3239 .limit(page_cap)
3240 .pagination_type(1);
3241
3242 if let Some(ref before_id) = before_trade_id {
3244 params_builder.after(before_id.clone());
3245 }
3246
3247 let params = params_builder.build().map_err(anyhow::Error::new)?;
3248 let raw = self
3249 .inner
3250 .get_history_trades(params)
3251 .await
3252 .map_err(anyhow::Error::new)?;
3253
3254 log::debug!("Received {} raw trades from API", raw.len());
3255
3256 if let (Some(first), Some(last)) = (raw.first(), raw.last()) {
3257 log::debug!(
3258 "Raw response trade ID range: first={} (newest), last={} (oldest)",
3259 first.trade_id,
3260 last.trade_id,
3261 );
3262 }
3263
3264 if raw.is_empty() {
3265 log::debug!("API returned empty page, stopping pagination");
3266 break;
3267 }
3268
3269 pages += 1;
3270
3271 let mut page_trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
3272 let mut hit_start_boundary = false;
3273 let mut filtered_out = 0usize;
3274 let mut duplicates = 0usize;
3275
3276 for r in &raw {
3277 match parse_trade_tick(
3278 r,
3279 instrument_id,
3280 inst.price_precision(),
3281 inst.size_precision(),
3282 ts_init,
3283 ) {
3284 Ok(trade) => {
3285 let ts_ms = trade.ts_event.as_i64() / 1_000_000;
3286
3287 if let Some(e_ms) = end_ms
3288 && ts_ms > e_ms
3289 {
3290 filtered_out += 1;
3291 continue;
3292 }
3293
3294 if let Some(s_ms) = start_ms
3295 && ts_ms < s_ms
3296 {
3297 hit_start_boundary = true;
3298 filtered_out += 1;
3299 break;
3300 }
3301
3302 let trade_key = (trade.trade_id.to_string(), trade.ts_event.as_i64());
3303 if seen_trades.insert(trade_key) {
3304 unique_count += 1;
3305 page_trades.push(trade);
3306 } else {
3307 duplicates += 1;
3308 }
3309 }
3310 Err(e) => log::error!("{e}"),
3311 }
3312 }
3313
3314 log::debug!(
3315 "Page {} processed: {} trades kept, {} filtered out, {} duplicates, hit_start_boundary={}",
3316 pages,
3317 page_trades.len(),
3318 filtered_out,
3319 duplicates,
3320 hit_start_boundary
3321 );
3322
3323 let oldest_trade_id = if page_trades.is_empty() {
3325 if unique_count > 0 {
3328 consecutive_empty_pages += 1;
3329 if consecutive_empty_pages >= MAX_CONSECUTIVE_EMPTY {
3330 log::debug!(
3331 "Stopping: {consecutive_empty_pages} consecutive pages with no trades in range after collecting {unique_count} trades"
3332 );
3333 break;
3334 }
3335 }
3336 raw.last().map(|t| {
3338 let id = t.trade_id.to_string();
3339 log::debug!(
3340 "Setting cursor from raw response (no unique trades): oldest_id={id}"
3341 );
3342 id
3343 })
3344 } else {
3345 let oldest_id = page_trades.last().map(|t| {
3347 let id = t.trade_id.to_string();
3348 log::debug!(
3349 "Setting cursor from deduplicated trades: oldest_id={}, ts_event={}",
3350 id,
3351 t.ts_event.as_i64()
3352 );
3353 id
3354 });
3355 page_trades.reverse();
3356 page_results.push(page_trades);
3357 consecutive_empty_pages = 0;
3358 oldest_id
3359 };
3360
3361 if let Some(ref old_id) = before_trade_id
3362 && oldest_trade_id.as_ref() == Some(old_id)
3363 {
3364 break;
3365 }
3366
3367 if oldest_trade_id.is_none() {
3368 break;
3369 }
3370
3371 before_trade_id = oldest_trade_id;
3372
3373 if hit_start_boundary {
3374 break;
3375 }
3376
3377 tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
3378 }
3379
3380 log::debug!(
3381 "Pagination complete: {pages} pages, {unique_count} unique trades collected"
3382 );
3383
3384 let mut out: Vec<TradeTick> = Vec::new();
3385
3386 for page in page_results.into_iter().rev() {
3387 out.extend(page);
3388 }
3389
3390 let mut dedup_keys = AHashSet::new();
3392 let pre_dedup_len = out.len();
3393 out.retain(|trade| {
3394 dedup_keys.insert((trade.trade_id.to_string(), trade.ts_event.as_i64()))
3395 });
3396
3397 if out.len() < pre_dedup_len {
3398 log::debug!(
3399 "Removed {} duplicate trades during final dedup",
3400 pre_dedup_len - out.len()
3401 );
3402 }
3403
3404 if let Some(lim) = limit
3405 && lim > 0
3406 && out.len() > lim as usize
3407 {
3408 let excess = out.len() - lim as usize;
3409 log::debug!("Trimming {excess} oldest trades to respect limit={lim}");
3410 out.drain(0..excess);
3411 }
3412
3413 log::debug!("Returning {} trades", out.len());
3414 return Ok(out);
3415 }
3416
3417 let req_limit = limit
3418 .unwrap_or(OKX_TRADES_MAX_LIMIT)
3419 .min(OKX_TRADES_MAX_LIMIT);
3420 let params = GetTradesParamsBuilder::default()
3421 .inst_id(instrument_id.symbol.inner())
3422 .limit(req_limit)
3423 .build()
3424 .map_err(anyhow::Error::new)?;
3425
3426 let raw = self
3427 .inner
3428 .get_history_trades(params)
3429 .await
3430 .map_err(anyhow::Error::new)?;
3431
3432 let mut trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
3433
3434 for r in &raw {
3435 match parse_trade_tick(
3436 r,
3437 instrument_id,
3438 inst.price_precision(),
3439 inst.size_precision(),
3440 ts_init,
3441 ) {
3442 Ok(trade) => trades.push(trade),
3443 Err(e) => log::error!("{e}"),
3444 }
3445 }
3446
3447 trades.reverse();
3449
3450 if let Some(lim) = limit
3451 && lim > 0
3452 && trades.len() > lim as usize
3453 {
3454 trades.drain(0..trades.len() - lim as usize);
3455 }
3456
3457 Ok(trades)
3458 }
3459
3460 pub async fn request_bars(
3501 &self,
3502 bar_type: BarType,
3503 start: Option<Timestamp>,
3504 mut end: Option<Timestamp>,
3505 limit: Option<u32>,
3506 ) -> anyhow::Result<Vec<Bar>> {
3507 const HISTORY_SPLIT_DAYS: i64 = 100;
3508 const MAX_PAGES_SOFT: usize = 500;
3509
3510 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3511 enum Mode {
3512 Latest,
3513 Backward,
3514 Range,
3515 }
3516
3517 let limit = if limit == Some(0) { None } else { limit };
3518
3519 anyhow::ensure!(
3520 bar_type.aggregation_source() == AggregationSource::External,
3521 "Only EXTERNAL aggregation is supported"
3522 );
3523
3524 if let (Some(s), Some(e)) = (start, end) {
3525 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
3526 }
3527
3528 let now = Timestamp::now();
3529
3530 if let Some(s) = start
3531 && s > now
3532 {
3533 return Ok(Vec::new());
3534 }
3535
3536 if let Some(e) = end
3537 && e > now
3538 {
3539 end = Some(now);
3540 }
3541
3542 let spec = bar_type.spec();
3543 let step = spec.step.get();
3544 let bar_param = match spec.aggregation {
3545 BarAggregation::Second => format!("{step}s"),
3546 BarAggregation::Minute => format!("{step}m"),
3547 BarAggregation::Hour => format!("{step}H"),
3548 BarAggregation::Day => format!("{step}D"),
3549 BarAggregation::Week => format!("{step}W"),
3550 BarAggregation::Month => format!("{step}M"),
3551 a => anyhow::bail!("OKX does not support {a:?} aggregation"),
3552 };
3553
3554 let slot_ms: i64 = match spec.aggregation {
3555 BarAggregation::Second => (step as i64) * 1_000,
3556 BarAggregation::Minute => (step as i64) * 60_000,
3557 BarAggregation::Hour => (step as i64) * 3_600_000,
3558 BarAggregation::Day => (step as i64) * 86_400_000,
3559 BarAggregation::Week => (step as i64) * 7 * 86_400_000,
3560 BarAggregation::Month => (step as i64) * 30 * 86_400_000,
3561 _ => unreachable!("Unsupported aggregation should have been caught above"),
3562 };
3563 let slot_ns: i64 = slot_ms * 1_000_000;
3564
3565 let mode = match (start, end) {
3566 (None, None) => Mode::Latest,
3567 (Some(_), None) => Mode::Backward, (None, Some(_)) => Mode::Backward,
3569 (Some(_), Some(_)) => Mode::Range,
3570 };
3571
3572 let start_ns = start.and_then(|s| i64::try_from(s.as_nanosecond()).ok());
3573 let end_ns = end.and_then(|e| i64::try_from(e.as_nanosecond()).ok());
3574
3575 let start_ms = start.map(|s| {
3577 let ms = s.as_millisecond();
3578
3579 if slot_ms > 0 {
3580 (ms / slot_ms) * slot_ms } else {
3582 ms
3583 }
3584 });
3585 let end_ms = end.map(|e| {
3586 let ms = e.as_millisecond();
3587
3588 if slot_ms > 0 {
3589 ((ms + slot_ms - 1) / slot_ms) * slot_ms } else {
3591 ms
3592 }
3593 });
3594 let now_ms = now.as_millisecond();
3595
3596 let instrument_id = bar_type.instrument_id();
3597 let symbol = instrument_id.symbol;
3598 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3599
3600 let mut out: Vec<Bar> = Vec::new();
3601 let mut pages = 0usize;
3602
3603 let mut after_ms: Option<i64> = match mode {
3608 Mode::Range => end_ms.or(Some(now_ms)), _ => None,
3610 };
3611 let mut before_ms: Option<i64> = match mode {
3612 Mode::Backward => end_ms.map(|v| v.saturating_sub(1)),
3613 Mode::Range => start_ms, Mode::Latest => None,
3615 };
3616
3617 let mut forward_prepend_mode = matches!(mode, Mode::Range);
3619
3620 if matches!(mode, Mode::Backward | Mode::Range)
3624 && let Some(b) = before_ms
3625 {
3626 let buffer_ms = slot_ms.max(60_000); if b >= now_ms.saturating_sub(buffer_ms) {
3632 before_ms = Some(now_ms.saturating_sub(buffer_ms));
3633 }
3634 }
3635
3636 let mut have_latest_first_page = false;
3637 let mut progressless_loops = 0u8;
3638
3639 loop {
3640 if let Some(lim) = limit
3641 && lim > 0
3642 && out.len() >= lim as usize
3643 {
3644 break;
3645 }
3646
3647 if pages >= MAX_PAGES_SOFT {
3648 break;
3649 }
3650
3651 let pivot_ms = if let Some(a) = after_ms {
3652 a
3653 } else if let Some(b) = before_ms {
3654 b
3655 } else {
3656 now_ms
3657 };
3658 let age_ms = now_ms.saturating_sub(pivot_ms);
3664 let age_hours = age_ms / (60 * 60 * 1000);
3665 let using_history = age_hours > 1; let page_ceiling = if using_history { 100 } else { 300 };
3668 let remaining = limit
3669 .filter(|&l| l > 0) .map_or(page_ceiling, |l| (l as usize).saturating_sub(out.len()));
3671 let page_cap = remaining.min(page_ceiling);
3672
3673 let mut p = GetCandlesticksParamsBuilder::default();
3674 p.inst_id(symbol.as_str())
3675 .bar(&bar_param)
3676 .limit(page_cap as u32);
3677
3678 let mut req_used_before = false;
3680
3681 match mode {
3682 Mode::Latest => {
3683 if have_latest_first_page && let Some(b) = before_ms {
3684 p.before_ms(b);
3685 req_used_before = true;
3686 }
3687 }
3688 Mode::Backward => {
3689 if let Some(b) = before_ms {
3691 p.after_ms(b);
3692 }
3693 }
3694 Mode::Range => {
3695 if let Some(a) = after_ms {
3698 p.after_ms(a);
3699 }
3700
3701 if let Some(b) = before_ms {
3702 p.before_ms(b);
3703 req_used_before = true;
3704 }
3705 }
3706 }
3707
3708 let params = p.build().map_err(anyhow::Error::new)?;
3709
3710 let mut raw = if using_history {
3711 self.inner
3712 .get_history_candles(params.clone())
3713 .await
3714 .map_err(anyhow::Error::new)?
3715 } else {
3716 self.inner
3717 .get_candles(params.clone())
3718 .await
3719 .map_err(anyhow::Error::new)?
3720 };
3721
3722 if raw.is_empty() {
3724 if matches!(mode, Mode::Latest)
3726 && have_latest_first_page
3727 && !using_history
3728 && let Some(b) = before_ms
3729 {
3730 let mut p2 = GetCandlesticksParamsBuilder::default();
3731 p2.inst_id(symbol.as_str())
3732 .bar(&bar_param)
3733 .limit(page_cap as u32);
3734 p2.before_ms(b);
3735 let params2 = p2.build().map_err(anyhow::Error::new)?;
3736 let raw2 = self
3737 .inner
3738 .get_history_candles(params2)
3739 .await
3740 .map_err(anyhow::Error::new)?;
3741
3742 if raw2.is_empty() {
3743 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
3745 before_ms = Some(b.saturating_sub(jump));
3746 progressless_loops = progressless_loops.saturating_add(1);
3747 if progressless_loops >= 3 {
3748 break;
3749 }
3750 continue;
3751 } else {
3752 raw = raw2;
3753 }
3754 }
3755
3756 if raw.is_empty() && matches!(mode, Mode::Range) && pages > 0 {
3760 let backstep_ms = (page_cap as i64).saturating_mul(slot_ms.max(1));
3761 let pivot_back = after_ms.unwrap_or(now_ms).saturating_sub(backstep_ms);
3762
3763 let mut p2 = GetCandlesticksParamsBuilder::default();
3764 p2.inst_id(symbol.as_str())
3765 .bar(&bar_param)
3766 .limit(page_cap as u32)
3767 .before_ms(pivot_back);
3768 let params2 = p2.build().map_err(anyhow::Error::new)?;
3769 let raw2 = if (now_ms.saturating_sub(pivot_back)) / (24 * 60 * 60 * 1000)
3770 > HISTORY_SPLIT_DAYS
3771 {
3772 self.inner.get_history_candles(params2).await
3773 } else {
3774 self.inner.get_candles(params2).await
3775 }
3776 .map_err(anyhow::Error::new)?;
3777
3778 if raw2.is_empty() {
3779 break;
3780 } else {
3781 raw = raw2;
3782 forward_prepend_mode = true;
3783 req_used_before = true;
3784 }
3785 }
3786
3787 if raw.is_empty()
3789 && matches!(mode, Mode::Latest)
3790 && !have_latest_first_page
3791 && !using_history
3792 {
3793 let jump_days_ms = (HISTORY_SPLIT_DAYS + 1) * 86_400_000;
3794 before_ms = Some(now_ms.saturating_sub(jump_days_ms));
3795 have_latest_first_page = true;
3796 continue;
3797 }
3798
3799 if raw.is_empty() {
3801 break;
3802 }
3803 }
3804 pages += 1;
3807
3808 let ts_init = self.generate_ts_init();
3810 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
3811
3812 for r in &raw {
3813 page.push(parse_candlestick(
3814 r,
3815 bar_type,
3816 inst.price_precision(),
3817 inst.size_precision(),
3818 ts_init,
3819 )?);
3820 }
3821 page.reverse();
3822
3823 let page_oldest_ms = page.first().map(|b| b.ts_event.as_i64() / 1_000_000);
3824 let page_newest_ms = page.last().map(|b| b.ts_event.as_i64() / 1_000_000);
3825
3826 let mut filtered: Vec<Bar> = if matches!(mode, Mode::Range)
3830 && out.is_empty()
3831 && pages < 2
3832 {
3833 let tolerance_ns = slot_ns * 2; if let (Some(first), Some(last)) = (page.first(), page.last()) {
3840 log::debug!(
3841 "Range mode bootstrap page: {} bars from {} to {}, filtering with start={:?} end={:?}",
3842 page.len(),
3843 first.ts_event.as_i64() / 1_000_000,
3844 last.ts_event.as_i64() / 1_000_000,
3845 start_ms,
3846 end_ms,
3847 );
3848 }
3849
3850 let result: Vec<Bar> = page
3851 .clone()
3852 .into_iter()
3853 .filter(|b| {
3854 let ts = b.ts_event.as_i64();
3855 let ok_after =
3857 start_ns.is_none_or(|sns| ts >= sns.saturating_sub(tolerance_ns));
3858 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
3859 ok_after && ok_before
3860 })
3861 .collect();
3862
3863 result
3864 } else {
3865 page.clone()
3867 .into_iter()
3868 .filter(|b| {
3869 let ts = b.ts_event.as_i64();
3870 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
3871 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
3872 ok_after && ok_before
3873 })
3874 .collect()
3875 };
3876
3877 if !page.is_empty() && filtered.is_empty() {
3878 if matches!(mode, Mode::Range)
3880 && !forward_prepend_mode
3881 && let (Some(newest_ms), Some(start_ms)) = (page_newest_ms, start_ms)
3882 && newest_ms < start_ms.saturating_sub(slot_ms * 2)
3883 {
3884 break;
3886 }
3887 }
3888
3889 let contribution;
3891
3892 if out.is_empty() {
3893 contribution = filtered.len();
3894 out = filtered;
3895 } else {
3896 match mode {
3897 Mode::Backward | Mode::Latest => {
3898 if let Some(first) = out.first() {
3899 filtered.retain(|b| b.ts_event < first.ts_event);
3900 }
3901 contribution = filtered.len();
3902 if contribution != 0 {
3903 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
3904 new_out.extend_from_slice(&filtered);
3905 new_out.extend_from_slice(&out);
3906 out = new_out;
3907 }
3908 }
3909 Mode::Range => {
3910 if forward_prepend_mode || req_used_before {
3911 if let Some(first) = out.first() {
3913 filtered.retain(|b| b.ts_event < first.ts_event);
3914 }
3915 contribution = filtered.len();
3916 if contribution != 0 {
3917 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
3918 new_out.extend_from_slice(&filtered);
3919 new_out.extend_from_slice(&out);
3920 out = new_out;
3921 }
3922 } else {
3923 if let Some(last) = out.last() {
3925 filtered.retain(|b| b.ts_event > last.ts_event);
3926 }
3927 contribution = filtered.len();
3928 out.extend(filtered);
3929 }
3930 }
3931 }
3932 }
3933
3934 if contribution == 0
3936 && matches!(mode, Mode::Latest | Mode::Backward | Mode::Range)
3937 && let Some(b) = before_ms
3938 {
3939 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
3940 let new_b = b.saturating_sub(jump);
3941 if new_b != b {
3942 before_ms = Some(new_b);
3943 }
3944 }
3945
3946 if contribution == 0 {
3947 progressless_loops = progressless_loops.saturating_add(1);
3948 if progressless_loops >= 3 {
3949 break;
3950 }
3951 } else {
3952 progressless_loops = 0;
3953
3954 match mode {
3956 Mode::Latest | Mode::Backward => {
3957 if let Some(oldest) = page_oldest_ms {
3958 before_ms = Some(oldest.saturating_sub(1));
3959 have_latest_first_page = true;
3960 } else {
3961 break;
3962 }
3963 }
3964 Mode::Range => {
3965 if forward_prepend_mode || req_used_before {
3966 if let Some(oldest) = page_oldest_ms {
3967 let jump_back = slot_ms.max(60_000); before_ms = Some(oldest.saturating_sub(jump_back));
3970 after_ms = None;
3971 } else {
3972 break;
3973 }
3974 } else if let Some(newest) = page_newest_ms {
3975 after_ms = Some(newest.saturating_add(1));
3976 before_ms = None;
3977 } else {
3978 break;
3979 }
3980 }
3981 }
3982 }
3983
3984 if let Some(lim) = limit
3986 && lim > 0
3987 && out.len() >= lim as usize
3988 {
3989 break;
3990 }
3991
3992 if let Some(ens) = end_ns
3993 && let Some(last) = out.last()
3994 && last.ts_event.as_i64() >= ens
3995 {
3996 break;
3997 }
3998
3999 if let Some(sns) = start_ns
4000 && let Some(first) = out.first()
4001 && (matches!(mode, Mode::Backward) || forward_prepend_mode)
4002 && first.ts_event.as_i64() <= sns
4003 {
4004 if matches!(mode, Mode::Range) {
4006 if let Some(ens) = end_ns
4008 && let Some(last) = out.last()
4009 {
4010 let last_ts = last.ts_event.as_i64();
4011 if last_ts < ens {
4012 forward_prepend_mode = false;
4015 after_ms = Some((last_ts / 1_000_000).saturating_add(1));
4016 before_ms = None;
4017 continue;
4018 }
4019 }
4020 }
4021 break;
4022 }
4023
4024 tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
4025 }
4026
4027 if out.is_empty() && matches!(mode, Mode::Range) {
4029 let pivot = end_ms.unwrap_or(now_ms.saturating_sub(1));
4030 let hist = (now_ms.saturating_sub(pivot)) / (24 * 60 * 60 * 1000) > HISTORY_SPLIT_DAYS;
4031 let mut p = GetCandlesticksParamsBuilder::default();
4032 p.inst_id(symbol.as_str())
4033 .bar(&bar_param)
4034 .limit(300)
4035 .before_ms(pivot);
4036 let params = p.build().map_err(anyhow::Error::new)?;
4037 let raw = if hist {
4038 self.inner.get_history_candles(params).await
4039 } else {
4040 self.inner.get_candles(params).await
4041 }
4042 .map_err(anyhow::Error::new)?;
4043
4044 if !raw.is_empty() {
4045 let ts_init = self.generate_ts_init();
4046 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
4047
4048 for r in &raw {
4049 page.push(parse_candlestick(
4050 r,
4051 bar_type,
4052 inst.price_precision(),
4053 inst.size_precision(),
4054 ts_init,
4055 )?);
4056 }
4057 page.reverse();
4058 out = page
4059 .into_iter()
4060 .filter(|b| {
4061 let ts = b.ts_event.as_i64();
4062 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
4063 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
4064 ok_after && ok_before
4065 })
4066 .collect();
4067 }
4068 }
4069
4070 if let Some(ens) = end_ns {
4072 while out.last().is_some_and(|b| b.ts_event.as_i64() > ens) {
4073 out.pop();
4074 }
4075 }
4076
4077 if matches!(mode, Mode::Range)
4079 && !forward_prepend_mode
4080 && let Some(sns) = start_ns
4081 {
4082 let lower = sns.saturating_sub(slot_ns);
4083 while out.first().is_some_and(|b| b.ts_event.as_i64() < lower) {
4084 out.remove(0);
4085 }
4086 }
4087
4088 if let Some(lim) = limit
4090 && lim > 0
4091 && out.len() > lim as usize
4092 {
4093 let start = out.len() - lim as usize;
4094 out.drain(..start);
4095 }
4096
4097 Ok(out)
4098 }
4099
4100 #[expect(clippy::too_many_arguments)]
4111 pub async fn request_order_status_reports(
4112 &self,
4113 account_id: AccountId,
4114 instrument_type: Option<OKXInstrumentType>,
4115 instrument_id: Option<InstrumentId>,
4116 start: Option<Timestamp>,
4117 end: Option<Timestamp>,
4118 open_only: bool,
4119 limit: Option<u32>,
4120 ) -> anyhow::Result<Vec<OrderStatusReport>> {
4121 Ok(self
4122 .request_order_status_reports_scoped(
4123 account_id,
4124 instrument_type,
4125 instrument_id,
4126 start,
4127 end,
4128 open_only,
4129 limit,
4130 None,
4131 )
4132 .await?
4133 .reports)
4134 }
4135
4136 #[expect(clippy::too_many_arguments)]
4137 pub(crate) async fn request_order_status_reports_scoped(
4138 &self,
4139 account_id: AccountId,
4140 instrument_type: Option<OKXInstrumentType>,
4141 instrument_id: Option<InstrumentId>,
4142 start: Option<Timestamp>,
4143 end: Option<Timestamp>,
4144 open_only: bool,
4145 limit: Option<u32>,
4146 scope: Option<ReportInstrumentScope<'_>>,
4147 ) -> anyhow::Result<ReportSweep<OrderStatusReport>> {
4148 if instrument_id
4149 .as_ref()
4150 .is_some_and(|id| is_okx_spread_symbol(id.symbol.as_str()))
4151 || (instrument_id.is_none() && instrument_type.is_none())
4152 {
4153 return self
4154 .request_spread_order_status_reports_scoped(
4155 account_id,
4156 instrument_id,
4157 start,
4158 end,
4159 open_only,
4160 limit,
4161 scope,
4162 )
4163 .await;
4164 }
4165
4166 let instrument_type = if let Some(instrument_type) = instrument_type {
4167 instrument_type
4168 } else {
4169 let instrument_id = instrument_id.ok_or_else(|| {
4170 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
4171 })?;
4172 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4173 okx_instrument_type(&instrument)?
4174 };
4175
4176 let mut history_base = GetOrderHistoryParamsBuilder::default();
4177 history_base.inst_type(instrument_type);
4178
4179 if let Some(instrument_id) = instrument_id.as_ref() {
4180 history_base.inst_id(instrument_id.symbol.inner().to_string());
4181 }
4182 let history_base = history_base.build().map_err(|e| anyhow::anyhow!(e))?;
4183
4184 let mut pending_base = GetOrderListParamsBuilder::default();
4185 pending_base.inst_type(instrument_type);
4186
4187 if let Some(instrument_id) = instrument_id.as_ref() {
4188 pending_base.inst_id(instrument_id.symbol.inner().to_string());
4189 }
4190 let pending_base = pending_base.build().map_err(|e| anyhow::anyhow!(e))?;
4191
4192 let (combined_resp, mut complete) = if open_only {
4193 let pending = self.paginate_orders_pending(&pending_base, limit).await?;
4194 (pending.items, pending.complete)
4195 } else {
4196 let (history, pending) = tokio::try_join!(
4197 self.paginate_orders_history(&history_base, limit),
4198 self.paginate_orders_pending(&pending_base, limit),
4199 )?;
4200 let mut combined_resp = history.items;
4201 combined_resp.extend(pending.items);
4202 (combined_resp, history.complete && pending.complete)
4203 };
4204
4205 let start_ns = start.map(UnixNanos::from);
4207 let end_ns = end.map(UnixNanos::from);
4208
4209 let ts_init = self.generate_ts_init();
4210 let mut reports = Vec::with_capacity(combined_resp.len());
4211
4212 let mut seen: AHashSet<String> = AHashSet::new();
4214
4215 for order in combined_resp {
4216 let seen_key = if !order.cl_ord_id.is_empty() {
4217 order.cl_ord_id.as_str().to_string()
4218 } else if let Some(algo_cl_ord_id) = order
4219 .algo_cl_ord_id
4220 .as_ref()
4221 .filter(|value| !value.as_str().is_empty())
4222 {
4223 algo_cl_ord_id.as_str().to_string()
4224 } else if let Some(algo_id) = order
4225 .algo_id
4226 .as_ref()
4227 .filter(|value| !value.as_str().is_empty())
4228 {
4229 algo_id.as_str().to_string()
4230 } else {
4231 order.ord_id.as_str().to_string()
4232 };
4233
4234 if !seen.insert(seen_key) {
4235 continue; }
4237
4238 if report_ts_outside_window(order.u_time, start_ns, end_ns)
4241 && !is_open_okx_order(order.state)
4242 {
4243 continue;
4244 }
4245
4246 let inst = match self.resolve_report_instrument(
4247 order.inst_id,
4248 order.inst_type,
4249 false,
4250 is_open_okx_order(order.state),
4251 scope,
4252 )? {
4253 InstrumentResolution::Found(inst) => inst,
4254 InstrumentResolution::Skip => continue,
4255 InstrumentResolution::Incomplete => {
4256 complete = false;
4257 continue;
4258 }
4259 };
4260
4261 let report = match parse_order_status_report(
4262 &order,
4263 account_id,
4264 inst.id(),
4265 inst.price_precision(),
4266 inst.size_precision(),
4267 ts_init,
4268 ) {
4269 Ok(report) => report,
4270 Err(e) => {
4271 log::warn!("Failed to parse order status report: {e}");
4272 complete = false;
4273 continue;
4274 }
4275 };
4276
4277 if let Some(start_ns) = start_ns
4278 && report.ts_last < start_ns
4279 && !report.order_status.is_open()
4280 {
4281 continue;
4282 }
4283
4284 if let Some(end_ns) = end_ns
4285 && report.ts_last > end_ns
4286 && !report.order_status.is_open()
4287 {
4288 continue;
4289 }
4290
4291 reports.push(report);
4292 }
4293
4294 Ok(ReportSweep { reports, complete })
4295 }
4296
4297 pub async fn request_order_status_report(
4303 &self,
4304 account_id: AccountId,
4305 instrument_id: InstrumentId,
4306 client_order_id: ClientOrderId,
4307 ) -> anyhow::Result<Option<OrderStatusReport>> {
4308 self.request_order_status_report_by_identifier(
4309 account_id,
4310 instrument_id,
4311 Some(client_order_id),
4312 None,
4313 )
4314 .await
4315 }
4316
4317 pub(crate) async fn request_order_status_report_by_venue_order_id(
4318 &self,
4319 account_id: AccountId,
4320 instrument_id: InstrumentId,
4321 venue_order_id: VenueOrderId,
4322 ) -> anyhow::Result<Option<OrderStatusReport>> {
4323 self.request_order_status_report_by_identifier(
4324 account_id,
4325 instrument_id,
4326 None,
4327 Some(venue_order_id),
4328 )
4329 .await
4330 }
4331
4332 async fn request_order_status_report_by_identifier(
4333 &self,
4334 account_id: AccountId,
4335 instrument_id: InstrumentId,
4336 client_order_id: Option<ClientOrderId>,
4337 venue_order_id: Option<VenueOrderId>,
4338 ) -> anyhow::Result<Option<OrderStatusReport>> {
4339 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4340 let mut params_builder = GetOrderParamsBuilder::default();
4341 params_builder.inst_id(instrument_id.symbol.inner().to_string());
4342
4343 match (client_order_id, venue_order_id) {
4344 (Some(client_order_id), None) => {
4345 params_builder.cl_ord_id(client_order_id.as_str().to_string());
4346 }
4347 (None, Some(venue_order_id)) => {
4348 params_builder.ord_id(venue_order_id.as_str().to_string());
4349 }
4350 _ => anyhow::bail!(
4351 "Exactly one of client_order_id or venue_order_id is required for an order detail request"
4352 ),
4353 }
4354
4355 let params = params_builder
4356 .build()
4357 .map_err(|e| anyhow::anyhow!("Failed to build order detail params: {e}"))?;
4358 let orders = match self.inner.get_order(params).await {
4359 Ok(orders) => orders,
4360 Err(e) if e.is_order_not_found() => return Ok(None),
4361 Err(e) => return Err(e.into()),
4362 };
4363 let order = match orders.as_slice() {
4364 [] => return Ok(None),
4365 [order] => order,
4366 _ => anyhow::bail!(
4367 "Order detail returned {} records for one identifier",
4368 orders.len(),
4369 ),
4370 };
4371
4372 if order.inst_id.as_str() != instrument_id.symbol.inner() {
4373 anyhow::bail!(
4374 "Order detail instrument mismatch for {instrument_id}: returned {}",
4375 order.inst_id,
4376 );
4377 }
4378
4379 if let Some(venue_order_id) = venue_order_id
4380 && order.ord_id.as_str() != venue_order_id.as_str()
4381 {
4382 anyhow::bail!(
4383 "Order detail venue order ID mismatch for {venue_order_id}: returned {}",
4384 order.ord_id,
4385 );
4386 }
4387
4388 let ts_init = self.generate_ts_init();
4389 let mut report = parse_order_status_report(
4390 order,
4391 account_id,
4392 instrument.id(),
4393 instrument.price_precision(),
4394 instrument.size_precision(),
4395 ts_init,
4396 )?;
4397 let post_only_rejected = order.state == OKXOrderStatus::Canceled
4398 && report.filled_qty.is_zero()
4399 && (order.cancel_source == OKX_POST_ONLY_CANCEL_SOURCE
4400 || order.cancel_source_reason.contains("POST_ONLY"));
4401 if post_only_rejected {
4402 report.order_status = OrderStatus::Rejected;
4403 report.post_only = true;
4404 let reason = if order.cancel_source_reason.is_empty() {
4405 OKX_POST_ONLY_CANCEL_REASON.to_string()
4406 } else {
4407 order.cancel_source_reason.clone()
4408 };
4409 report.cancel_reason = Some(reason);
4410 }
4411
4412 Ok(Some(report))
4413 }
4414
4415 pub async fn request_spread_order_status_reports(
4421 &self,
4422 account_id: AccountId,
4423 instrument_id: Option<InstrumentId>,
4424 start: Option<Timestamp>,
4425 end: Option<Timestamp>,
4426 open_only: bool,
4427 limit: Option<u32>,
4428 ) -> anyhow::Result<Vec<OrderStatusReport>> {
4429 Ok(self
4430 .request_spread_order_status_reports_scoped(
4431 account_id,
4432 instrument_id,
4433 start,
4434 end,
4435 open_only,
4436 limit,
4437 None,
4438 )
4439 .await?
4440 .reports)
4441 }
4442
4443 #[expect(clippy::too_many_arguments)]
4444 pub(crate) async fn request_spread_order_status_reports_scoped(
4445 &self,
4446 account_id: AccountId,
4447 instrument_id: Option<InstrumentId>,
4448 start: Option<Timestamp>,
4449 end: Option<Timestamp>,
4450 open_only: bool,
4451 limit: Option<u32>,
4452 scope: Option<ReportInstrumentScope<'_>>,
4453 ) -> anyhow::Result<ReportSweep<OrderStatusReport>> {
4454 let mut pending_builder = GetSpreadOrdersParamsBuilder::default();
4455 let mut history_builder = GetSpreadOrdersParamsBuilder::default();
4456
4457 if let Some(instrument_id) = instrument_id.as_ref() {
4458 let sprd_id = instrument_id.symbol.inner().to_string();
4459 pending_builder.sprd_id(sprd_id.clone());
4460 history_builder.sprd_id(sprd_id);
4461 }
4462
4463 if let Some(start) = start {
4464 history_builder.begin(start.as_millisecond().to_string());
4465 }
4466
4467 if let Some(end) = end {
4468 history_builder.end(end.as_millisecond().to_string());
4469 }
4470
4471 if let Some(limit) = spread_page_limit(limit) {
4472 pending_builder.limit(limit);
4473 history_builder.limit(limit);
4474 }
4475
4476 let pending_base = pending_builder.build().map_err(|e| anyhow::anyhow!(e))?;
4477 let history_base = history_builder.build().map_err(|e| anyhow::anyhow!(e))?;
4478
4479 let (combined_resp, mut complete) = if open_only {
4480 let pending = self
4481 .paginate_spread_orders_pending(&pending_base, limit)
4482 .await?;
4483 (pending.items, pending.complete)
4484 } else {
4485 let (history, pending) = tokio::try_join!(
4486 self.paginate_spread_orders_history(&history_base, limit),
4487 self.paginate_spread_orders_pending(&pending_base, limit),
4488 )?;
4489 let mut combined_resp = history.items;
4490 combined_resp.extend(pending.items);
4491 (combined_resp, history.complete && pending.complete)
4492 };
4493
4494 let start_ns = start.map(UnixNanos::from);
4495 let end_ns = end.map(UnixNanos::from);
4496 let ts_init = self.generate_ts_init();
4497 let mut reports = Vec::with_capacity(combined_resp.len());
4498 let mut seen: AHashSet<String> = AHashSet::new();
4499
4500 for order in combined_resp {
4501 let seen_key = if order.cl_ord_id.is_empty() {
4502 order.ord_id.as_str().to_string()
4503 } else {
4504 order.cl_ord_id.as_str().to_string()
4505 };
4506
4507 if !seen.insert(seen_key) {
4508 continue;
4509 }
4510
4511 if let Some(ts) = order.u_time.or(order.c_time)
4514 && report_ts_outside_window(ts, start_ns, end_ns)
4515 && !is_open_okx_order(order.state)
4516 {
4517 continue;
4518 }
4519
4520 let inst = match self.resolve_report_instrument(
4521 order.sprd_id,
4522 OKXInstrumentType::Any,
4523 true,
4524 is_open_okx_order(order.state),
4525 scope,
4526 )? {
4527 InstrumentResolution::Found(inst) => inst,
4528 InstrumentResolution::Skip => continue,
4529 InstrumentResolution::Incomplete => {
4530 complete = false;
4531 continue;
4532 }
4533 };
4534
4535 let report = match parse_spread_order_status_report(
4536 &order,
4537 account_id,
4538 inst.id(),
4539 inst.price_precision(),
4540 inst.size_precision(),
4541 ts_init,
4542 ) {
4543 Ok(report) => report,
4544 Err(e) => {
4545 log::warn!("Failed to parse spread order status report: {e}");
4546 complete = false;
4547 continue;
4548 }
4549 };
4550
4551 if let Some(start_ns) = start_ns
4552 && report.ts_last < start_ns
4553 && !report.order_status.is_open()
4554 {
4555 continue;
4556 }
4557
4558 if let Some(end_ns) = end_ns
4559 && report.ts_last > end_ns
4560 && !report.order_status.is_open()
4561 {
4562 continue;
4563 }
4564
4565 reports.push(report);
4566 }
4567
4568 Ok(ReportSweep { reports, complete })
4569 }
4570
4571 async fn paginate_spread_orders_history(
4572 &self,
4573 base: &GetSpreadOrdersParams,
4574 limit: Option<u32>,
4575 ) -> anyhow::Result<PageSweep<OKXSpreadOrder>> {
4576 let mut all = Vec::new();
4577 let mut cursor: Option<String> = None;
4578 let mut exhausted = true;
4579
4580 for _ in 0..MAX_RECONCILIATION_PAGES {
4581 let mut params = base.clone();
4582 params.end_id = cursor.take();
4583
4584 let page = self
4585 .inner
4586 .get_spread_orders_history(params)
4587 .await
4588 .map_err(|e| anyhow::anyhow!(e))?;
4589
4590 let page_len = page.len();
4591 cursor = page.last().map(|o| o.ord_id.to_string());
4592 all.extend(page);
4593
4594 if page_len < OKX_PAGE_SIZE {
4595 exhausted = false;
4596 break;
4597 }
4598
4599 if let Some(lim) = limit
4600 && all.len() >= lim as usize
4601 {
4602 exhausted = false;
4603 break;
4604 }
4605 }
4606
4607 if exhausted && !all.is_empty() {
4608 log::warn!(
4609 "Spread order history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4610 results may be truncated ({} records)",
4611 all.len()
4612 );
4613 }
4614
4615 if let Some(lim) = limit {
4616 all.truncate(lim as usize);
4617 }
4618
4619 Ok(PageSweep::from_pages(all, exhausted))
4620 }
4621
4622 async fn paginate_spread_orders_pending(
4623 &self,
4624 base: &GetSpreadOrdersParams,
4625 limit: Option<u32>,
4626 ) -> anyhow::Result<PageSweep<OKXSpreadOrder>> {
4627 let mut all = Vec::new();
4628 let mut cursor: Option<String> = None;
4629 let mut exhausted = true;
4630
4631 for _ in 0..MAX_RECONCILIATION_PAGES {
4632 let mut params = base.clone();
4633 params.end_id = cursor.take();
4634
4635 let page = self
4636 .inner
4637 .get_spread_orders_pending(params)
4638 .await
4639 .map_err(|e| anyhow::anyhow!(e))?;
4640
4641 let page_len = page.len();
4642 cursor = page.last().map(|o| o.ord_id.to_string());
4643 all.extend(page);
4644
4645 if page_len < OKX_PAGE_SIZE {
4646 exhausted = false;
4647 break;
4648 }
4649
4650 if let Some(lim) = limit
4651 && all.len() >= lim as usize
4652 {
4653 exhausted = false;
4654 break;
4655 }
4656 }
4657
4658 if exhausted && !all.is_empty() {
4659 log::warn!(
4660 "Pending spread orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4661 results may be truncated ({} records)",
4662 all.len()
4663 );
4664 }
4665
4666 if let Some(lim) = limit {
4667 all.truncate(lim as usize);
4668 }
4669
4670 Ok(PageSweep::from_pages(all, exhausted))
4671 }
4672
4673 async fn paginate_orders_history(
4675 &self,
4676 base: &GetOrderHistoryParams,
4677 limit: Option<u32>,
4678 ) -> anyhow::Result<PageSweep<OKXOrderHistory>> {
4679 let mut all = Vec::new();
4680 let mut cursor: Option<String> = None;
4681 let mut exhausted = true;
4682
4683 for _ in 0..MAX_RECONCILIATION_PAGES {
4684 let mut params = base.clone();
4685 params.after = cursor.take();
4686
4687 let page = self
4688 .inner
4689 .get_orders_history(params)
4690 .await
4691 .map_err(|e| anyhow::anyhow!(e))?;
4692
4693 let page_len = page.len();
4694 cursor = page.last().map(|o| o.ord_id.to_string());
4695 all.extend(page);
4696
4697 if page_len < OKX_PAGE_SIZE {
4698 exhausted = false;
4699 break;
4700 }
4701
4702 if let Some(lim) = limit
4703 && all.len() >= lim as usize
4704 {
4705 exhausted = false;
4706 break;
4707 }
4708 }
4709
4710 if exhausted && !all.is_empty() {
4711 log::warn!(
4712 "Order history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4713 results may be truncated ({} records)",
4714 all.len()
4715 );
4716 }
4717
4718 if let Some(lim) = limit {
4719 all.truncate(lim as usize);
4720 }
4721
4722 Ok(PageSweep::from_pages(all, exhausted))
4723 }
4724
4725 async fn paginate_orders_pending(
4727 &self,
4728 base: &GetOrderListParams,
4729 limit: Option<u32>,
4730 ) -> anyhow::Result<PageSweep<OKXOrderHistory>> {
4731 let mut all = Vec::new();
4732 let mut cursor: Option<String> = None;
4733 let mut exhausted = true;
4734
4735 for _ in 0..MAX_RECONCILIATION_PAGES {
4736 let mut params = base.clone();
4737 params.after = cursor.take();
4738
4739 let page = self
4740 .inner
4741 .get_orders_pending(params)
4742 .await
4743 .map_err(|e| anyhow::anyhow!(e))?;
4744
4745 let page_len = page.len();
4746 cursor = page.last().map(|o| o.ord_id.to_string());
4747 all.extend(page);
4748
4749 if page_len < OKX_PAGE_SIZE {
4750 exhausted = false;
4751 break;
4752 }
4753
4754 if let Some(lim) = limit
4755 && all.len() >= lim as usize
4756 {
4757 exhausted = false;
4758 break;
4759 }
4760 }
4761
4762 if exhausted && !all.is_empty() {
4763 log::warn!(
4764 "Pending orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4765 results may be truncated ({} records)",
4766 all.len()
4767 );
4768 }
4769
4770 if let Some(lim) = limit {
4771 all.truncate(lim as usize);
4772 }
4773
4774 Ok(PageSweep::from_pages(all, exhausted))
4775 }
4776
4777 async fn paginate_fills(
4779 &self,
4780 base: &GetTransactionDetailsParams,
4781 limit: Option<u32>,
4782 history: FillHistory,
4783 ) -> anyhow::Result<PageSweep<OKXTransactionDetail>> {
4784 let mut all = Vec::new();
4785 let mut cursor: Option<String> = None;
4786 let mut exhausted = true;
4787
4788 for _ in 0..MAX_RECONCILIATION_PAGES {
4789 let mut params = base.clone();
4790 params.after = cursor.take();
4791
4792 let page = match history {
4793 FillHistory::Recent => self.inner.get_fills(params).await,
4794 FillHistory::Extended => self.inner.get_fills_history(params).await,
4795 }
4796 .map_err(|e| anyhow::anyhow!(e))?;
4797
4798 let page_len = page.len();
4799 cursor = page.last().map(|o| o.bill_id.to_string());
4800 all.extend(page);
4801
4802 if page_len < OKX_PAGE_SIZE {
4803 exhausted = false;
4804 break;
4805 }
4806
4807 if let Some(lim) = limit
4808 && all.len() >= lim as usize
4809 {
4810 exhausted = false;
4811 break;
4812 }
4813 }
4814
4815 if exhausted && !all.is_empty() {
4816 log::warn!(
4817 "Fill pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4818 results may be truncated ({} records)",
4819 all.len()
4820 );
4821 }
4822
4823 if let Some(lim) = limit {
4824 all.truncate(lim as usize);
4825 }
4826
4827 Ok(PageSweep::from_pages(all, exhausted))
4828 }
4829
4830 async fn paginate_algo_pending(
4832 &self,
4833 base: &GetAlgoOrdersParams,
4834 limit: Option<usize>,
4835 ) -> anyhow::Result<PageSweep<OKXOrderAlgo>> {
4836 let mut all = Vec::new();
4837 let mut cursor: Option<String> = None;
4838 let mut exhausted = true;
4839
4840 for _ in 0..MAX_RECONCILIATION_PAGES {
4841 let mut params = base.clone();
4842 params.after = cursor.take();
4843
4844 let page = match self.inner.get_order_algo_pending(params).await {
4845 Ok(result) => result,
4846 Err(OKXHttpError::UnexpectedStatus { status, .. })
4847 if status == StatusCode::NOT_FOUND =>
4848 {
4849 exhausted = false;
4850 break;
4851 }
4852 Err(e) => return Err(e.into()),
4853 };
4854
4855 let page_len = page.len();
4856 cursor = page.last().map(|o| o.algo_id.clone());
4857 all.extend(page);
4858
4859 if page_len < OKX_PAGE_SIZE {
4860 exhausted = false;
4861 break;
4862 }
4863
4864 if let Some(lim) = limit
4865 && all.len() >= lim
4866 {
4867 exhausted = false;
4868 break;
4869 }
4870 }
4871
4872 if exhausted && !all.is_empty() {
4873 log::warn!(
4874 "Algo pending pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4875 results may be truncated ({} records)",
4876 all.len()
4877 );
4878 }
4879
4880 Ok(PageSweep::from_pages(all, exhausted))
4881 }
4882
4883 async fn paginate_algo_history(
4885 &self,
4886 base: &GetAlgoOrdersParams,
4887 limit: Option<usize>,
4888 ) -> anyhow::Result<PageSweep<OKXOrderAlgo>> {
4889 let mut all = Vec::new();
4890 let mut cursor: Option<String> = None;
4891 let mut exhausted = true;
4892
4893 for _ in 0..MAX_RECONCILIATION_PAGES {
4894 let mut params = base.clone();
4895 params.after = cursor.take();
4896
4897 let page = match self.inner.get_order_algo_history(params).await {
4898 Ok(result) => result,
4899 Err(OKXHttpError::UnexpectedStatus { status, .. })
4900 if status == StatusCode::NOT_FOUND =>
4901 {
4902 exhausted = false;
4903 break;
4904 }
4905 Err(e) => return Err(e.into()),
4906 };
4907
4908 let page_len = page.len();
4909 cursor = page.last().map(|o| o.algo_id.clone());
4910 all.extend(page);
4911
4912 if page_len < OKX_PAGE_SIZE {
4913 exhausted = false;
4914 break;
4915 }
4916
4917 if let Some(lim) = limit
4918 && all.len() >= lim
4919 {
4920 exhausted = false;
4921 break;
4922 }
4923 }
4924
4925 if exhausted && !all.is_empty() {
4926 log::warn!(
4927 "Algo history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4928 results may be truncated ({} records)",
4929 all.len()
4930 );
4931 }
4932
4933 Ok(PageSweep::from_pages(all, exhausted))
4934 }
4935
4936 pub async fn request_fill_reports(
4946 &self,
4947 account_id: AccountId,
4948 instrument_type: Option<OKXInstrumentType>,
4949 instrument_id: Option<InstrumentId>,
4950 start: Option<Timestamp>,
4951 end: Option<Timestamp>,
4952 limit: Option<u32>,
4953 ) -> anyhow::Result<Vec<FillReport>> {
4954 Ok(self
4955 .request_fill_reports_scoped(
4956 account_id,
4957 instrument_type,
4958 instrument_id,
4959 start,
4960 end,
4961 limit,
4962 FillHistory::Recent,
4963 None,
4964 )
4965 .await?
4966 .reports)
4967 }
4968
4969 #[expect(clippy::too_many_arguments)]
4970 pub(crate) async fn request_fill_reports_scoped(
4971 &self,
4972 account_id: AccountId,
4973 instrument_type: Option<OKXInstrumentType>,
4974 instrument_id: Option<InstrumentId>,
4975 start: Option<Timestamp>,
4976 end: Option<Timestamp>,
4977 limit: Option<u32>,
4978 history: FillHistory,
4979 scope: Option<ReportInstrumentScope<'_>>,
4980 ) -> anyhow::Result<ReportSweep<FillReport>> {
4981 if instrument_id
4982 .as_ref()
4983 .is_some_and(|id| is_okx_spread_symbol(id.symbol.as_str()))
4984 || (instrument_id.is_none() && instrument_type.is_none())
4985 {
4986 return self
4987 .request_spread_fill_reports_scoped(
4988 account_id,
4989 instrument_id,
4990 start,
4991 end,
4992 limit,
4993 scope,
4994 )
4995 .await;
4996 }
4997
4998 let mut params = GetTransactionDetailsParamsBuilder::default();
4999
5000 let instrument_type = if let Some(instrument_type) = instrument_type {
5001 instrument_type
5002 } else {
5003 let instrument_id = instrument_id.ok_or_else(|| {
5004 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
5005 })?;
5006 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5007 okx_instrument_type(&instrument)?
5008 };
5009
5010 params.inst_type(instrument_type);
5011
5012 if let Some(instrument_id) = instrument_id {
5013 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5014 let instrument_type = okx_instrument_type(&instrument)?;
5015 params.inst_type(instrument_type);
5016 params.inst_id(instrument_id.symbol.inner().to_string());
5017 }
5018
5019 if let Some(start) = start {
5020 params.begin(start.as_millisecond().to_string());
5021 }
5022
5023 if let Some(end) = end {
5024 params.end(end.as_millisecond().to_string());
5025 }
5026
5027 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
5028
5029 let sweep = self.paginate_fills(¶ms, limit, history).await?;
5030 let mut complete = sweep.complete;
5031
5032 let start_ns = start.map(UnixNanos::from);
5034 let end_ns = end.map(UnixNanos::from);
5035
5036 let ts_init = self.generate_ts_init();
5037 let mut reports = Vec::with_capacity(sweep.items.len());
5038
5039 for detail in sweep.items {
5040 if detail.fill_sz.is_empty() {
5042 continue;
5043 }
5044
5045 if !fill_quantity_is_positive(&detail.fill_sz).with_context(|| {
5046 format!(
5047 "failed to parse fill quantity for instrument {}",
5048 detail.inst_id
5049 )
5050 })? {
5051 continue;
5052 }
5053
5054 if report_ts_outside_window(detail.ts, start_ns, end_ns) {
5055 continue;
5056 }
5057
5058 let inst = match self.resolve_report_instrument(
5059 detail.inst_id,
5060 detail.inst_type,
5061 false,
5062 false,
5063 scope,
5064 )? {
5065 InstrumentResolution::Found(inst) => inst,
5066 InstrumentResolution::Skip => continue,
5067 InstrumentResolution::Incomplete => {
5068 complete = false;
5069 continue;
5070 }
5071 };
5072
5073 let report = parse_fill_report(
5074 &detail,
5075 account_id,
5076 inst.id(),
5077 inst.price_precision(),
5078 inst.size_precision(),
5079 ts_init,
5080 )
5081 .with_context(|| {
5082 format!(
5083 "failed to parse fill report for instrument {}",
5084 detail.inst_id
5085 )
5086 })?;
5087
5088 if let Some(start_ns) = start_ns
5089 && report.ts_event < start_ns
5090 {
5091 continue;
5092 }
5093
5094 if let Some(end_ns) = end_ns
5095 && report.ts_event > end_ns
5096 {
5097 continue;
5098 }
5099
5100 reports.push(report);
5101 }
5102
5103 Ok(ReportSweep { reports, complete })
5104 }
5105
5106 pub async fn request_spread_fill_reports(
5112 &self,
5113 account_id: AccountId,
5114 instrument_id: Option<InstrumentId>,
5115 start: Option<Timestamp>,
5116 end: Option<Timestamp>,
5117 limit: Option<u32>,
5118 ) -> anyhow::Result<Vec<FillReport>> {
5119 Ok(self
5120 .request_spread_fill_reports_scoped(account_id, instrument_id, start, end, limit, None)
5121 .await?
5122 .reports)
5123 }
5124
5125 pub(crate) async fn request_spread_fill_reports_scoped(
5126 &self,
5127 account_id: AccountId,
5128 instrument_id: Option<InstrumentId>,
5129 start: Option<Timestamp>,
5130 end: Option<Timestamp>,
5131 limit: Option<u32>,
5132 scope: Option<ReportInstrumentScope<'_>>,
5133 ) -> anyhow::Result<ReportSweep<FillReport>> {
5134 let mut builder = GetSpreadTradesParamsBuilder::default();
5135
5136 if let Some(instrument_id) = instrument_id.as_ref() {
5137 builder.sprd_id(instrument_id.symbol.inner().to_string());
5138 }
5139
5140 if let Some(start) = start {
5141 builder.begin(start.as_millisecond().to_string());
5142 }
5143
5144 if let Some(end) = end {
5145 builder.end(end.as_millisecond().to_string());
5146 }
5147
5148 if let Some(limit) = spread_page_limit(limit) {
5149 builder.limit(limit);
5150 }
5151
5152 let params = builder.build().map_err(|e| anyhow::anyhow!(e))?;
5153 let sweep = self.paginate_spread_fills(¶ms, limit).await?;
5154 let mut complete = sweep.complete;
5155
5156 let start_ns = start.map(UnixNanos::from);
5157 let end_ns = end.map(UnixNanos::from);
5158 let ts_init = self.generate_ts_init();
5159 let mut reports = Vec::with_capacity(sweep.items.len());
5160
5161 for detail in sweep.items {
5162 if detail.fill_sz.is_empty() {
5163 continue;
5164 }
5165
5166 if !fill_quantity_is_positive(&detail.fill_sz).with_context(|| {
5167 format!(
5168 "failed to parse spread fill quantity for instrument {}",
5169 detail.sprd_id
5170 )
5171 })? {
5172 continue;
5173 }
5174
5175 if report_ts_outside_window(detail.ts, start_ns, end_ns) {
5176 continue;
5177 }
5178
5179 let inst = match self.resolve_report_instrument(
5180 detail.sprd_id,
5181 OKXInstrumentType::Any,
5182 true,
5183 false,
5184 scope,
5185 )? {
5186 InstrumentResolution::Found(inst) => inst,
5187 InstrumentResolution::Skip => continue,
5188 InstrumentResolution::Incomplete => {
5189 complete = false;
5190 continue;
5191 }
5192 };
5193
5194 let report = parse_spread_fill_report(
5195 &detail,
5196 account_id,
5197 inst.id(),
5198 inst.price_precision(),
5199 inst.size_precision(),
5200 ts_init,
5201 )
5202 .with_context(|| {
5203 format!(
5204 "failed to parse spread fill report for instrument {}",
5205 detail.sprd_id
5206 )
5207 })?;
5208
5209 if let Some(start_ns) = start_ns
5210 && report.ts_event < start_ns
5211 {
5212 continue;
5213 }
5214
5215 if let Some(end_ns) = end_ns
5216 && report.ts_event > end_ns
5217 {
5218 continue;
5219 }
5220
5221 reports.push(report);
5222 }
5223
5224 Ok(ReportSweep { reports, complete })
5225 }
5226
5227 async fn paginate_spread_fills(
5228 &self,
5229 base: &GetSpreadTradesParams,
5230 limit: Option<u32>,
5231 ) -> anyhow::Result<PageSweep<OKXSpreadTrade>> {
5232 let mut all = Vec::new();
5233 let mut cursor: Option<String> = None;
5234 let mut exhausted = true;
5235
5236 for _ in 0..MAX_RECONCILIATION_PAGES {
5237 let mut params = base.clone();
5238 params.end_id = cursor.take();
5239
5240 let page = self
5241 .inner
5242 .get_spread_trades(params)
5243 .await
5244 .map_err(|e| anyhow::anyhow!(e))?;
5245
5246 let page_len = page.len();
5247 cursor = page.last().map(|o| o.trade_id.to_string());
5248 all.extend(page);
5249
5250 if page_len < OKX_PAGE_SIZE {
5251 exhausted = false;
5252 break;
5253 }
5254
5255 if let Some(lim) = limit
5256 && all.len() >= lim as usize
5257 {
5258 exhausted = false;
5259 break;
5260 }
5261 }
5262
5263 if exhausted && !all.is_empty() {
5264 log::warn!(
5265 "Spread fill pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
5266 results may be truncated ({} records)",
5267 all.len()
5268 );
5269 }
5270
5271 if let Some(lim) = limit {
5272 all.truncate(lim as usize);
5273 }
5274
5275 Ok(PageSweep::from_pages(all, exhausted))
5276 }
5277
5278 pub async fn request_position_status_reports(
5305 &self,
5306 account_id: AccountId,
5307 instrument_type: Option<OKXInstrumentType>,
5308 instrument_id: Option<InstrumentId>,
5309 ) -> anyhow::Result<Vec<PositionStatusReport>> {
5310 Ok(self
5311 .request_position_status_reports_scoped(
5312 account_id,
5313 instrument_type,
5314 instrument_id,
5315 None,
5316 )
5317 .await?
5318 .reports)
5319 }
5320
5321 pub(crate) async fn request_position_status_reports_scoped(
5322 &self,
5323 account_id: AccountId,
5324 instrument_type: Option<OKXInstrumentType>,
5325 instrument_id: Option<InstrumentId>,
5326 scope: Option<ReportInstrumentScope<'_>>,
5327 ) -> anyhow::Result<ReportSweep<PositionStatusReport>> {
5328 let mut params = GetPositionsParamsBuilder::default();
5329
5330 let instrument_type = if let Some(instrument_type) = instrument_type {
5331 instrument_type
5332 } else {
5333 let instrument_id = instrument_id.ok_or_else(|| {
5334 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
5335 })?;
5336 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
5337 okx_instrument_type(&instrument)?
5338 };
5339
5340 params.inst_type(instrument_type);
5341
5342 instrument_id
5343 .as_ref()
5344 .map(|i| params.inst_id(i.symbol.inner()));
5345
5346 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
5347
5348 let resp = self
5349 .inner
5350 .get_positions(params)
5351 .await
5352 .map_err(|e| anyhow::anyhow!(e))?;
5353
5354 let ts_init = self.generate_ts_init();
5355 let mut reports = Vec::with_capacity(resp.len());
5356
5357 for position in resp {
5358 let inst = match self.resolve_report_instrument(
5359 position.inst_id,
5360 position.inst_type,
5361 false,
5362 true,
5363 scope,
5364 )? {
5365 InstrumentResolution::Found(inst) => inst,
5366 InstrumentResolution::Skip => continue,
5367 InstrumentResolution::Incomplete => {
5368 anyhow::bail!(
5369 "Instrument {} missing from cache for position report",
5370 position.inst_id
5371 );
5372 }
5373 };
5374
5375 let report = parse_position_status_report(
5376 &position,
5377 account_id,
5378 inst.id(),
5379 inst.size_precision(),
5380 ts_init,
5381 )
5382 .with_context(|| {
5383 format!(
5384 "failed to parse position status report for instrument {}",
5385 position.inst_id
5386 )
5387 })?;
5388 reports.push(report);
5389 }
5390
5391 Ok(ReportSweep {
5392 reports,
5393 complete: true,
5394 })
5395 }
5396
5397 pub async fn request_spot_margin_position_reports(
5412 &self,
5413 account_id: AccountId,
5414 ) -> anyhow::Result<Vec<PositionStatusReport>> {
5415 let accounts = self
5416 .inner
5417 .get_balance()
5418 .await
5419 .map_err(|e| anyhow::anyhow!(e))?;
5420
5421 let ts_init = self.generate_ts_init();
5422 let mut reports = Vec::new();
5423
5424 let cache_snapshot = self.instruments_cache.load();
5438 let mut candidates: Vec<&InstrumentAny> = cache_snapshot
5439 .values()
5440 .filter(|inst| matches!(inst, InstrumentAny::CurrencyPair(_)))
5441 .collect();
5442 candidates.sort_by(|a, b| {
5443 let a_sym = a.id().symbol.as_str().to_string();
5444 let b_sym = b.id().symbol.as_str().to_string();
5445 spot_quote_priority(&a_sym)
5446 .cmp(&spot_quote_priority(&b_sym))
5447 .then_with(|| a_sym.cmp(&b_sym))
5448 });
5449
5450 let mut by_base: AHashMap<Ustr, (InstrumentId, u8)> = AHashMap::new();
5451
5452 for inst in candidates {
5453 if let Some(base) = inst.base_currency() {
5454 let base_code = base.code;
5455 by_base
5456 .entry(base_code)
5457 .or_insert_with(|| (inst.id(), inst.size_precision()));
5458 }
5459 }
5460
5461 for account in accounts {
5462 for balance in account.details {
5463 let ccy_str = balance.ccy.as_str();
5464
5465 let Some((instrument_id, size_precision)) =
5466 by_base.get(&Ustr::from(ccy_str)).copied()
5467 else {
5468 log::debug!("Skipping balance for {ccy_str} - no matching instrument in cache");
5469 continue;
5470 };
5471
5472 match parse_spot_margin_position_from_balance(
5473 &balance,
5474 account_id,
5475 instrument_id,
5476 size_precision,
5477 ts_init,
5478 ) {
5479 Ok(Some(report)) => reports.push(report),
5480 Ok(None) => {} Err(e) => {
5482 log::error!(
5483 "Failed to parse spot margin position from balance for {ccy_str}: {e}"
5484 );
5485 }
5486 }
5487 }
5488 }
5489
5490 Ok(reports)
5491 }
5492
5493 pub async fn place_order(
5503 &self,
5504 request: OKXPlaceOrderRequest,
5505 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5506 let body =
5507 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5508
5509 let resp: Vec<OKXPlaceOrderResponse> = self
5510 .inner
5511 .send_request::<_, ()>(Method::POST, "/api/v5/trade/order", None, Some(body), true)
5512 .await?;
5513
5514 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
5515 }
5516
5517 pub async fn place_orders(
5523 &self,
5524 requests: Vec<OKXPlaceOrderRequest>,
5525 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
5526 if requests.is_empty() {
5527 return Ok(Vec::new());
5528 }
5529
5530 let body =
5531 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5532 self.inner
5533 .send_request::<_, ()>(
5534 Method::POST,
5535 "/api/v5/trade/batch-orders",
5536 None,
5537 Some(body),
5538 true,
5539 )
5540 .await
5541 }
5542
5543 pub async fn amend_order(
5549 &self,
5550 request: OKXAmendOrderRequest,
5551 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5552 let body =
5553 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5554 let resp: Vec<OKXPlaceOrderResponse> = self
5555 .inner
5556 .send_request::<_, ()>(
5557 Method::POST,
5558 "/api/v5/trade/amend-order",
5559 None,
5560 Some(body),
5561 true,
5562 )
5563 .await?;
5564
5565 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
5566 }
5567
5568 pub async fn amend_orders(
5574 &self,
5575 requests: Vec<OKXAmendOrderRequest>,
5576 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
5577 if requests.is_empty() {
5578 return Ok(Vec::new());
5579 }
5580
5581 let body =
5582 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5583 self.inner
5584 .send_request::<_, ()>(
5585 Method::POST,
5586 "/api/v5/trade/amend-batch-orders",
5587 None,
5588 Some(body),
5589 true,
5590 )
5591 .await
5592 }
5593
5594 pub async fn place_spread_order(
5600 &self,
5601 request: OKXPlaceSpreadOrderRequest,
5602 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5603 let resp = self.inner.place_spread_order(request).await?;
5604 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5605
5606 if let Some(ref code) = item.s_code
5607 && code != OKX_SUCCESS_CODE
5608 {
5609 let msg = item.s_msg.clone().unwrap_or_default();
5610 return Err(OKXHttpError::OkxError {
5611 error_code: code.clone(),
5612 message: msg,
5613 });
5614 }
5615
5616 Ok(item)
5617 }
5618
5619 pub async fn cancel_spread_order(
5625 &self,
5626 request: OKXCancelSpreadOrderRequest,
5627 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
5628 let resp = self.inner.cancel_spread_order(request).await?;
5629 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5630
5631 if let Some(ref code) = item.s_code
5632 && code != OKX_SUCCESS_CODE
5633 {
5634 let msg = item.s_msg.clone().unwrap_or_default();
5635 return Err(OKXHttpError::OkxError {
5636 error_code: code.clone(),
5637 message: msg,
5638 });
5639 }
5640
5641 Ok(item)
5642 }
5643
5644 pub async fn cancel_all_spread_orders(
5650 &self,
5651 spread_id: InstrumentId,
5652 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
5653 let request = OKXCancelAllSpreadOrdersRequest {
5654 sprd_id: spread_id.symbol.as_str().to_string(),
5655 };
5656
5657 self.inner.cancel_all_spread_orders(request).await
5658 }
5659
5660 pub async fn cancel_order(
5666 &self,
5667 instrument_id: InstrumentId,
5668 client_order_id: Option<ClientOrderId>,
5669 venue_order_id: Option<VenueOrderId>,
5670 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
5671 if client_order_id.is_none() && venue_order_id.is_none() {
5672 return Err(OKXHttpError::ValidationError(
5673 "Either `client_order_id` or `venue_order_id` is required".to_string(),
5674 ));
5675 }
5676
5677 let ord_id = venue_order_id.as_ref().map(ToString::to_string);
5678 let cl_ord_id = if venue_order_id.is_none() {
5679 client_order_id.as_ref().map(|id| id.as_str().to_string())
5680 } else {
5681 None
5682 };
5683
5684 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
5685 return self
5686 .cancel_spread_order(OKXCancelSpreadOrderRequest { ord_id, cl_ord_id })
5687 .await;
5688 }
5689
5690 let request = OKXCancelOrderRequest {
5691 inst_id: instrument_id.symbol.as_str().to_string(),
5692 inst_id_code: None,
5693 ord_id,
5694 cl_ord_id,
5695 };
5696 let mut resp = self.cancel_orders(vec![request]).await?;
5697 let item = resp.pop().ok_or(OKXHttpError::EmptyResponse)?;
5698
5699 if let Some(ref code) = item.s_code
5700 && code != OKX_SUCCESS_CODE
5701 {
5702 let msg = item.s_msg.clone().unwrap_or_default();
5703 return Err(OKXHttpError::OkxError {
5704 error_code: code.clone(),
5705 message: msg,
5706 });
5707 }
5708
5709 Ok(item)
5710 }
5711
5712 pub async fn cancel_all_orders(
5718 &self,
5719 instrument_id: InstrumentId,
5720 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
5721 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
5722 return self.cancel_all_spread_orders(instrument_id).await;
5723 }
5724
5725 let instrument = self
5726 .instrument_from_cache(instrument_id.symbol.inner())
5727 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5728 let instrument_type = okx_instrument_type(&instrument)
5729 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5730
5731 let mut pending_base = GetOrderListParamsBuilder::default();
5732 pending_base.inst_type(instrument_type);
5733 pending_base.inst_id(instrument_id.symbol.inner().to_string());
5734 let pending_base = pending_base
5735 .build()
5736 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5737
5738 let pending = self
5739 .paginate_orders_pending(&pending_base, None)
5740 .await
5741 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5742 let requests = pending
5743 .items
5744 .into_iter()
5745 .map(|order| OKXCancelOrderRequest {
5746 inst_id: order.inst_id.to_string(),
5747 inst_id_code: None,
5748 ord_id: if order.ord_id.is_empty() {
5749 None
5750 } else {
5751 Some(order.ord_id.to_string())
5752 },
5753 cl_ord_id: if order.ord_id.is_empty() && !order.cl_ord_id.is_empty() {
5754 Some(order.cl_ord_id.to_string())
5755 } else {
5756 None
5757 },
5758 })
5759 .collect();
5760
5761 self.cancel_orders(requests).await
5762 }
5763
5764 pub async fn cancel_orders(
5777 &self,
5778 requests: Vec<OKXCancelOrderRequest>,
5779 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
5780 if requests.is_empty() {
5781 return Ok(Vec::new());
5782 }
5783
5784 let body =
5785 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5786
5787 let resp: Vec<OKXCancelOrderResponse> = self
5788 .inner
5789 .send_request::<_, ()>(
5790 Method::POST,
5791 "/api/v5/trade/cancel-batch-orders",
5792 None,
5793 Some(body),
5794 true,
5795 )
5796 .await?;
5797
5798 for item in &resp {
5799 if let Some(ref code) = item.s_code
5800 && code != OKX_SUCCESS_CODE
5801 {
5802 let msg = item.s_msg.as_deref().unwrap_or("");
5803 log::warn!(
5804 "Order cancel rejected: ord_id={} sCode={code} sMsg={msg}",
5805 item.ord_id
5806 );
5807 }
5808 }
5809
5810 Ok(resp)
5811 }
5812
5813 pub async fn place_algo_order(
5823 &self,
5824 request: OKXPlaceAlgoOrderRequest,
5825 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
5826 let body =
5827 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5828
5829 let resp: Vec<OKXPlaceAlgoOrderResponse> = self
5830 .inner
5831 .send_request::<_, ()>(
5832 Method::POST,
5833 "/api/v5/trade/order-algo",
5834 None,
5835 Some(body),
5836 true,
5837 )
5838 .await?;
5839
5840 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5841
5842 if let Some(ref code) = item.s_code
5843 && code != "0"
5844 {
5845 let msg = item.s_msg.clone().unwrap_or_default();
5846 return Err(OKXHttpError::OkxError {
5847 error_code: code.clone(),
5848 message: msg,
5849 });
5850 }
5851
5852 Ok(item)
5853 }
5854
5855 pub async fn cancel_algo_order(
5865 &self,
5866 request: OKXCancelAlgoOrderRequest,
5867 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
5868 let body =
5871 serde_json::to_vec(&[request]).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5872
5873 let resp: Vec<OKXCancelAlgoOrderResponse> = self
5874 .inner
5875 .send_request::<_, ()>(
5876 Method::POST,
5877 "/api/v5/trade/cancel-algos",
5878 None,
5879 Some(body),
5880 true,
5881 )
5882 .await?;
5883
5884 let item = resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)?;
5885
5886 if let Some(ref code) = item.s_code
5887 && code != "0"
5888 {
5889 let msg = item.s_msg.clone().unwrap_or_default();
5890 return Err(OKXHttpError::OkxError {
5891 error_code: code.clone(),
5892 message: msg,
5893 });
5894 }
5895
5896 Ok(item)
5897 }
5898
5899 pub async fn cancel_algo_orders(
5912 &self,
5913 requests: Vec<OKXCancelAlgoOrderRequest>,
5914 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
5915 if requests.is_empty() {
5916 return Ok(Vec::new());
5917 }
5918
5919 let body =
5920 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5921
5922 let resp: Vec<OKXCancelAlgoOrderResponse> = self
5923 .inner
5924 .send_request::<_, ()>(
5925 Method::POST,
5926 "/api/v5/trade/cancel-algos",
5927 None,
5928 Some(body),
5929 true,
5930 )
5931 .await?;
5932
5933 for item in &resp {
5934 if let Some(ref code) = item.s_code
5935 && code != "0"
5936 {
5937 let msg = item.s_msg.as_deref().unwrap_or("");
5938 log::warn!(
5939 "Algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
5940 item.algo_id
5941 );
5942 }
5943 }
5944
5945 Ok(resp)
5946 }
5947
5948 pub async fn cancel_advance_algo_orders(
5961 &self,
5962 requests: Vec<OKXCancelAlgoOrderRequest>,
5963 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
5964 if requests.is_empty() {
5965 return Ok(Vec::new());
5966 }
5967
5968 let body =
5969 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5970
5971 let resp: Vec<OKXCancelAlgoOrderResponse> = self
5972 .inner
5973 .send_request::<_, ()>(
5974 Method::POST,
5975 "/api/v5/trade/cancel-advance-algos",
5976 None,
5977 Some(body),
5978 true,
5979 )
5980 .await?;
5981
5982 for item in &resp {
5983 if let Some(ref code) = item.s_code
5984 && code != "0"
5985 {
5986 let msg = item.s_msg.as_deref().unwrap_or("");
5987 log::warn!(
5988 "Advance algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
5989 item.algo_id
5990 );
5991 }
5992 }
5993
5994 Ok(resp)
5995 }
5996
5997 pub async fn amend_algo_order(
6007 &self,
6008 request: OKXAmendAlgoOrderRequest,
6009 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
6010 let body =
6011 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
6012
6013 let resp: Vec<OKXAmendAlgoOrderResponse> = self
6014 .inner
6015 .send_request::<_, ()>(
6016 Method::POST,
6017 "/api/v5/trade/amend-algos",
6018 None,
6019 Some(body),
6020 true,
6021 )
6022 .await?;
6023
6024 resp.into_iter().next().ok_or(OKXHttpError::EmptyResponse)
6025 }
6026
6027 #[expect(clippy::too_many_arguments)]
6036 pub async fn amend_algo_order_with_domain_types(
6037 &self,
6038 instrument_id: InstrumentId,
6039 algo_id: String,
6040 new_trigger_price: Option<Price>,
6041 new_sl_trigger_price: Option<Price>,
6042 new_limit_price: Option<Price>,
6043 new_quantity: Option<Quantity>,
6044 new_callback_ratio: Option<String>,
6045 new_callback_spread: Option<String>,
6046 new_activation_price: Option<Price>,
6047 new_tp_trigger_price: Option<Price>,
6048 new_tp_order_price: Option<String>,
6049 new_tp_trigger_px_type: Option<String>,
6050 new_sl_order_price: Option<String>,
6051 new_sl_trigger_px_type: Option<String>,
6052 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
6053 let request = OKXAmendAlgoOrderRequest {
6054 inst_id: instrument_id.symbol.as_str().to_string(),
6055 algo_id,
6056 algo_cl_ord_id: None,
6057 new_sz: new_quantity.map(|q| q.to_string()),
6058 new_trigger_px: new_trigger_price.map(|p| p.to_string()),
6059 new_tp_trigger_px: new_tp_trigger_price.map(|p| p.to_string()),
6060 new_tp_ord_px: new_tp_order_price,
6061 new_tp_trigger_px_type,
6062 new_sl_trigger_px: new_sl_trigger_price.map(|p| p.to_string()),
6063 new_sl_ord_px: new_sl_order_price,
6064 new_sl_trigger_px_type,
6065 new_order_px: new_limit_price.map(|p| p.to_string()),
6066 new_callback_ratio,
6067 new_callback_spread,
6068 new_active_px: new_activation_price.map(|p| p.to_string()),
6069 };
6070
6071 self.amend_algo_order(request).await
6072 }
6073
6074 #[expect(clippy::too_many_arguments)]
6083 pub async fn place_order_with_domain_types(
6084 &self,
6085 instrument_id: InstrumentId,
6086 td_mode: OKXTradeMode,
6087 client_order_id: ClientOrderId,
6088 order_side: OrderSide,
6089 order_type: OrderType,
6090 quantity: Quantity,
6091 time_in_force: Option<TimeInForce>,
6092 price: Option<Price>,
6093 post_only: Option<bool>,
6094 reduce_only: Option<bool>,
6095 quote_quantity: Option<bool>,
6096 position_side: Option<PositionSide>,
6097 attach_algo_ords: Option<Vec<OKXAttachAlgoOrdRequest>>,
6098 px_usd: Option<String>,
6099 px_vol: Option<String>,
6100 speed_bump: Option<String>,
6101 outcome: Option<String>,
6102 slippage_pct: Option<String>,
6103 rpi: Option<bool>,
6104 rpi_taker_access: Option<bool>,
6105 rpi_px_round: Option<bool>,
6106 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
6107 let rpi = rpi.unwrap_or(false);
6108
6109 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
6110 if reduce_only.unwrap_or(false)
6111 || quote_quantity.unwrap_or(false)
6112 || attach_algo_ords
6113 .as_ref()
6114 .is_some_and(|orders| !orders.is_empty())
6115 || px_usd.is_some()
6116 || px_vol.is_some()
6117 || speed_bump.is_some()
6118 || outcome.is_some()
6119 || slippage_pct.is_some()
6120 || rpi
6121 || rpi_taker_access.is_some()
6122 || rpi_px_round.is_some()
6123 {
6124 return Err(OKXHttpError::ValidationError(
6125 "OKX spread orders do not support regular order extensions".to_string(),
6126 ));
6127 }
6128
6129 return self
6130 .place_spread_order_with_domain_types(
6131 instrument_id,
6132 client_order_id,
6133 order_side,
6134 order_type,
6135 quantity,
6136 time_in_force,
6137 price,
6138 post_only,
6139 )
6140 .await;
6141 }
6142
6143 if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
6144 return Err(OKXHttpError::ValidationError(format!(
6145 "Unsupported order type: {order_type:?}",
6146 )));
6147 }
6148
6149 if matches!(
6150 order_type,
6151 OrderType::StopMarket
6152 | OrderType::StopLimit
6153 | OrderType::MarketIfTouched
6154 | OrderType::LimitIfTouched
6155 | OrderType::TrailingStopMarket
6156 ) {
6157 return Err(OKXHttpError::ValidationError(
6158 "Conditional order types must use OKX algo order placement".to_string(),
6159 ));
6160 }
6161
6162 if let Some(tif) = time_in_force
6163 && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
6164 {
6165 return Err(OKXHttpError::ValidationError(format!(
6166 "Unsupported time in force: {tif:?}",
6167 )));
6168 }
6169
6170 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6171 return Err(OKXHttpError::ValidationError(
6172 "Invalid order side".to_string(),
6173 ));
6174 }
6175
6176 let instrument = self
6177 .instrument_from_cache(instrument_id.symbol.inner())
6178 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6179 let instrument_type = okx_instrument_type(&instrument)
6180 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6181
6182 if instrument_type == OKXInstrumentType::Option
6184 && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
6185 {
6186 return Err(OKXHttpError::ValidationError(
6187 "Market orders are not supported for OKX options, use Limit orders instead"
6188 .to_string(),
6189 ));
6190 }
6191
6192 let side = OKXSide::from(order_side);
6193 let pos_side = position_side.map(Into::into).or({
6194 if matches!(
6195 instrument_type,
6196 OKXInstrumentType::Swap | OKXInstrumentType::Futures | OKXInstrumentType::Option
6197 ) {
6198 Some(OKXPositionSide::Net)
6199 } else {
6200 None
6201 }
6202 });
6203
6204 let tgt_ccy = if instrument_type == OKXInstrumentType::Spot
6205 && order_type == OrderType::Market
6206 && td_mode == OKXTradeMode::Cash
6207 {
6208 match quote_quantity {
6209 Some(true) => Some(OKXTargetCurrency::QuoteCcy),
6210 Some(false) if order_side == OrderSide::Buy => Some(OKXTargetCurrency::BaseCcy),
6211 _ => None,
6212 }
6213 } else {
6214 None
6215 };
6216
6217 if rpi && order_type != OrderType::Limit {
6218 return Err(OKXHttpError::ValidationError(
6219 "OKX RPI orders require a limit order".to_string(),
6220 ));
6221 }
6222
6223 let (ord_type, px) = if rpi {
6224 (OKXOrderType::Rpi, price)
6225 } else if post_only.unwrap_or(false) {
6226 (OKXOrderType::PostOnly, price)
6227 } else if let Some(tif) = time_in_force {
6228 match (order_type, tif) {
6229 (OrderType::Market, TimeInForce::Fok) => {
6230 return Err(OKXHttpError::ValidationError(
6231 "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string(),
6232 ));
6233 }
6234 (OrderType::Market, TimeInForce::Ioc) => {
6235 if matches!(
6237 instrument_type,
6238 OKXInstrumentType::Spot | OKXInstrumentType::Option
6239 ) {
6240 (OKXOrderType::Market, price)
6241 } else {
6242 (OKXOrderType::OptimalLimitIoc, price)
6243 }
6244 }
6245 (OrderType::Limit, TimeInForce::Fok) => {
6246 if instrument_type == OKXInstrumentType::Option {
6248 (OKXOrderType::OpFok, price)
6249 } else {
6250 (OKXOrderType::Fok, price)
6251 }
6252 }
6253 (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
6254 _ => (OKXOrderType::from(order_type), price),
6255 }
6256 } else {
6257 (OKXOrderType::from(order_type), price)
6258 };
6259
6260 let speed_bump = if instrument_type == OKXInstrumentType::Events {
6261 if outcome.is_none() {
6262 return Err(OKXHttpError::ValidationError(
6263 "OKX event contract orders require `outcome`".to_string(),
6264 ));
6265 }
6266
6267 if ord_type == OKXOrderType::PostOnly {
6268 speed_bump
6269 } else {
6270 Some(speed_bump.unwrap_or_else(|| "1".to_string()))
6271 }
6272 } else {
6273 speed_bump
6274 };
6275
6276 let reduce_only = if instrument_type == OKXInstrumentType::Option {
6278 None
6279 } else {
6280 reduce_only
6281 };
6282
6283 let (px, px_usd, px_vol) = if px_usd.is_some() {
6285 (None, px_usd, None)
6286 } else if px_vol.is_some() {
6287 (None, None, px_vol)
6288 } else {
6289 (px.map(|p| p.to_string()), None, None)
6290 };
6291
6292 let request = OKXPlaceOrderRequest {
6293 inst_id: instrument_id.symbol.as_str().to_string(),
6294 td_mode,
6295 ccy: None,
6296 cl_ord_id: Some(client_order_id.as_str().to_string()),
6297 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6298 side,
6299 pos_side,
6300 ord_type,
6301 sz: quantity.to_string(),
6302 px,
6303 px_usd,
6304 px_vol,
6305 reduce_only,
6306 tgt_ccy,
6307 attach_algo_ords,
6308 speed_bump,
6309 outcome,
6310 slippage_pct,
6311 rpi_taker_access,
6312 rpi_px_round,
6313 };
6314
6315 self.place_order(request).await
6316 }
6317
6318 #[expect(clippy::too_many_arguments)]
6325 pub async fn place_spread_order_with_domain_types(
6326 &self,
6327 instrument_id: InstrumentId,
6328 client_order_id: ClientOrderId,
6329 order_side: OrderSide,
6330 order_type: OrderType,
6331 quantity: Quantity,
6332 time_in_force: Option<TimeInForce>,
6333 price: Option<Price>,
6334 post_only: Option<bool>,
6335 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
6336 if !is_okx_spread_symbol(instrument_id.symbol.as_str()) {
6337 return Err(OKXHttpError::ValidationError(format!(
6338 "Instrument is not an OKX spread: {instrument_id}",
6339 )));
6340 }
6341
6342 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6343 return Err(OKXHttpError::ValidationError(
6344 "Invalid order side".to_string(),
6345 ));
6346 }
6347
6348 if !matches!(order_type, OrderType::Limit) {
6349 return Err(OKXHttpError::ValidationError(
6350 "OKX spread orders support Limit orders only".to_string(),
6351 ));
6352 }
6353
6354 let ord_type = if post_only.unwrap_or(false) {
6355 OKXOrderType::PostOnly
6356 } else if matches!(time_in_force, Some(TimeInForce::Ioc)) {
6357 OKXOrderType::Ioc
6358 } else if matches!(time_in_force, Some(TimeInForce::Fok)) {
6359 return Err(OKXHttpError::ValidationError(
6360 "OKX spread orders do not support FOK time-in-force".to_string(),
6361 ));
6362 } else {
6363 OKXOrderType::Limit
6364 };
6365
6366 let price = price.ok_or_else(|| {
6367 OKXHttpError::ValidationError("OKX spread orders require a limit price".to_string())
6368 })?;
6369
6370 let request = OKXPlaceSpreadOrderRequest {
6371 sprd_id: instrument_id.symbol.as_str().to_string(),
6372 cl_ord_id: Some(client_order_id.as_str().to_string()),
6373 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6374 side: OKXSide::from(order_side),
6375 ord_type,
6376 sz: quantity.to_string(),
6377 px: Some(price.to_string()),
6378 };
6379
6380 self.place_spread_order(request).await
6381 }
6382
6383 #[expect(clippy::too_many_arguments)]
6392 pub async fn place_algo_order_with_domain_types(
6393 &self,
6394 instrument_id: InstrumentId,
6395 td_mode: OKXTradeMode,
6396 client_order_id: ClientOrderId,
6397 order_side: OrderSide,
6398 order_type: OrderType,
6399 quantity: Quantity,
6400 trigger_price: Option<Price>,
6401 trigger_type: Option<TriggerType>,
6402 limit_price: Option<Price>,
6403 reduce_only: Option<bool>,
6404 close_fraction: Option<String>,
6405 callback_ratio: Option<String>,
6406 callback_spread: Option<String>,
6407 activation_price: Option<Price>,
6408 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
6409 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
6410 return Err(OKXHttpError::ValidationError(
6411 "Invalid order side".to_string(),
6412 ));
6413 }
6414
6415 let okx_side = OKXSide::from(order_side);
6416
6417 let trigger_px_type_enum = trigger_type.map_or(OKXTriggerType::Last, Into::into);
6419
6420 let uses_close_fraction = close_fraction.is_some();
6421 let (
6422 algo_type,
6423 sz,
6424 trigger_px,
6425 order_px,
6426 trigger_px_type,
6427 sl_trigger_px,
6428 sl_ord_px,
6429 sl_trigger_px_type,
6430 tp_trigger_px,
6431 tp_ord_px,
6432 tp_trigger_px_type,
6433 pos_side,
6434 reduce_only,
6435 ) = if uses_close_fraction {
6436 if order_type == OrderType::TrailingStopMarket {
6437 return Err(OKXHttpError::ValidationError(
6438 "OKX close_fraction does not support TrailingStopMarket".to_string(),
6439 ));
6440 }
6441
6442 let trigger_px = trigger_price.map(|p| p.to_string()).ok_or_else(|| {
6443 OKXHttpError::ValidationError(
6444 "OKX close_fraction orders require trigger_price".to_string(),
6445 )
6446 })?;
6447
6448 let close_order_px =
6449 if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched) {
6450 limit_price.map(|p| p.to_string()).ok_or_else(|| {
6451 OKXHttpError::ValidationError(format!(
6452 "OKX {order_type:?} close_fraction orders require limit_price"
6453 ))
6454 })?
6455 } else {
6456 "-1".to_string()
6457 };
6458
6459 let (
6460 sl_trigger_px,
6461 sl_ord_px,
6462 sl_trigger_px_type,
6463 tp_trigger_px,
6464 tp_ord_px,
6465 tp_trigger_px_type,
6466 ) = match order_type {
6467 OrderType::StopMarket | OrderType::StopLimit => (
6468 Some(trigger_px),
6469 Some(close_order_px),
6470 Some(trigger_px_type_enum),
6471 None,
6472 None,
6473 None,
6474 ),
6475 OrderType::MarketIfTouched | OrderType::LimitIfTouched => (
6476 None,
6477 None,
6478 None,
6479 Some(trigger_px),
6480 Some(close_order_px),
6481 Some(trigger_px_type_enum),
6482 ),
6483 _ => {
6484 return Err(OKXHttpError::ValidationError(format!(
6485 "OKX close_fraction is only supported for stop/touched conditional orders, received {order_type:?}"
6486 )));
6487 }
6488 };
6489
6490 (
6491 OKXAlgoOrderType::Conditional,
6492 None,
6493 None,
6494 None,
6495 None,
6496 sl_trigger_px,
6497 sl_ord_px,
6498 sl_trigger_px_type,
6499 tp_trigger_px,
6500 tp_ord_px,
6501 tp_trigger_px_type,
6502 Some(OKXPositionSide::Net),
6503 Some(true),
6504 )
6505 } else {
6506 let algo_type = conditional_order_to_algo_type(order_type)
6507 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
6508
6509 let order_px = if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
6510 {
6511 limit_price.map(|p| p.to_string())
6512 } else if order_type == OrderType::TrailingStopMarket {
6513 None
6514 } else {
6515 Some("-1".to_string())
6516 };
6517
6518 (
6519 algo_type,
6520 Some(quantity.to_string()),
6521 trigger_price.map(|p| p.to_string()),
6522 order_px,
6523 Some(trigger_px_type_enum),
6524 None,
6525 None,
6526 None,
6527 None,
6528 None,
6529 None,
6530 None,
6531 reduce_only,
6532 )
6533 };
6534
6535 let request = OKXPlaceAlgoOrderRequest {
6536 inst_id: instrument_id.symbol.as_str().to_string(),
6537 inst_id_code: None,
6538 td_mode,
6539 side: okx_side,
6540 ord_type: algo_type,
6541 sz,
6542 algo_cl_ord_id: Some(client_order_id.as_str().to_string()),
6543 trigger_px,
6544 order_px,
6545 trigger_px_type,
6546 sl_trigger_px,
6547 sl_ord_px,
6548 sl_trigger_px_type,
6549 tp_trigger_px,
6550 tp_ord_px,
6551 tp_trigger_px_type,
6552 tgt_ccy: None,
6553 pos_side,
6554 close_position: None,
6555 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
6556 reduce_only,
6557 close_fraction,
6558 callback_ratio,
6559 callback_spread,
6560 active_px: activation_price.map(|p| p.to_string()),
6561 };
6562
6563 self.place_algo_order(request).await
6564 }
6565
6566 pub async fn cancel_algo_order_with_domain_types(
6575 &self,
6576 instrument_id: InstrumentId,
6577 algo_id: String,
6578 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
6579 let request = OKXCancelAlgoOrderRequest {
6580 inst_id: instrument_id.symbol.to_string(),
6581 inst_id_code: None,
6582 algo_id: Some(algo_id),
6583 algo_cl_ord_id: None,
6584 };
6585
6586 self.cancel_algo_order(request).await
6587 }
6588
6589 #[expect(clippy::too_many_arguments)]
6595 pub async fn request_algo_order_status_reports(
6596 &self,
6597 account_id: AccountId,
6598 instrument_type: Option<OKXInstrumentType>,
6599 instrument_id: Option<InstrumentId>,
6600 algo_id: Option<String>,
6601 algo_client_order_id: Option<ClientOrderId>,
6602 state: Option<OKXAlgoOrderStatus>,
6603 limit: Option<u32>,
6604 ) -> anyhow::Result<Vec<OrderStatusReport>> {
6605 Ok(self
6606 .request_algo_order_status_reports_sweep(
6607 account_id,
6608 instrument_type,
6609 instrument_id,
6610 algo_id,
6611 algo_client_order_id,
6612 state,
6613 limit,
6614 None,
6615 None,
6616 )
6617 .await?
6618 .reports)
6619 }
6620
6621 #[expect(clippy::too_many_arguments)]
6622 pub(crate) async fn request_algo_order_status_reports_sweep(
6623 &self,
6624 account_id: AccountId,
6625 instrument_type: Option<OKXInstrumentType>,
6626 instrument_id: Option<InstrumentId>,
6627 algo_id: Option<String>,
6628 algo_client_order_id: Option<ClientOrderId>,
6629 state: Option<OKXAlgoOrderStatus>,
6630 limit: Option<u32>,
6631 start: Option<Timestamp>,
6632 end: Option<Timestamp>,
6633 ) -> anyhow::Result<AlgoOrderReportSweep> {
6634 let mut instruments_cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
6635 let mut ambiguous_triggered_child_ids = AHashSet::new();
6636 let has_specific_lookup = algo_id.is_some() || algo_client_order_id.is_some();
6637 let start_ns = start.map(UnixNanos::from);
6638 let end_ns = end.map(UnixNanos::from);
6639 let mut complete = true;
6640
6641 let inst_type = if let Some(inst_type) = instrument_type {
6642 inst_type
6643 } else if let Some(inst_id) = instrument_id {
6644 let instrument = self.instrument_from_cache(inst_id.symbol.inner())?;
6645 let inst_type = okx_instrument_type(&instrument)?;
6646 instruments_cache.insert(inst_id.symbol.inner(), instrument);
6647 inst_type
6648 } else {
6649 anyhow::bail!("instrument_type or instrument_id required for algo order query")
6650 };
6651
6652 let ts_init = self.generate_ts_init();
6653 let mut reports = Vec::new();
6654 let mut seen: AHashMap<(String, String), usize> = AHashMap::new();
6655
6656 if has_specific_lookup {
6657 let mut params_builder = GetAlgoOrderParamsBuilder::default();
6658
6659 if let Some(algo_id) = algo_id {
6660 params_builder.algo_id(algo_id);
6661 }
6662
6663 if let Some(client_order_id) = algo_client_order_id {
6664 params_builder.algo_cl_ord_id(client_order_id.as_str().to_string());
6665 }
6666
6667 let params = params_builder
6668 .build()
6669 .map_err(|e| anyhow::anyhow!(format!("Failed to build algo order params: {e}")))?;
6670 let mut orders = match self.inner.get_algo_order(params).await {
6671 Ok(orders) => orders,
6672 Err(e) if e.is_order_not_found() => {
6673 return Ok(AlgoOrderReportSweep {
6674 reports,
6675 complete,
6676 ambiguous_triggered_child_ids,
6677 });
6678 }
6679 Err(e) => return Err(e.into()),
6680 };
6681
6682 if let Some(state) = state {
6683 orders.retain(|order| order.state == state);
6684 }
6685
6686 complete &= self
6687 .collect_algo_reports(
6688 account_id,
6689 &orders,
6690 &mut instruments_cache,
6691 ts_init,
6692 start_ns,
6693 end_ns,
6694 &mut seen,
6695 &mut reports,
6696 &mut ambiguous_triggered_child_ids,
6697 )
6698 .await?;
6699
6700 if let Some(limit) = limit {
6701 reports.truncate(limit as usize);
6702 }
6703
6704 return Ok(AlgoOrderReportSweep {
6705 reports,
6706 complete,
6707 ambiguous_triggered_child_ids,
6708 });
6709 }
6710
6711 let query_pending = state.is_none()
6712 || matches!(
6713 state,
6714 Some(OKXAlgoOrderStatus::Live | OKXAlgoOrderStatus::Pause)
6715 );
6716 let history_states: &[OKXAlgoOrderStatus] = match state {
6717 None => &[
6718 OKXAlgoOrderStatus::Effective,
6719 OKXAlgoOrderStatus::Canceled,
6720 OKXAlgoOrderStatus::OrderFailed,
6721 ],
6722 Some(OKXAlgoOrderStatus::Unknown) => &[],
6724 Some(OKXAlgoOrderStatus::Live | OKXAlgoOrderStatus::Pause) => &[],
6725 Some(
6726 OKXAlgoOrderStatus::Effective
6727 | OKXAlgoOrderStatus::OrderPlaced
6728 | OKXAlgoOrderStatus::PartiallyEffective
6729 | OKXAlgoOrderStatus::Filled,
6730 ) => &[OKXAlgoOrderStatus::Effective],
6731 Some(OKXAlgoOrderStatus::Canceled) => &[OKXAlgoOrderStatus::Canceled],
6732 Some(OKXAlgoOrderStatus::OrderFailed | OKXAlgoOrderStatus::PartiallyFailed) => {
6733 &[OKXAlgoOrderStatus::OrderFailed]
6734 }
6735 };
6736
6737 for ord_type in [
6738 OKXAlgoOrderType::Oco,
6739 OKXAlgoOrderType::Conditional,
6740 OKXAlgoOrderType::Trigger,
6741 OKXAlgoOrderType::MoveOrderStop,
6742 ] {
6743 let mut params_builder = GetAlgoOrdersParamsBuilder::default();
6744 params_builder.inst_type(inst_type);
6745 params_builder.ord_type(ord_type);
6746
6747 if let Some(inst_id) = instrument_id {
6748 params_builder.inst_id(inst_id.symbol.inner().to_string());
6749 }
6750
6751 let mut params = params_builder
6752 .build()
6753 .map_err(|e| anyhow::anyhow!(format!("Failed to build algo order params: {e}")))?;
6754
6755 if query_pending {
6756 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
6757 let pending_sweep = self.paginate_algo_pending(¶ms, remaining).await?;
6758 complete &= pending_sweep.complete;
6759 let mut pending = pending_sweep.items;
6760
6761 if let Some(state) = state {
6762 pending.retain(|order| order.state == state);
6763 }
6764
6765 complete &= self
6766 .collect_algo_reports(
6767 account_id,
6768 &pending,
6769 &mut instruments_cache,
6770 ts_init,
6771 start_ns,
6772 end_ns,
6773 &mut seen,
6774 &mut reports,
6775 &mut ambiguous_triggered_child_ids,
6776 )
6777 .await?;
6778
6779 if let Some(lim) = limit
6780 && reports.len() >= lim as usize
6781 {
6782 reports.truncate(lim as usize);
6783 return Ok(AlgoOrderReportSweep {
6784 reports,
6785 complete,
6786 ambiguous_triggered_child_ids,
6787 });
6788 }
6789 }
6790
6791 for history_state in history_states {
6792 params.state = Some(*history_state);
6793 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
6794 let history_sweep = self.paginate_algo_history(¶ms, remaining).await?;
6795 complete &= history_sweep.complete;
6796 let mut history = history_sweep.items;
6797
6798 if let Some(state) = state {
6799 history.retain(|order| order.state == state);
6800 }
6801
6802 complete &= self
6803 .collect_algo_reports(
6804 account_id,
6805 &history,
6806 &mut instruments_cache,
6807 ts_init,
6808 start_ns,
6809 end_ns,
6810 &mut seen,
6811 &mut reports,
6812 &mut ambiguous_triggered_child_ids,
6813 )
6814 .await?;
6815
6816 if let Some(lim) = limit
6817 && reports.len() >= lim as usize
6818 {
6819 reports.truncate(lim as usize);
6820 return Ok(AlgoOrderReportSweep {
6821 reports,
6822 complete,
6823 ambiguous_triggered_child_ids,
6824 });
6825 }
6826 }
6827 }
6828
6829 Ok(AlgoOrderReportSweep {
6830 reports,
6831 complete,
6832 ambiguous_triggered_child_ids,
6833 })
6834 }
6835
6836 pub async fn request_algo_order_status_report(
6842 &self,
6843 account_id: AccountId,
6844 instrument_id: InstrumentId,
6845 algo_client_order_id: ClientOrderId,
6846 ) -> anyhow::Result<Option<OrderStatusReport>> {
6847 let reports = self
6848 .request_algo_order_status_reports(
6849 account_id,
6850 None,
6851 Some(instrument_id),
6852 None,
6853 Some(algo_client_order_id),
6854 None,
6855 Some(50_u32),
6856 )
6857 .await?;
6858
6859 Ok(reports.into_iter().next())
6860 }
6861
6862 pub fn raw_client(&self) -> &Arc<OKXRawHttpClient> {
6864 &self.inner
6865 }
6866
6867 #[expect(clippy::too_many_arguments)]
6868 async fn collect_algo_reports(
6869 &self,
6870 account_id: AccountId,
6871 orders: &[OKXOrderAlgo],
6872 instruments_cache: &mut AHashMap<Ustr, InstrumentAny>,
6873 ts_init: UnixNanos,
6874 start_ns: Option<UnixNanos>,
6875 end_ns: Option<UnixNanos>,
6876 seen: &mut AHashMap<(String, String), usize>,
6877 reports: &mut Vec<OrderStatusReport>,
6878 ambiguous_triggered_child_ids: &mut AHashSet<VenueOrderId>,
6879 ) -> anyhow::Result<bool> {
6880 let mut complete = true;
6881
6882 for order in orders {
6883 let key = (order.algo_id.clone(), order.algo_cl_ord_id.clone());
6884
6885 let has_ambiguous_children = order.ord_id_list.len() > 1
6886 || !order.sub_algo_id_list.is_empty()
6887 || matches!(
6888 order.ord_id_list.as_slice(),
6889 [child_order_id]
6890 if !order.ord_id.is_empty() && child_order_id != &order.ord_id
6891 );
6892
6893 if report_ts_outside_window(order.u_time, start_ns, end_ns)
6896 && !is_open_okx_algo(order.state)
6897 {
6898 continue;
6899 }
6900
6901 let instrument = if let Some(instrument) = instruments_cache.get(&order.inst_id) {
6902 instrument.clone()
6903 } else {
6904 match self.resolve_report_instrument(
6905 order.inst_id,
6906 order.inst_type,
6907 false,
6908 is_open_okx_algo(order.state),
6909 None,
6910 )? {
6911 InstrumentResolution::Found(instrument) => {
6912 instruments_cache.insert(order.inst_id, (*instrument).clone());
6913 *instrument
6914 }
6915 InstrumentResolution::Skip => continue,
6916 InstrumentResolution::Incomplete => {
6917 complete = false;
6918 continue;
6919 }
6920 }
6921 };
6922
6923 match parse_http_algo_order(order, account_id, &instrument, ts_init) {
6924 Ok(report) => {
6925 if has_ambiguous_children && report.order_status == OrderStatus::Triggered {
6926 log::warn!(
6927 "Algo order {} has ambiguous child order identifiers",
6928 order.algo_id,
6929 );
6930 ambiguous_triggered_child_ids.insert(report.venue_order_id);
6931 }
6932
6933 if let Some(index) = seen.get(&key).copied() {
6934 if is_order_status_report_more_advanced(&report, &reports[index]) {
6935 reports[index] = report;
6936 }
6937 } else {
6938 seen.insert(key, reports.len());
6939 reports.push(report);
6940 }
6941 }
6942 Err(e) => {
6943 log::warn!("Failed to parse algo order report: {e}");
6944 complete = false;
6945 }
6946 }
6947 }
6948
6949 Ok(complete)
6950 }
6951
6952 fn resolve_report_instrument(
6953 &self,
6954 symbol: Ustr,
6955 inst_type: OKXInstrumentType,
6956 is_spread: bool,
6957 is_open_or_position: bool,
6958 scope: Option<ReportInstrumentScope<'_>>,
6959 ) -> anyhow::Result<InstrumentResolution> {
6960 if let Ok(instrument) = self.instrument_from_cache(symbol) {
6961 return Ok(InstrumentResolution::Found(Box::new(instrument)));
6962 }
6963
6964 let in_scope = match scope {
6965 None => true,
6966 Some(scope) if is_spread => scope.load_spreads,
6967 Some(scope) => {
6968 scope.instrument_types.contains(&inst_type)
6969 || scope.instrument_types.contains(&OKXInstrumentType::Any)
6970 }
6971 };
6972
6973 if !in_scope {
6974 log::debug!("Skipping report for out-of-scope instrument: symbol={symbol}");
6975 return Ok(InstrumentResolution::Skip);
6976 }
6977
6978 if is_open_or_position {
6979 anyhow::bail!("Instrument {symbol} missing from cache");
6980 }
6981
6982 log::warn!("Instrument {symbol} missing from cache");
6983 Ok(InstrumentResolution::Incomplete)
6984 }
6985
6986 pub(crate) async fn request_spread_order_status_report(
6987 &self,
6988 account_id: AccountId,
6989 instrument_id: InstrumentId,
6990 client_order_id: Option<ClientOrderId>,
6991 venue_order_id: Option<VenueOrderId>,
6992 ) -> anyhow::Result<Option<OrderStatusReport>> {
6993 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
6994 let mut params_builder = GetSpreadOrderParamsBuilder::default();
6995
6996 match (client_order_id, venue_order_id) {
6997 (Some(client_order_id), None) => {
6998 params_builder.cl_ord_id(client_order_id.as_str().to_string());
6999 }
7000 (None, Some(venue_order_id)) => {
7001 params_builder.ord_id(venue_order_id.as_str().to_string());
7002 }
7003 _ => anyhow::bail!(
7004 "Exactly one of client_order_id or venue_order_id is required for a spread order detail request"
7005 ),
7006 }
7007
7008 let params = params_builder
7009 .build()
7010 .map_err(|e| anyhow::anyhow!("Failed to build spread order detail params: {e}"))?;
7011 let orders = match self.inner.get_spread_order(params).await {
7012 Ok(orders) => orders,
7013 Err(e) if e.is_order_not_found() => return Ok(None),
7014 Err(e) => return Err(e.into()),
7015 };
7016 let Some(order) = orders.into_iter().next() else {
7017 return Ok(None);
7018 };
7019 let ts_init = self.generate_ts_init();
7020 let report = parse_spread_order_status_report(
7021 &order,
7022 account_id,
7023 instrument.id(),
7024 instrument.price_precision(),
7025 instrument.size_precision(),
7026 ts_init,
7027 )?;
7028
7029 Ok(Some(report))
7030 }
7031}
7032
7033fn is_open_okx_order(state: OKXOrderStatus) -> bool {
7034 matches!(
7035 state,
7036 OKXOrderStatus::Live | OKXOrderStatus::PartiallyFilled
7037 )
7038}
7039
7040fn fill_quantity_is_positive(value: &str) -> Result<bool, rust_decimal::Error> {
7041 Ok(Decimal::from_str(value)? > Decimal::ZERO)
7042}
7043
7044fn report_ts_outside_window(
7045 timestamp_ms: u64,
7046 start_ns: Option<UnixNanos>,
7047 end_ns: Option<UnixNanos>,
7048) -> bool {
7049 if start_ns.is_none() && end_ns.is_none() {
7050 return false;
7051 }
7052
7053 let ts = parse_millisecond_timestamp(timestamp_ms);
7054 start_ns.is_some_and(|start| ts < start) || end_ns.is_some_and(|end| ts > end)
7055}
7056
7057fn is_open_okx_algo(state: OKXAlgoOrderStatus) -> bool {
7058 matches!(
7059 state,
7060 OKXAlgoOrderStatus::Live
7061 | OKXAlgoOrderStatus::Pause
7062 | OKXAlgoOrderStatus::OrderPlaced
7063 | OKXAlgoOrderStatus::PartiallyEffective
7064 )
7065}
7066
7067fn spread_page_limit(limit: Option<u32>) -> Option<u32> {
7068 limit.map(|limit| limit.min(OKX_PAGE_SIZE as u32))
7069}
7070
7071fn parse_http_algo_order(
7072 order: &OKXOrderAlgo,
7073 account_id: AccountId,
7074 instrument: &InstrumentAny,
7075 ts_init: UnixNanos,
7076) -> anyhow::Result<OrderStatusReport> {
7077 let ord_id = if order.ord_id.is_empty() {
7078 match order.ord_id_list.as_slice() {
7079 [child_order_id] => child_order_id.clone(),
7080 _ => String::new(),
7081 }
7082 } else {
7083 order.ord_id.clone()
7084 };
7085 let ord_px = if order.ord_px.is_empty() {
7086 "-1".to_string()
7087 } else {
7088 order.ord_px.clone()
7089 };
7090
7091 let reduce_only = if order.reduce_only.is_empty() {
7092 "false".to_string()
7093 } else {
7094 order.reduce_only.clone()
7095 };
7096
7097 let msg = OKXAlgoOrderMsg {
7098 algo_id: order.algo_id.clone(),
7099 algo_cl_ord_id: order.algo_cl_ord_id.clone(),
7100 cl_ord_id: order.cl_ord_id.clone(),
7101 ord_id,
7102 ord_id_list: order.ord_id_list.clone(),
7103 inst_id: order.inst_id,
7104 inst_type: order.inst_type,
7105 ord_type: order.ord_type,
7106 state: order.state,
7107 side: order.side,
7108 pos_side: order.pos_side,
7109 sz: order.sz.clone(),
7110 trigger_px: order.trigger_px.clone(),
7111 trigger_px_type: order.trigger_px_type.unwrap_or(OKXTriggerType::None),
7112 sl_trigger_px: order.sl_trigger_px.clone(),
7113 sl_ord_px: order.sl_ord_px.clone(),
7114 sl_trigger_px_type: order.sl_trigger_px_type.unwrap_or(OKXTriggerType::None),
7115 tp_trigger_px: order.tp_trigger_px.clone(),
7116 tp_ord_px: order.tp_ord_px.clone(),
7117 tp_trigger_px_type: order.tp_trigger_px_type.unwrap_or(OKXTriggerType::None),
7118 ord_px,
7119 td_mode: order.td_mode,
7120 lever: order.lever.clone(),
7121 reduce_only,
7122 close_fraction: order.close_fraction.clone(),
7123 actual_px: order.actual_px.clone(),
7124 actual_sz: order.actual_sz.clone(),
7125 notional_usd: order.notional_usd.clone(),
7126 c_time: order.c_time,
7127 u_time: order.u_time,
7128 trigger_time: order.trigger_time.clone(),
7129 fail_code: String::new(),
7130 tag: order.tag.clone(),
7131 callback_ratio: order.callback_ratio.clone(),
7132 callback_spread: order.callback_spread.clone(),
7133 active_px: order.active_px.clone(),
7134 ccy: None,
7135 tgt_ccy: None,
7136 fee: None,
7137 fee_ccy: None,
7138 advance_ord_type: None,
7139 };
7140
7141 parse_algo_order_status_report(&msg, instrument, account_id, ts_init)
7142}