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 chrono::{DateTime, Utc};
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, 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, 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, OKXAttachAlgoOrdRequest,
87 OKXCancelAlgoOrderRequest, OKXCancelAlgoOrderResponse, OKXCancelAllSpreadOrdersRequest,
88 OKXCancelOrderRequest, OKXCancelOrderResponse, OKXCancelSpreadOrderRequest,
89 OKXEventContractEvent, OKXEventContractMarket, OKXEventContractSeries, OKXFeeRate,
90 OKXFundingRateHistory, OKXIndexTicker, OKXMarkPrice, OKXOptionSummary, OKXOrderAlgo,
91 OKXOrderBookSnapshot, OKXOrderHistory, OKXPlaceAlgoOrderRequest, OKXPlaceAlgoOrderResponse,
92 OKXPlaceOrderRequest, OKXPlaceOrderResponse, OKXPlaceSpreadOrderRequest, OKXPosition,
93 OKXPositionHistory, OKXPositionTier, OKXServerTime, OKXSpread, OKXSpreadOrder,
94 OKXSpreadTrade, OKXTransactionDetail,
95 },
96 query::{
97 GetAlgoOrdersParams, GetAlgoOrdersParamsBuilder, GetCandlesticksParams,
98 GetCandlesticksParamsBuilder, GetEventContractEventsParams, GetEventContractMarketsParams,
99 GetEventContractSeriesParams, GetFundingRateHistoryParams, GetIndexTickerParams,
100 GetIndexTickerParamsBuilder, GetInstrumentsParams, GetInstrumentsParamsBuilder,
101 GetMarkPriceParams, GetMarkPriceParamsBuilder, GetOptionSummaryParams, GetOrderBookParams,
102 GetOrderHistoryParams, GetOrderHistoryParamsBuilder, GetOrderListParams,
103 GetOrderListParamsBuilder, GetPositionTiersParams, GetPositionsHistoryParams,
104 GetPositionsParams, GetPositionsParamsBuilder, GetSpreadOrderParams, GetSpreadOrdersParams,
105 GetSpreadOrdersParamsBuilder, GetSpreadTradesParams, GetSpreadTradesParamsBuilder,
106 GetSpreadsParams, GetTradeFeeParams, GetTradesParams, GetTradesParamsBuilder,
107 GetTransactionDetailsParams, GetTransactionDetailsParamsBuilder, SetPositionModeParams,
108 SetPositionModeParamsBuilder,
109 },
110};
111use crate::{
112 common::{
113 consts::{
114 OKX_FIELD_SCODE, OKX_FIELD_SMSG, OKX_HTTP_URL, OKX_NAUTILUS_BROKER_ID,
115 OKX_SUPPORTED_ORDER_TYPES, OKX_SUPPORTED_TIME_IN_FORCE,
116 },
117 credential::Credential,
118 enums::{
119 OKXAlgoOrderType, OKXContractType, OKXEnvironment, OKXInstrumentStatus,
120 OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionMode, OKXPositionSide,
121 OKXSide, OKXTargetCurrency, OKXTradeMode, OKXTriggerType,
122 conditional_order_to_algo_type,
123 },
124 models::OKXInstrument,
125 parse::{
126 extract_inst_family, is_okx_spread_symbol, okx_instrument_type,
127 okx_instrument_type_from_symbol, parse_account_state, parse_base_quote_from_symbol,
128 parse_candlestick, parse_fill_report, parse_funding_rate, parse_index_price_update,
129 parse_instrument_any, parse_instrument_id, parse_mark_price_update,
130 parse_order_status_report, parse_position_status_report, parse_price, parse_quantity,
131 parse_spot_margin_position_from_balance, parse_spread_fill_report,
132 parse_spread_instrument, parse_spread_order_status_report, parse_trade_tick,
133 },
134 },
135 http::{
136 models::{OKXCandlestick, OKXTrade},
137 query::GetOrderParams,
138 },
139 websocket::{messages::OKXAlgoOrderMsg, parse::parse_algo_order_status_report},
140};
141
142const OKX_SUCCESS_CODE: &str = "0";
143
144#[derive(Debug, Error)]
145#[error("Failed to parse instrument {symbol}: {source}")]
146pub(crate) struct OKXInstrumentDefinitionError {
147 symbol: String,
148 #[source]
149 source: anyhow::Error,
150}
151
152impl OKXInstrumentDefinitionError {
153 fn new(symbol: &str, source: anyhow::Error) -> Self {
154 Self {
155 symbol: symbol.to_string(),
156 source,
157 }
158 }
159}
160
161fn spot_quote_priority(symbol: &str) -> u8 {
166 symbol.rsplit_once('-').map_or(4, |(_, quote)| match quote {
167 "USDT" => 0,
168 "USDC" => 1,
169 "USD" => 2,
170 _ => 3,
171 })
172}
173
174fn resolve_okx_error_message(response_body: &[u8], top_level_msg: &str) -> String {
175 let message = top_level_msg.trim();
176 let is_generic_top_level = message.eq_ignore_ascii_case("All operations failed");
177 if !message.is_empty() && !is_generic_top_level {
178 return message.to_string();
179 }
180
181 if let Ok(payload) = serde_json::from_slice::<serde_json::Value>(response_body)
182 && let Some(first_item) = payload
183 .get("data")
184 .and_then(serde_json::Value::as_array)
185 .and_then(|items| items.first())
186 {
187 if let Some(s_msg) = first_item
188 .get(OKX_FIELD_SMSG)
189 .and_then(serde_json::Value::as_str)
190 {
191 let s_msg = s_msg.trim();
192 if !s_msg.is_empty() {
193 return s_msg.to_string();
194 }
195 }
196
197 if let Some(s_code) = first_item
198 .get(OKX_FIELD_SCODE)
199 .and_then(serde_json::Value::as_str)
200 {
201 let s_code = s_code.trim();
202 if !s_code.is_empty() {
203 return s_code.to_string();
204 }
205 }
206 }
207
208 String::new()
209}
210
211fn deserialize_okx_response<T: DeserializeOwned>(
212 response_body: &[u8],
213) -> Result<OKXResponse<T>, serde_json::Error> {
214 serde_json::from_slice(response_body)
215}
216
217#[cfg(test)]
218mod tests {
219 use anyhow::Context;
220 use rstest::rstest;
221
222 use super::{OKXInstrumentDefinitionError, resolve_okx_error_message};
223
224 #[rstest]
225 fn test_instrument_definition_error_survives_anyhow_context_downcast() {
226 let result: anyhow::Result<()> = Err(OKXInstrumentDefinitionError::new(
227 "USDG-SGD",
228 anyhow::anyhow!("`tick_sz` is empty"),
229 )
230 .into());
231
232 let err = result.context("fetch instrument from API").unwrap_err();
233
234 assert!(err.downcast_ref::<OKXInstrumentDefinitionError>().is_some());
235 }
236
237 #[rstest]
238 fn test_resolve_okx_error_message_prefers_detailed_s_msg_over_generic_top_level() {
239 let body = br#"{
240 "code": "1",
241 "msg": "All operations failed",
242 "data": [
243 {
244 "sCode": "51046",
245 "sMsg": "Test detailed failure"
246 }
247 ]
248 }"#;
249
250 assert_eq!(
251 resolve_okx_error_message(body, "All operations failed"),
252 "Test detailed failure",
253 );
254 }
255
256 #[rstest]
257 #[case("BTC-USD")]
258 #[case("BTC-USD-241217")]
259 #[case("BTC-USD-241217-92000")]
260 fn test_option_summary_expiry_key_rejects_short_symbol(#[case] symbol: &str) {
261 let result = super::OKXHttpClient::option_summary_expiry_key(symbol);
262 assert!(result.is_err());
263 let err = result.unwrap_err().to_string();
264 assert!(
265 err.contains("Expected OKX option symbol with expiry"),
266 "unexpected error: {err}"
267 );
268 }
269
270 #[rstest]
271 fn test_option_summary_expiry_key_extracts_base_quote_expiry() {
272 let result =
273 super::OKXHttpClient::option_summary_expiry_key("BTC-USD-241217-92000-C").unwrap();
274 assert_eq!(result, "BTC-USD-241217");
275 }
276
277 #[rstest]
278 #[case("BTC-USD")]
279 #[case("BTC-USD-241217")]
280 #[case("BTC-USD-241217-92000")]
281 fn test_option_summary_exp_time_rejects_short_symbol(#[case] symbol: &str) {
282 let result = super::OKXHttpClient::option_summary_exp_time(symbol);
283 assert!(result.is_err());
284 let err = result.unwrap_err().to_string();
285 assert!(
286 err.contains("Expected OKX option symbol with expiry"),
287 "unexpected error: {err}"
288 );
289 }
290
291 #[rstest]
292 fn test_option_summary_exp_time_extracts_expiry() {
293 let result =
294 super::OKXHttpClient::option_summary_exp_time("BTC-USD-241217-92000-C").unwrap();
295 assert_eq!(result, Some("241217".to_string()));
296 }
297}
298
299pub static OKX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
308 Quota::per_second(NonZeroU32::new(250).expect("non-zero")).expect("valid constant")
309});
310
311const OKX_GLOBAL_RATE_KEY: &str = "okx:global";
312
313const OKX_PAGE_SIZE: usize = 100;
315
316const MAX_RECONCILIATION_PAGES: usize = 50;
318
319#[derive(Debug, Serialize, Deserialize)]
321pub struct OKXResponse<T> {
322 pub code: String,
324 pub msg: String,
326 pub data: Vec<T>,
328}
329
330pub struct OKXRawHttpClient {
336 base_url: String,
337 client: HttpClient,
338 credential: Option<Credential>,
339 retry_manager: RetryManager<OKXHttpError>,
340 cancellation_token: CancellationToken,
341 environment: OKXEnvironment,
342}
343
344impl Default for OKXRawHttpClient {
345 fn default() -> Self {
346 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
347 .expect("Failed to create default OKXRawHttpClient")
348 }
349}
350
351impl Debug for OKXRawHttpClient {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 let credential = self.credential.as_ref().map(|_| REDACTED);
354 f.debug_struct(stringify!(OKXRawHttpClient))
355 .field("base_url", &self.base_url)
356 .field("credential", &credential)
357 .finish_non_exhaustive()
358 }
359}
360
361impl OKXRawHttpClient {
362 fn rate_limiter_quotas() -> Vec<(String, Quota)> {
363 vec![
364 (OKX_GLOBAL_RATE_KEY.to_string(), *OKX_REST_QUOTA),
365 (
366 "okx:/api/v5/account/set-position-mode".to_string(),
367 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
368 ),
369 (
370 "okx:/api/v5/account/balance".to_string(),
371 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
372 ),
373 (
374 "okx:/api/v5/account/trade-fee".to_string(),
375 Quota::per_second(NonZeroU32::new(2).expect("non-zero")).expect("valid constant"),
376 ),
377 (
378 "okx:/api/v5/account/positions".to_string(),
379 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
380 ),
381 (
382 "okx:/api/v5/account/positions-history".to_string(),
383 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
384 ),
385 (
386 "okx:/api/v5/public/instruments".to_string(),
387 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
388 ),
389 (
390 "okx:/api/v5/public/position-tiers".to_string(),
391 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
392 ),
393 (
394 "okx:/api/v5/public/event-contract/series".to_string(),
395 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
396 ),
397 (
398 "okx:/api/v5/public/event-contract/events".to_string(),
399 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
400 ),
401 (
402 "okx:/api/v5/public/event-contract/markets".to_string(),
403 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
404 ),
405 (
406 "okx:/api/v5/public/opt-summary".to_string(),
407 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
408 ),
409 (
410 "okx:/api/v5/public/time".to_string(),
411 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
412 ),
413 (
414 "okx:/api/v5/public/mark-price".to_string(),
415 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
416 ),
417 (
418 "okx:/api/v5/sprd/spreads".to_string(),
419 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
420 ),
421 (
422 "okx:/api/v5/sprd/order".to_string(),
423 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
424 ),
425 (
426 "okx:/api/v5/sprd/cancel-order".to_string(),
427 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
428 ),
429 (
430 "okx:/api/v5/sprd/mass-cancel".to_string(),
431 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
432 ),
433 (
434 "okx:/api/v5/sprd/orders-pending".to_string(),
435 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
436 ),
437 (
438 "okx:/api/v5/sprd/orders-history".to_string(),
439 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
440 ),
441 (
442 "okx:/api/v5/sprd/trades".to_string(),
443 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
444 ),
445 (
446 "okx:/api/v5/market/index-tickers".to_string(),
447 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
448 ),
449 (
450 "okx:/api/v5/market/books".to_string(),
451 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
452 ),
453 (
454 "okx:/api/v5/market/candles".to_string(),
455 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
456 ),
457 (
458 "okx:/api/v5/market/history-candles".to_string(),
459 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
460 ),
461 (
462 "okx:/api/v5/market/history-trades".to_string(),
463 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
464 ),
465 (
466 "okx:/api/v5/public/funding-rate-history".to_string(),
467 Quota::per_second(NonZeroU32::new(5).expect("non-zero")).expect("valid constant"),
468 ),
469 (
470 "okx:/api/v5/trade/order".to_string(),
471 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
472 ),
473 (
474 "okx:/api/v5/trade/cancel-batch-orders".to_string(),
475 Quota::per_second(NonZeroU32::new(7).expect("non-zero")).expect("valid constant"),
476 ),
477 (
478 "okx:/api/v5/trade/orders-pending".to_string(),
479 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
480 ),
481 (
482 "okx:/api/v5/trade/orders-history".to_string(),
483 Quota::per_second(NonZeroU32::new(20).expect("non-zero")).expect("valid constant"),
484 ),
485 (
486 "okx:/api/v5/trade/fills".to_string(),
487 Quota::per_second(NonZeroU32::new(30).expect("non-zero")).expect("valid constant"),
488 ),
489 (
490 "okx:/api/v5/trade/order-algo".to_string(),
491 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
492 ),
493 (
494 "okx:/api/v5/trade/cancel-algos".to_string(),
495 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
496 ),
497 (
498 "okx:/api/v5/trade/cancel-advance-algos".to_string(),
499 Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant"),
500 ),
501 (
502 "okx:/api/v5/trade/amend-algos".to_string(),
503 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
504 ),
505 (
506 "okx:/api/v5/trade/orders-algo-pending".to_string(),
507 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
508 ),
509 (
510 "okx:/api/v5/trade/orders-algo-history".to_string(),
511 Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant"),
512 ),
513 ]
514 }
515
516 fn rate_limit_keys(endpoint: &str) -> Vec<Ustr> {
517 let normalized = endpoint.split('?').next().unwrap_or(endpoint);
518 let route = format!("okx:{normalized}");
519
520 vec![Ustr::from(OKX_GLOBAL_RATE_KEY), Ustr::from(route.as_str())]
521 }
522
523 pub fn cancel_all_requests(&self) {
525 self.cancellation_token.cancel();
526 }
527
528 pub fn cancellation_token(&self) -> &CancellationToken {
530 &self.cancellation_token
531 }
532
533 pub fn new(
543 base_url: Option<String>,
544 timeout_secs: u64,
545 max_retries: u32,
546 retry_delay_ms: u64,
547 retry_delay_max_ms: u64,
548 environment: OKXEnvironment,
549 proxy_url: Option<String>,
550 ) -> Result<Self, OKXHttpError> {
551 let retry_config = RetryConfig {
552 max_retries,
553 initial_delay_ms: retry_delay_ms,
554 max_delay_ms: retry_delay_max_ms,
555 backoff_factor: 2.0,
556 jitter_ms: 1000,
557 operation_timeout_ms: Some(60_000),
558 immediate_first: false,
559 max_elapsed_ms: Some(180_000),
560 };
561
562 let retry_manager = RetryManager::new(retry_config);
563
564 Ok(Self {
565 base_url: base_url.unwrap_or(OKX_HTTP_URL.to_string()),
566 client: HttpClient::new(
567 Self::default_headers(environment),
568 vec![],
569 Self::rate_limiter_quotas(),
570 Some(*OKX_REST_QUOTA),
571 Some(timeout_secs),
572 proxy_url,
573 )
574 .map_err(|e| {
575 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
576 })?,
577 credential: None,
578 retry_manager,
579 cancellation_token: CancellationToken::new(),
580 environment,
581 })
582 }
583
584 #[expect(clippy::too_many_arguments)]
591 pub fn with_credentials(
592 api_key: String,
593 api_secret: String,
594 api_passphrase: String,
595 base_url: String,
596 timeout_secs: u64,
597 max_retries: u32,
598 retry_delay_ms: u64,
599 retry_delay_max_ms: u64,
600 environment: OKXEnvironment,
601 proxy_url: Option<String>,
602 ) -> Result<Self, OKXHttpError> {
603 let retry_config = RetryConfig {
604 max_retries,
605 initial_delay_ms: retry_delay_ms,
606 max_delay_ms: retry_delay_max_ms,
607 backoff_factor: 2.0,
608 jitter_ms: 1000,
609 operation_timeout_ms: Some(60_000),
610 immediate_first: false,
611 max_elapsed_ms: Some(180_000),
612 };
613
614 let retry_manager = RetryManager::new(retry_config);
615
616 Ok(Self {
617 base_url,
618 client: HttpClient::new(
619 Self::default_headers(environment),
620 vec![],
621 Self::rate_limiter_quotas(),
622 Some(*OKX_REST_QUOTA),
623 Some(timeout_secs),
624 proxy_url,
625 )
626 .map_err(|e| {
627 OKXHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
628 })?,
629 credential: Some(Credential::new(api_key, api_secret, api_passphrase)),
630 retry_manager,
631 cancellation_token: CancellationToken::new(),
632 environment,
633 })
634 }
635
636 fn default_headers(environment: OKXEnvironment) -> HashMap<String, String> {
638 let mut headers =
639 HashMap::from([(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())]);
640
641 if environment == OKXEnvironment::Demo {
642 headers.insert("x-simulated-trading".to_string(), "1".to_string());
643 }
644
645 headers
646 }
647
648 fn sign_request(
655 &self,
656 method: &Method,
657 path: &str,
658 body: Option<&[u8]>,
659 ) -> Result<HashMap<String, String>, OKXHttpError> {
660 let credential = match self.credential.as_ref() {
661 Some(c) => c,
662 None => return Err(OKXHttpError::MissingCredentials),
663 };
664
665 let api_key = credential.api_key().to_string();
666 let api_passphrase = credential.api_passphrase().to_string();
667
668 let now = Utc::now();
670 let millis = now.timestamp_subsec_millis();
671 let timestamp = now.format("%Y-%m-%dT%H:%M:%S").to_string() + &format!(".{millis:03}Z");
672 let signature = credential.sign_bytes(×tamp, method.as_str(), path, body);
673
674 let mut headers = HashMap::new();
675 headers.insert("OK-ACCESS-KEY".to_string(), api_key);
676 headers.insert("OK-ACCESS-PASSPHRASE".to_string(), api_passphrase);
677 headers.insert("OK-ACCESS-TIMESTAMP".to_string(), timestamp);
678 headers.insert("OK-ACCESS-SIGN".to_string(), signature);
679
680 Ok(headers)
681 }
682
683 async fn send_request<T: DeserializeOwned, P: Serialize>(
699 &self,
700 method: Method,
701 path: &str,
702 params: Option<&P>,
703 body: Option<Vec<u8>>,
704 authenticate: bool,
705 ) -> Result<Vec<T>, OKXHttpError> {
706 let url = format!("{}{path}", self.base_url);
707
708 let rate_keys: Vec<String> = Self::rate_limit_keys(path)
710 .into_iter()
711 .map(|k| k.to_string())
712 .collect();
713
714 let operation = || {
715 let url = url.clone();
716 let method = method.clone();
717 let body = body.clone();
718 let rate_keys = rate_keys.clone();
719
720 async move {
721 let query_string = if let Some(p) = params {
723 serde_urlencoded::to_string(p).map_err(|e| {
724 OKXHttpError::JsonError(format!("Failed to serialize params: {e}"))
725 })?
726 } else {
727 String::new()
728 };
729
730 let full_path = if query_string.is_empty() {
732 path.to_string()
733 } else {
734 format!("{path}?{query_string}")
735 };
736
737 let mut headers = if authenticate {
738 self.sign_request(&method, &full_path, body.as_deref())?
739 } else {
740 HashMap::new()
741 };
742
743 if body.is_some() {
745 headers.insert("Content-Type".to_string(), "application/json".to_string());
746 }
747
748 let resp = self
749 .client
750 .request_with_params(
751 method.clone(),
752 url,
753 params,
754 Some(headers),
755 body,
756 None,
757 Some(rate_keys),
758 )
759 .await?;
760
761 log::trace!("Response: {resp:?}");
762
763 if resp.status.is_success() {
764 let okx_response: OKXResponse<T> = deserialize_okx_response(&resp.body)
765 .map_err(|e| {
766 log::warn!("Failed to deserialize OKXResponse: {e}");
767 OKXHttpError::JsonError(e.to_string())
768 })?;
769
770 if okx_response.code != OKX_SUCCESS_CODE {
771 return Err(OKXHttpError::OkxError {
772 error_code: okx_response.code,
773 message: resolve_okx_error_message(&resp.body, &okx_response.msg),
774 });
775 }
776
777 Ok(okx_response.data)
778 } else {
779 let error_body = String::from_utf8_lossy(&resp.body);
780 if resp.status.as_u16() == StatusCode::NOT_FOUND.as_u16() {
781 log::debug!("HTTP 404 with body: {error_body}");
782 } else {
783 log::warn!(
784 "HTTP error {} with body: {error_body}",
785 resp.status.as_str()
786 );
787 }
788
789 if let Ok(parsed_error) = deserialize_okx_response::<T>(&resp.body) {
790 return Err(OKXHttpError::OkxError {
791 error_code: parsed_error.code,
792 message: resolve_okx_error_message(&resp.body, &parsed_error.msg),
793 });
794 }
795
796 Err(OKXHttpError::UnexpectedStatus {
797 status: StatusCode::from_u16(resp.status.as_u16())
800 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
801 body: error_body.to_string(),
802 })
803 }
804 }
805 };
806
807 let should_retry = |error: &OKXHttpError| -> bool { error.is_retryable() };
816
817 let create_error = |msg: String| -> OKXHttpError {
818 if msg == "canceled" {
819 OKXHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
820 } else {
821 OKXHttpError::ValidationError(msg)
822 }
823 };
824
825 let result = self
826 .retry_manager
827 .execute_with_retry_with_cancel(
828 path,
829 operation,
830 should_retry,
831 create_error,
832 &self.cancellation_token,
833 )
834 .await;
835
836 if let Err(ref e) = result
837 && e.is_retryable()
838 {
839 log::error!("Request exhausted retries: path={path}, error={e}");
840 }
841
842 result
843 }
844
845 pub async fn set_position_mode(
856 &self,
857 params: SetPositionModeParams,
858 ) -> Result<Vec<serde_json::Value>, OKXHttpError> {
859 let path = "/api/v5/account/set-position-mode";
860 let body = serde_json::to_vec(¶ms)?;
861 self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
862 .await
863 }
864
865 pub async fn get_position_tiers(
876 &self,
877 params: GetPositionTiersParams,
878 ) -> Result<Vec<OKXPositionTier>, OKXHttpError> {
879 self.send_request(
880 Method::GET,
881 "/api/v5/public/position-tiers",
882 Some(¶ms),
883 None,
884 false,
885 )
886 .await
887 }
888
889 pub async fn get_instruments(
900 &self,
901 params: GetInstrumentsParams,
902 ) -> Result<Vec<OKXInstrument>, OKXHttpError> {
903 self.send_request(
904 Method::GET,
905 "/api/v5/public/instruments",
906 Some(¶ms),
907 None,
908 false,
909 )
910 .await
911 }
912
913 pub async fn get_spreads(
924 &self,
925 params: GetSpreadsParams,
926 ) -> Result<Vec<OKXSpread>, OKXHttpError> {
927 self.send_request(
928 Method::GET,
929 "/api/v5/sprd/spreads",
930 Some(¶ms),
931 None,
932 false,
933 )
934 .await
935 }
936
937 pub async fn place_spread_order(
947 &self,
948 request: OKXPlaceSpreadOrderRequest,
949 ) -> Result<Vec<OKXPlaceOrderResponse>, OKXHttpError> {
950 let body =
951 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
952
953 self.send_request(
954 Method::POST,
955 "/api/v5/sprd/order",
956 None::<&()>,
957 Some(body),
958 true,
959 )
960 .await
961 }
962
963 pub async fn cancel_spread_order(
973 &self,
974 request: OKXCancelSpreadOrderRequest,
975 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
976 let body =
977 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
978
979 self.send_request(
980 Method::POST,
981 "/api/v5/sprd/cancel-order",
982 None::<&()>,
983 Some(body),
984 true,
985 )
986 .await
987 }
988
989 pub async fn cancel_all_spread_orders(
999 &self,
1000 request: OKXCancelAllSpreadOrdersRequest,
1001 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
1002 let body =
1003 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
1004
1005 self.send_request(
1006 Method::POST,
1007 "/api/v5/sprd/mass-cancel",
1008 None::<&()>,
1009 Some(body),
1010 true,
1011 )
1012 .await
1013 }
1014
1015 pub async fn get_spread_order(
1025 &self,
1026 params: GetSpreadOrderParams,
1027 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1028 self.send_request(Method::GET, "/api/v5/sprd/order", Some(¶ms), None, true)
1029 .await
1030 }
1031
1032 pub async fn get_spread_orders_pending(
1042 &self,
1043 params: GetSpreadOrdersParams,
1044 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1045 self.send_request(
1046 Method::GET,
1047 "/api/v5/sprd/orders-pending",
1048 Some(¶ms),
1049 None,
1050 true,
1051 )
1052 .await
1053 }
1054
1055 pub async fn get_spread_orders_history(
1065 &self,
1066 params: GetSpreadOrdersParams,
1067 ) -> Result<Vec<OKXSpreadOrder>, OKXHttpError> {
1068 self.send_request(
1069 Method::GET,
1070 "/api/v5/sprd/orders-history",
1071 Some(¶ms),
1072 None,
1073 true,
1074 )
1075 .await
1076 }
1077
1078 pub async fn get_spread_trades(
1088 &self,
1089 params: GetSpreadTradesParams,
1090 ) -> Result<Vec<OKXSpreadTrade>, OKXHttpError> {
1091 self.send_request(
1092 Method::GET,
1093 "/api/v5/sprd/trades",
1094 Some(¶ms),
1095 None,
1096 true,
1097 )
1098 .await
1099 }
1100
1101 pub async fn get_event_contract_series(
1111 &self,
1112 params: GetEventContractSeriesParams,
1113 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
1114 self.send_request(
1115 Method::GET,
1116 "/api/v5/public/event-contract/series",
1117 Some(¶ms),
1118 None,
1119 false,
1120 )
1121 .await
1122 }
1123
1124 pub async fn get_event_contract_events(
1134 &self,
1135 params: GetEventContractEventsParams,
1136 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
1137 self.send_request(
1138 Method::GET,
1139 "/api/v5/public/event-contract/events",
1140 Some(¶ms),
1141 None,
1142 false,
1143 )
1144 .await
1145 }
1146
1147 pub async fn get_event_contract_markets(
1157 &self,
1158 params: GetEventContractMarketsParams,
1159 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
1160 self.send_request(
1161 Method::GET,
1162 "/api/v5/public/event-contract/markets",
1163 Some(¶ms),
1164 None,
1165 false,
1166 )
1167 .await
1168 }
1169
1170 pub async fn get_option_summary(
1180 &self,
1181 params: GetOptionSummaryParams,
1182 ) -> Result<Vec<OKXOptionSummary>, OKXHttpError> {
1183 self.send_request(
1184 Method::GET,
1185 "/api/v5/public/opt-summary",
1186 Some(¶ms),
1187 None,
1188 false,
1189 )
1190 .await
1191 }
1192
1193 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
1207 let response: Vec<OKXServerTime> = self
1208 .send_request::<_, ()>(Method::GET, "/api/v5/public/time", None, None, false)
1209 .await?;
1210 response
1211 .first()
1212 .map(|t| t.ts)
1213 .ok_or_else(|| OKXHttpError::JsonError("Empty server time response".to_string()))
1214 }
1215
1216 pub async fn get_mark_price(
1230 &self,
1231 params: GetMarkPriceParams,
1232 ) -> Result<Vec<OKXMarkPrice>, OKXHttpError> {
1233 self.send_request(
1234 Method::GET,
1235 "/api/v5/public/mark-price",
1236 Some(¶ms),
1237 None,
1238 false,
1239 )
1240 .await
1241 }
1242
1243 pub async fn get_index_tickers(
1253 &self,
1254 params: GetIndexTickerParams,
1255 ) -> Result<Vec<OKXIndexTicker>, OKXHttpError> {
1256 self.send_request(
1257 Method::GET,
1258 "/api/v5/market/index-tickers",
1259 Some(¶ms),
1260 None,
1261 false,
1262 )
1263 .await
1264 }
1265
1266 pub async fn get_history_trades(
1276 &self,
1277 params: GetTradesParams,
1278 ) -> Result<Vec<OKXTrade>, OKXHttpError> {
1279 self.send_request(
1280 Method::GET,
1281 "/api/v5/market/history-trades",
1282 Some(¶ms),
1283 None,
1284 false,
1285 )
1286 .await
1287 }
1288
1289 pub async fn get_order_book(
1299 &self,
1300 params: GetOrderBookParams,
1301 ) -> Result<Vec<OKXOrderBookSnapshot>, OKXHttpError> {
1302 self.send_request(
1303 Method::GET,
1304 "/api/v5/market/books",
1305 Some(¶ms),
1306 None,
1307 false,
1308 )
1309 .await
1310 }
1311
1312 pub async fn get_funding_rate_history(
1322 &self,
1323 params: GetFundingRateHistoryParams,
1324 ) -> Result<Vec<OKXFundingRateHistory>, OKXHttpError> {
1325 self.send_request(
1326 Method::GET,
1327 "/api/v5/public/funding-rate-history",
1328 Some(¶ms),
1329 None,
1330 false,
1331 )
1332 .await
1333 }
1334
1335 pub async fn get_candles(
1345 &self,
1346 params: GetCandlesticksParams,
1347 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1348 self.send_request(
1349 Method::GET,
1350 "/api/v5/market/candles",
1351 Some(¶ms),
1352 None,
1353 false,
1354 )
1355 .await
1356 }
1357
1358 pub async fn get_history_candles(
1368 &self,
1369 params: GetCandlesticksParams,
1370 ) -> Result<Vec<OKXCandlestick>, OKXHttpError> {
1371 self.send_request(
1372 Method::GET,
1373 "/api/v5/market/history-candles",
1374 Some(¶ms),
1375 None,
1376 false,
1377 )
1378 .await
1379 }
1380
1381 pub async fn get_balance(&self) -> Result<Vec<OKXAccount>, OKXHttpError> {
1392 let path = "/api/v5/account/balance";
1393 self.send_request::<_, ()>(Method::GET, path, None, None, true)
1394 .await
1395 }
1396
1397 pub async fn get_trade_fee(
1409 &self,
1410 params: GetTradeFeeParams,
1411 ) -> Result<Vec<OKXFeeRate>, OKXHttpError> {
1412 self.send_request(
1413 Method::GET,
1414 "/api/v5/account/trade-fee",
1415 Some(¶ms),
1416 None,
1417 true,
1418 )
1419 .await
1420 }
1421
1422 pub async fn get_order(
1432 &self,
1433 params: GetOrderParams,
1434 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1435 self.send_request(
1436 Method::GET,
1437 "/api/v5/trade/order",
1438 Some(¶ms),
1439 None,
1440 true,
1441 )
1442 .await
1443 }
1444
1445 pub async fn get_orders_pending(
1455 &self,
1456 params: GetOrderListParams,
1457 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1458 self.send_request(
1459 Method::GET,
1460 "/api/v5/trade/orders-pending",
1461 Some(¶ms),
1462 None,
1463 true,
1464 )
1465 .await
1466 }
1467
1468 pub async fn get_orders_history(
1478 &self,
1479 params: GetOrderHistoryParams,
1480 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1481 self.send_request(
1482 Method::GET,
1483 "/api/v5/trade/orders-history",
1484 Some(¶ms),
1485 None,
1486 true,
1487 )
1488 .await
1489 }
1490
1491 pub async fn get_order_algo_pending(
1497 &self,
1498 params: GetAlgoOrdersParams,
1499 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1500 self.send_request(
1501 Method::GET,
1502 "/api/v5/trade/orders-algo-pending",
1503 Some(¶ms),
1504 None,
1505 true,
1506 )
1507 .await
1508 }
1509
1510 pub async fn get_order_algo_history(
1516 &self,
1517 params: GetAlgoOrdersParams,
1518 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1519 self.send_request(
1520 Method::GET,
1521 "/api/v5/trade/orders-algo-history",
1522 Some(¶ms),
1523 None,
1524 true,
1525 )
1526 .await
1527 }
1528
1529 pub async fn get_fills(
1539 &self,
1540 params: GetTransactionDetailsParams,
1541 ) -> Result<Vec<OKXTransactionDetail>, OKXHttpError> {
1542 self.send_request(
1543 Method::GET,
1544 "/api/v5/trade/fills",
1545 Some(¶ms),
1546 None,
1547 true,
1548 )
1549 .await
1550 }
1551
1552 pub async fn get_positions(
1564 &self,
1565 params: GetPositionsParams,
1566 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
1567 self.send_request(
1568 Method::GET,
1569 "/api/v5/account/positions",
1570 Some(¶ms),
1571 None,
1572 true,
1573 )
1574 .await
1575 }
1576
1577 pub async fn get_positions_history(
1587 &self,
1588 params: GetPositionsHistoryParams,
1589 ) -> Result<Vec<OKXPositionHistory>, OKXHttpError> {
1590 self.send_request(
1591 Method::GET,
1592 "/api/v5/account/positions-history",
1593 Some(¶ms),
1594 None,
1595 true,
1596 )
1597 .await
1598 }
1599}
1600
1601#[derive(Debug)]
1606#[cfg_attr(
1607 feature = "python",
1608 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.okx", from_py_object)
1609)]
1610#[cfg_attr(
1611 feature = "python",
1612 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
1613)]
1614pub struct OKXHttpClient {
1615 pub(crate) inner: Arc<OKXRawHttpClient>,
1616 pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1617 clock: &'static AtomicTime,
1618 cache_initialized: AtomicBool,
1619}
1620
1621impl Clone for OKXHttpClient {
1622 fn clone(&self) -> Self {
1623 let cache_initialized = AtomicBool::new(false);
1624
1625 let is_initialized = self.cache_initialized.load(Ordering::Acquire);
1626 if is_initialized {
1627 cache_initialized.store(true, Ordering::Release);
1628 }
1629
1630 Self {
1631 inner: self.inner.clone(),
1632 instruments_cache: self.instruments_cache.clone(),
1633 cache_initialized,
1634 clock: self.clock,
1635 }
1636 }
1637}
1638
1639impl Default for OKXHttpClient {
1640 fn default() -> Self {
1641 Self::new(None, 60, 3, 1000, 10_000, OKXEnvironment::Live, None)
1642 .expect("Failed to create default OKXHttpClient")
1643 }
1644}
1645
1646impl OKXHttpClient {
1647 pub fn new(
1657 base_url: Option<String>,
1658 timeout_secs: u64,
1659 max_retries: u32,
1660 retry_delay_ms: u64,
1661 retry_delay_max_ms: u64,
1662 environment: OKXEnvironment,
1663 proxy_url: Option<String>,
1664 ) -> anyhow::Result<Self> {
1665 Ok(Self {
1666 inner: Arc::new(OKXRawHttpClient::new(
1667 base_url,
1668 timeout_secs,
1669 max_retries,
1670 retry_delay_ms,
1671 retry_delay_max_ms,
1672 environment,
1673 proxy_url,
1674 )?),
1675 instruments_cache: Arc::new(AtomicMap::new()),
1676 cache_initialized: AtomicBool::new(false),
1677 clock: get_atomic_clock_realtime(),
1678 })
1679 }
1680
1681 fn generate_ts_init(&self) -> UnixNanos {
1683 self.clock.get_time_ns()
1684 }
1685
1686 pub fn from_env() -> anyhow::Result<Self> {
1693 Self::with_credentials(
1694 None,
1695 None,
1696 None,
1697 None,
1698 60,
1699 3,
1700 1000,
1701 10_000,
1702 OKXEnvironment::Live,
1703 None,
1704 )
1705 }
1706
1707 #[expect(clippy::too_many_arguments)]
1714 pub fn with_credentials(
1715 api_key: Option<String>,
1716 api_secret: Option<String>,
1717 api_passphrase: Option<String>,
1718 base_url: Option<String>,
1719 timeout_secs: u64,
1720 max_retries: u32,
1721 retry_delay_ms: u64,
1722 retry_delay_max_ms: u64,
1723 environment: OKXEnvironment,
1724 proxy_url: Option<String>,
1725 ) -> anyhow::Result<Self> {
1726 let api_key = get_or_env_var(api_key, "OKX_API_KEY")?;
1727 let api_secret = get_or_env_var(api_secret, "OKX_API_SECRET")?;
1728 let api_passphrase = get_or_env_var(api_passphrase, "OKX_API_PASSPHRASE")?;
1729 let base_url = base_url.unwrap_or(OKX_HTTP_URL.to_string());
1730
1731 Ok(Self {
1732 inner: Arc::new(OKXRawHttpClient::with_credentials(
1733 api_key,
1734 api_secret,
1735 api_passphrase,
1736 base_url,
1737 timeout_secs,
1738 max_retries,
1739 retry_delay_ms,
1740 retry_delay_max_ms,
1741 environment,
1742 proxy_url,
1743 )?),
1744 instruments_cache: Arc::new(AtomicMap::new()),
1745 cache_initialized: AtomicBool::new(false),
1746 clock: get_atomic_clock_realtime(),
1747 })
1748 }
1749
1750 fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
1756 self.instruments_cache
1757 .get_cloned(&symbol)
1758 .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not in cache"))
1759 }
1760
1761 fn instrument_from_cache_by_id(
1762 &self,
1763 instrument_id: InstrumentId,
1764 ) -> anyhow::Result<InstrumentAny> {
1765 self.instruments_cache
1766 .get_cloned(&instrument_id.symbol.inner())
1767 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
1768 }
1769
1770 pub fn cancel_all_requests(&self) {
1772 self.inner.cancel_all_requests();
1773 }
1774
1775 pub fn cancellation_token(&self) -> &CancellationToken {
1777 self.inner.cancellation_token()
1778 }
1779
1780 pub async fn get_orders_pending(
1786 &self,
1787 params: GetOrderListParams,
1788 ) -> Result<Vec<OKXOrderHistory>, OKXHttpError> {
1789 self.inner.get_orders_pending(params).await
1790 }
1791
1792 pub async fn get_order_algo_pending(
1798 &self,
1799 params: GetAlgoOrdersParams,
1800 ) -> Result<Vec<OKXOrderAlgo>, OKXHttpError> {
1801 self.inner.get_order_algo_pending(params).await
1802 }
1803
1804 pub async fn get_positions(
1810 &self,
1811 params: GetPositionsParams,
1812 ) -> Result<Vec<OKXPosition>, OKXHttpError> {
1813 self.inner.get_positions(params).await
1814 }
1815
1816 pub fn base_url(&self) -> &str {
1818 self.inner.base_url.as_str()
1819 }
1820
1821 pub fn api_key(&self) -> Option<&str> {
1823 self.inner.credential.as_ref().map(|c| c.api_key())
1824 }
1825
1826 #[must_use]
1828 pub fn api_key_masked(&self) -> Option<String> {
1829 self.inner.credential.as_ref().map(|c| c.api_key_masked())
1830 }
1831
1832 #[must_use]
1834 pub fn is_demo(&self) -> bool {
1835 self.inner.environment == OKXEnvironment::Demo
1836 }
1837
1838 pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
1846 self.inner.get_server_time().await
1847 }
1848
1849 #[must_use]
1853 pub fn is_initialized(&self) -> bool {
1854 self.cache_initialized.load(Ordering::Acquire)
1855 }
1856
1857 #[must_use]
1860 pub fn get_cached_symbols(&self) -> Vec<String> {
1861 self.instruments_cache
1862 .load()
1863 .keys()
1864 .map(|k| k.to_string())
1865 .collect()
1866 }
1867
1868 pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1872 self.instruments_cache.rcu(|m| {
1873 for inst in instruments {
1874 m.insert(inst.raw_symbol().inner(), inst.clone());
1875 }
1876 });
1877 self.cache_initialized.store(true, Ordering::Release);
1878 }
1879
1880 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1884 self.instruments_cache
1885 .insert(instrument.raw_symbol().inner(), instrument);
1886 self.cache_initialized.store(true, Ordering::Release);
1887 }
1888
1889 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1891 self.instruments_cache.get_cloned(symbol)
1892 }
1893
1894 pub async fn request_account_state(
1900 &self,
1901 account_id: AccountId,
1902 ) -> anyhow::Result<AccountState> {
1903 let resp = self
1904 .inner
1905 .get_balance()
1906 .await
1907 .map_err(|e| anyhow::anyhow!(e))?;
1908
1909 let ts_init = self.generate_ts_init();
1910 let raw = resp
1911 .first()
1912 .ok_or_else(|| anyhow::anyhow!("No account state returned from OKX"))?;
1913 let account_state = parse_account_state(raw, account_id, ts_init)?;
1914
1915 Ok(account_state)
1916 }
1917
1918 pub async fn set_position_mode(&self, position_mode: OKXPositionMode) -> anyhow::Result<()> {
1931 let mut params = SetPositionModeParamsBuilder::default();
1932 params.pos_mode(position_mode);
1933 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
1934
1935 match self.inner.set_position_mode(params).await {
1936 Ok(_) => Ok(()),
1937 Err(e) => {
1938 if let OKXHttpError::OkxError {
1939 error_code,
1940 message,
1941 } = &e
1942 && error_code == "50115"
1943 {
1944 log::warn!(
1945 "Account does not support position mode setting (derivatives trading not enabled): {message}"
1946 );
1947 return Ok(()); }
1949 anyhow::bail!(e)
1950 }
1951 }
1952 }
1953
1954 pub async fn request_instruments(
1966 &self,
1967 instrument_type: OKXInstrumentType,
1968 instrument_family: Option<String>,
1969 ) -> anyhow::Result<(Vec<InstrumentAny>, Vec<(Ustr, u64)>)> {
1970 let resp = if instrument_type == OKXInstrumentType::Events {
1971 let series_ids = if let Some(series_id) = instrument_family.clone() {
1972 vec![series_id]
1973 } else {
1974 self.inner
1975 .get_event_contract_series(GetEventContractSeriesParams::default())
1976 .await
1977 .map_err(|e| anyhow::anyhow!(e))?
1978 .into_iter()
1979 .map(|series| series.series_id)
1980 .collect()
1981 };
1982
1983 let mut event_instruments = Vec::new();
1984
1985 for series_id in series_ids {
1986 let mut params = GetInstrumentsParamsBuilder::default();
1987 params.inst_type(OKXInstrumentType::Events);
1988 params.series_id(series_id);
1989 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
1990 let mut page = self
1991 .inner
1992 .get_instruments(params)
1993 .await
1994 .map_err(|e| anyhow::anyhow!(e))?;
1995 event_instruments.append(&mut page);
1996 }
1997 event_instruments
1998 } else {
1999 let mut params = GetInstrumentsParamsBuilder::default();
2000 params.inst_type(instrument_type);
2001
2002 if let Some(family) = instrument_family.clone() {
2003 params.inst_family(family);
2004 }
2005
2006 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2007
2008 self.inner
2009 .get_instruments(params)
2010 .await
2011 .map_err(|e| anyhow::anyhow!(e))?
2012 };
2013
2014 let fee_rate_opt = {
2015 let fee_params = GetTradeFeeParams {
2016 inst_type: instrument_type,
2017 uly: None,
2018 inst_family: if instrument_type == OKXInstrumentType::Events {
2019 None
2020 } else {
2021 instrument_family
2022 },
2023 };
2024
2025 match self.inner.get_trade_fee(fee_params).await {
2026 Ok(rates) => rates.into_iter().next(),
2027 Err(OKXHttpError::MissingCredentials) => {
2028 log::debug!("Missing credentials for fee rates, using None");
2029 None
2030 }
2031 Err(e) => {
2032 log::warn!("Failed to fetch fee rates for {instrument_type}: {e}");
2033 None
2034 }
2035 }
2036 };
2037
2038 let ts_init = self.generate_ts_init();
2039
2040 let mut instruments: Vec<InstrumentAny> = Vec::new();
2041 let mut inst_id_codes: Vec<(Ustr, u64)> = Vec::new();
2042
2043 for inst in &resp {
2044 if let Some(code) = inst.inst_id_code {
2046 inst_id_codes.push((inst.inst_id, code));
2047 }
2048 if inst.state == OKXInstrumentStatus::Preopen {
2051 continue;
2052 }
2053
2054 let (maker_fee, taker_fee) = if let Some(ref fee_rate) = fee_rate_opt {
2059 let is_usdt_margined = inst.ct_type == OKXContractType::Linear;
2060 let (maker_str, taker_str) = if is_usdt_margined {
2061 (&fee_rate.maker_u, &fee_rate.taker_u)
2062 } else {
2063 (&fee_rate.maker, &fee_rate.taker)
2064 };
2065
2066 let maker = if maker_str.is_empty() {
2067 None
2068 } else {
2069 Decimal::from_str(maker_str).ok().map(|v| -v)
2070 };
2071 let taker = if taker_str.is_empty() {
2072 None
2073 } else {
2074 Decimal::from_str(taker_str).ok().map(|v| -v)
2075 };
2076
2077 (maker, taker)
2078 } else {
2079 (None, None)
2080 };
2081
2082 match parse_instrument_any(inst, None, None, maker_fee, taker_fee, ts_init) {
2083 Ok(Some(instrument_any)) => {
2084 instruments.push(instrument_any);
2085 }
2086 Ok(None) => {
2087 }
2089 Err(e) => {
2090 log::warn!("Failed to parse instrument {}: {e}", inst.inst_id);
2091 }
2092 }
2093 }
2094
2095 Ok((instruments, inst_id_codes))
2096 }
2097
2098 pub async fn request_spread_instruments(
2104 &self,
2105 params: GetSpreadsParams,
2106 ) -> anyhow::Result<Vec<InstrumentAny>> {
2107 let resp = self
2108 .inner
2109 .get_spreads(params)
2110 .await
2111 .map_err(|e| anyhow::anyhow!(e))?;
2112
2113 let ts_init = self.generate_ts_init();
2114 let mut instruments = Vec::new();
2115
2116 for spread in &resp {
2117 match parse_spread_instrument(spread, None, None, None, None, ts_init) {
2118 Ok(instrument) => instruments.push(instrument),
2119 Err(e) => log::warn!("Failed to parse spread {}: {e}", spread.sprd_id),
2120 }
2121 }
2122
2123 Ok(instruments)
2124 }
2125
2126 pub async fn request_instrument(
2137 &self,
2138 instrument_id: InstrumentId,
2139 ) -> anyhow::Result<InstrumentAny> {
2140 let symbol = instrument_id.symbol.as_str();
2141
2142 if is_okx_spread_symbol(symbol) {
2143 let instrument = self.request_spread_instrument(symbol).await?;
2144 self.cache_instrument(instrument.clone());
2145 return Ok(instrument);
2146 }
2147
2148 let instrument_type = okx_instrument_type_from_symbol(symbol);
2149
2150 let resp = if instrument_type == OKXInstrumentType::Events {
2151 let series = self
2152 .inner
2153 .get_event_contract_series(GetEventContractSeriesParams::default())
2154 .await
2155 .map_err(|e| anyhow::anyhow!(e))?;
2156
2157 let mut matched = Vec::new();
2158
2159 for series in series {
2160 let mut params = GetInstrumentsParamsBuilder::default();
2161 params.inst_type(OKXInstrumentType::Events);
2162 params.series_id(series.series_id);
2163 params.inst_id(symbol);
2164 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2165
2166 let mut page = self
2167 .inner
2168 .get_instruments(params)
2169 .await
2170 .map_err(|e| anyhow::anyhow!(e))?;
2171 matched.append(&mut page);
2172 if !matched.is_empty() {
2173 break;
2174 }
2175 }
2176 matched
2177 } else {
2178 let mut params = GetInstrumentsParamsBuilder::default();
2179 params.inst_type(instrument_type);
2180 params.inst_id(symbol);
2181
2182 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2183
2184 self.inner
2185 .get_instruments(params)
2186 .await
2187 .map_err(|e| anyhow::anyhow!(e))?
2188 };
2189
2190 let raw_inst = resp
2191 .first()
2192 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2193
2194 if raw_inst.state == OKXInstrumentStatus::Preopen {
2196 return Err(OKXInstrumentDefinitionError::new(
2197 symbol,
2198 anyhow::anyhow!("instrument is in pre-open state"),
2199 )
2200 .into());
2201 }
2202
2203 let fee_rate_opt = {
2204 let fee_params = GetTradeFeeParams {
2205 inst_type: instrument_type,
2206 uly: None,
2207 inst_family: None,
2208 };
2209
2210 match self.inner.get_trade_fee(fee_params).await {
2211 Ok(rates) => rates.into_iter().next(),
2212 Err(OKXHttpError::MissingCredentials) => {
2213 log::debug!("Missing credentials for fee rates, using None");
2214 None
2215 }
2216 Err(e) => {
2217 log::warn!("Failed to fetch fee rates for {symbol}: {e}");
2218 None
2219 }
2220 }
2221 };
2222
2223 let (maker_fee, taker_fee) = if let Some(ref fee_rate) = fee_rate_opt {
2227 let is_usdt_margined = raw_inst.ct_type == OKXContractType::Linear;
2228 let (maker_str, taker_str) = if is_usdt_margined {
2229 (&fee_rate.maker_u, &fee_rate.taker_u)
2230 } else {
2231 (&fee_rate.maker, &fee_rate.taker)
2232 };
2233
2234 let maker = if maker_str.is_empty() {
2235 None
2236 } else {
2237 Decimal::from_str(maker_str).ok().map(|v| -v)
2238 };
2239 let taker = if taker_str.is_empty() {
2240 None
2241 } else {
2242 Decimal::from_str(taker_str).ok().map(|v| -v)
2243 };
2244
2245 (maker, taker)
2246 } else {
2247 (None, None)
2248 };
2249
2250 let ts_init = self.generate_ts_init();
2251 let Some(instrument) =
2252 parse_instrument_any(raw_inst, None, None, maker_fee, taker_fee, ts_init)
2253 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e))?
2254 else {
2255 return Err(OKXInstrumentDefinitionError::new(
2256 symbol,
2257 anyhow::anyhow!("unsupported instrument type"),
2258 )
2259 .into());
2260 };
2261
2262 self.cache_instrument(instrument.clone());
2263
2264 Ok(instrument)
2265 }
2266
2267 async fn request_spread_instrument(&self, symbol: &str) -> anyhow::Result<InstrumentAny> {
2268 let resp = self
2269 .inner
2270 .get_spreads(GetSpreadsParams {
2271 sprd_id: Some(symbol.to_string()),
2272 ..Default::default()
2273 })
2274 .await
2275 .map_err(|e| anyhow::anyhow!(e))?;
2276
2277 let raw_spread = resp
2278 .first()
2279 .ok_or_else(|| anyhow::anyhow!("Spread instrument {symbol} not found"))?;
2280 let ts_init = self.generate_ts_init();
2281
2282 parse_spread_instrument(raw_spread, None, None, None, None, ts_init)
2283 .map_err(|e| OKXInstrumentDefinitionError::new(symbol, e).into())
2284 }
2285
2286 pub async fn request_event_contract_series(
2292 &self,
2293 params: GetEventContractSeriesParams,
2294 ) -> Result<Vec<OKXEventContractSeries>, OKXHttpError> {
2295 self.inner.get_event_contract_series(params).await
2296 }
2297
2298 pub async fn request_event_contract_events(
2304 &self,
2305 params: GetEventContractEventsParams,
2306 ) -> Result<Vec<OKXEventContractEvent>, OKXHttpError> {
2307 self.inner.get_event_contract_events(params).await
2308 }
2309
2310 pub async fn request_event_contract_markets(
2316 &self,
2317 params: GetEventContractMarketsParams,
2318 ) -> Result<Vec<OKXEventContractMarket>, OKXHttpError> {
2319 self.inner.get_event_contract_markets(params).await
2320 }
2321
2322 pub async fn request_forward_prices(
2328 &self,
2329 underlying: &str,
2330 instrument_id: Option<InstrumentId>,
2331 ) -> anyhow::Result<Vec<ForwardPrice>> {
2332 let requests = self.resolve_forward_price_requests(underlying, instrument_id.as_ref())?;
2333 let requested_symbol = instrument_id.as_ref().map(|id| id.symbol.inner());
2334 let requested_instrument_id = instrument_id.as_ref();
2335 let ts_init = self.generate_ts_init();
2336 let mut forward_prices = Vec::new();
2337 let mut seen_expiries = AHashSet::new();
2338
2339 for (inst_family, exp_time) in requests {
2340 let summaries = self
2341 .inner
2342 .get_option_summary(GetOptionSummaryParams {
2343 inst_family,
2344 exp_time,
2345 })
2346 .await
2347 .map_err(|e| anyhow::anyhow!(e))?;
2348
2349 for summary in summaries {
2350 if summary.inst_type != OKXInstrumentType::Option {
2351 continue;
2352 }
2353
2354 if let Some(symbol) = requested_symbol
2355 && summary.inst_id != symbol
2356 {
2357 continue;
2358 }
2359
2360 let forward_price = match Decimal::from_str(&summary.fwd_px) {
2361 Ok(price) if !price.is_zero() => price,
2362 Ok(_) => continue,
2363 Err(e) => {
2364 log::warn!(
2365 "Skipping invalid OKX forward price for {}: {e}",
2366 summary.inst_id
2367 );
2368 continue;
2369 }
2370 };
2371
2372 if requested_symbol.is_none() {
2373 let expiry_key = Self::option_summary_expiry_key(summary.inst_id.as_str())?;
2374 if !seen_expiries.insert(expiry_key) {
2375 continue;
2376 }
2377 }
2378
2379 let ts_event =
2380 UnixNanos::from(summary.ts.saturating_mul(NANOSECONDS_IN_MILLISECOND));
2381 let instrument_id = if let Some(inst_id) = requested_instrument_id {
2382 *inst_id
2383 } else {
2384 parse_instrument_id(summary.inst_id)
2385 };
2386
2387 forward_prices.push(ForwardPrice::new(
2388 instrument_id,
2389 forward_price,
2390 Some(summary.uly.to_string()),
2391 ts_event,
2392 ts_init,
2393 ));
2394 }
2395 }
2396
2397 Ok(forward_prices)
2398 }
2399
2400 pub async fn request_mark_price(
2406 &self,
2407 instrument_id: InstrumentId,
2408 ) -> anyhow::Result<MarkPriceUpdate> {
2409 let mut params = GetMarkPriceParamsBuilder::default();
2410 params.inst_id(instrument_id.symbol.inner());
2411 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2412
2413 let resp = self
2414 .inner
2415 .get_mark_price(params)
2416 .await
2417 .map_err(|e| anyhow::anyhow!(e))?;
2418
2419 let raw = resp
2420 .first()
2421 .ok_or_else(|| anyhow::anyhow!("No mark price returned from OKX"))?;
2422 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
2423 let ts_init = self.generate_ts_init();
2424
2425 let mark_price =
2426 parse_mark_price_update(raw, instrument_id, inst.price_precision(), ts_init)
2427 .map_err(|e| anyhow::anyhow!(e))?;
2428 Ok(mark_price)
2429 }
2430
2431 fn resolve_forward_price_requests(
2432 &self,
2433 underlying: &str,
2434 instrument_id: Option<&InstrumentId>,
2435 ) -> anyhow::Result<Vec<(String, Option<String>)>> {
2436 if let Some(inst_id) = instrument_id {
2437 let symbol = inst_id.symbol.inner().as_str();
2438 let inst_family = extract_inst_family(symbol)?.to_string();
2439 let exp_time = Self::option_summary_exp_time(symbol)?;
2440 return Ok(vec![(inst_family, exp_time)]);
2441 }
2442
2443 let underlying = Ustr::from(underlying);
2444 let mut families = AHashSet::new();
2445
2446 for instrument in self.instruments_cache.load().values() {
2447 let InstrumentAny::CryptoOption(option) = instrument else {
2448 continue;
2449 };
2450
2451 if option.underlying.code != underlying {
2452 continue;
2453 }
2454
2455 let inst_family = extract_inst_family(option.id.symbol.inner().as_str())?;
2456 families.insert(inst_family.to_string());
2457 }
2458
2459 let mut families: Vec<String> = families.into_iter().collect();
2460 families.sort_unstable();
2461
2462 anyhow::ensure!(
2463 !families.is_empty(),
2464 "No cached OKX option families for underlying {underlying}; provide a sample instrument or pre-load option instruments"
2465 );
2466
2467 Ok(families.into_iter().map(|family| (family, None)).collect())
2468 }
2469
2470 fn option_summary_expiry_key(symbol: &str) -> anyhow::Result<String> {
2471 let parts: Vec<&str> = symbol.split('-').collect();
2472 anyhow::ensure!(
2473 parts.len() >= 5,
2474 "Expected OKX option symbol with expiry, received {symbol}"
2475 );
2476 Ok(format!("{}-{}-{}", parts[0], parts[1], parts[2]))
2477 }
2478
2479 fn option_summary_exp_time(symbol: &str) -> anyhow::Result<Option<String>> {
2480 let parts: Vec<&str> = symbol.split('-').collect();
2481 anyhow::ensure!(
2482 parts.len() >= 5,
2483 "Expected OKX option symbol with expiry, received {symbol}"
2484 );
2485 Ok(Some(parts[2].to_string()))
2486 }
2487
2488 pub async fn request_index_price(
2494 &self,
2495 instrument_id: InstrumentId,
2496 ) -> anyhow::Result<IndexPriceUpdate> {
2497 let symbol = instrument_id.symbol.inner();
2499 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())?;
2500 let inst_id = format!("{base}-{quote}");
2501
2502 let mut params = GetIndexTickerParamsBuilder::default();
2503 params.inst_id(Ustr::from(&inst_id));
2504 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2505
2506 let resp = self
2507 .inner
2508 .get_index_tickers(params)
2509 .await
2510 .map_err(|e| anyhow::anyhow!(e))?;
2511
2512 let raw = resp
2513 .first()
2514 .ok_or_else(|| anyhow::anyhow!("No index price returned from OKX"))?;
2515 let inst = self.instrument_from_cache(instrument_id.symbol.inner())?;
2516 let ts_init = self.generate_ts_init();
2517
2518 let index_price =
2519 parse_index_price_update(raw, instrument_id, inst.price_precision(), ts_init)
2520 .map_err(|e| anyhow::anyhow!(e))?;
2521 Ok(index_price)
2522 }
2523
2524 pub async fn request_book_snapshot(
2530 &self,
2531 instrument_id: InstrumentId,
2532 depth: Option<u32>,
2533 ) -> anyhow::Result<OrderBook> {
2534 let inst = self.instrument_from_cache_by_id(instrument_id)?;
2535 let price_precision = inst.price_precision();
2536 let size_precision = inst.size_precision();
2537
2538 let params = GetOrderBookParams {
2539 inst_id: instrument_id.symbol.to_string(),
2540 sz: depth,
2541 };
2542
2543 let resp = self
2544 .inner
2545 .get_order_book(params)
2546 .await
2547 .map_err(|e| anyhow::anyhow!(e))?;
2548
2549 let snapshot = resp
2550 .first()
2551 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
2552
2553 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
2554 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
2555
2556 for (i, level) in snapshot.bids.iter().enumerate() {
2557 let price = parse_price(&level.0, price_precision)?;
2558 let size = parse_quantity(&level.1, size_precision)?;
2559 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
2560 book.add(order, 0, i as u64, ts_event);
2561 }
2562
2563 let bids_len = snapshot.bids.len();
2564
2565 for (i, level) in snapshot.asks.iter().enumerate() {
2566 let price = parse_price(&level.0, price_precision)?;
2567 let size = parse_quantity(&level.1, size_precision)?;
2568 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
2569 book.add(order, 0, (bids_len + i) as u64, ts_event);
2570 }
2571
2572 log::debug!(
2573 "Fetched order book for {} with {} bids and {} asks",
2574 instrument_id,
2575 snapshot.bids.len(),
2576 snapshot.asks.len(),
2577 );
2578
2579 Ok(book)
2580 }
2581
2582 pub async fn request_orderbook_snapshot(
2588 &self,
2589 instrument_id: InstrumentId,
2590 depth: Option<u32>,
2591 ) -> anyhow::Result<OrderBookDeltas> {
2592 let inst = self.instrument_from_cache_by_id(instrument_id)?;
2593 let price_precision = inst.price_precision();
2594 let size_precision = inst.size_precision();
2595
2596 let params = GetOrderBookParams {
2597 inst_id: instrument_id.symbol.to_string(),
2598 sz: depth,
2599 };
2600
2601 let resp = self
2602 .inner
2603 .get_order_book(params)
2604 .await
2605 .map_err(|e| anyhow::anyhow!(e))?;
2606
2607 let snapshot = resp
2608 .first()
2609 .ok_or_else(|| anyhow::anyhow!("No order book returned from OKX"))?;
2610
2611 let ts_event = UnixNanos::from(snapshot.ts * NANOSECONDS_IN_MILLISECOND);
2612 let total_levels = snapshot.bids.len() + snapshot.asks.len();
2613 let mut deltas = Vec::with_capacity(total_levels + 1);
2614
2615 let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_event);
2616
2617 if total_levels == 0 {
2618 clear.flags |= RecordFlag::F_LAST as u8;
2619 }
2620 deltas.push(clear);
2621
2622 let mut processed = 0_usize;
2623
2624 for (i, level) in snapshot.bids.iter().enumerate() {
2625 let price = parse_price(&level.0, price_precision)?;
2626 let size = parse_quantity(&level.1, size_precision)?;
2627 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
2628 processed += 1;
2629 let mut flags = RecordFlag::F_SNAPSHOT as u8;
2630
2631 if processed == total_levels {
2632 flags |= RecordFlag::F_LAST as u8;
2633 }
2634 deltas.push(OrderBookDelta::new(
2635 instrument_id,
2636 BookAction::Add,
2637 order,
2638 flags,
2639 0,
2640 ts_event,
2641 ts_event,
2642 ));
2643 }
2644
2645 let bids_len = snapshot.bids.len();
2646
2647 for (i, level) in snapshot.asks.iter().enumerate() {
2648 let price = parse_price(&level.0, price_precision)?;
2649 let size = parse_quantity(&level.1, size_precision)?;
2650 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
2651 processed += 1;
2652 let mut flags = RecordFlag::F_SNAPSHOT as u8;
2653
2654 if processed == total_levels {
2655 flags |= RecordFlag::F_LAST as u8;
2656 }
2657 deltas.push(OrderBookDelta::new(
2658 instrument_id,
2659 BookAction::Add,
2660 order,
2661 flags,
2662 0,
2663 ts_event,
2664 ts_event,
2665 ));
2666 }
2667
2668 log::debug!(
2669 "Fetched order book snapshot for {} with {} bids and {} asks",
2670 instrument_id,
2671 snapshot.bids.len(),
2672 snapshot.asks.len(),
2673 );
2674
2675 OrderBookDeltas::new_checked(instrument_id, deltas)
2676 .context("failed to assemble OrderBookDeltas from OKX snapshot")
2677 }
2678
2679 pub async fn request_funding_rates(
2685 &self,
2686 instrument_id: InstrumentId,
2687 start: Option<DateTime<Utc>>,
2688 end: Option<DateTime<Utc>>,
2689 limit: Option<u32>,
2690 ) -> anyhow::Result<Vec<FundingRateUpdate>> {
2691 let mut params = GetFundingRateHistoryParams {
2692 inst_id: instrument_id.symbol.to_string(),
2693 ..Default::default()
2694 };
2695
2696 if let Some(start) = start {
2698 params.before = Some(start.timestamp_millis().to_string());
2699 }
2700
2701 if let Some(end) = end {
2702 params.after = Some(end.timestamp_millis().to_string());
2703 }
2704
2705 params.limit = limit;
2706
2707 let resp = self
2708 .inner
2709 .get_funding_rate_history(params)
2710 .await
2711 .map_err(|e| anyhow::anyhow!(e))?;
2712
2713 let mut rates = Vec::with_capacity(resp.len());
2714
2715 for window in resp.windows(2) {
2716 let raw = &window[0];
2717 let interval_millis = raw
2718 .funding_time
2719 .checked_sub(window[1].funding_time)
2720 .context("funding interval negative, funding rates out of order")?;
2721 let rate = parse_funding_rate(raw, instrument_id, Some(interval_millis))?;
2722 rates.push(rate);
2723 }
2724
2725 if let Some(last_raw) = resp.last() {
2726 let rate = parse_funding_rate(last_raw, instrument_id, None)?;
2728 rates.push(rate);
2729 }
2730
2731 rates.reverse();
2734
2735 log::debug!(
2736 "Fetched {} funding rates for {}",
2737 rates.len(),
2738 instrument_id,
2739 );
2740
2741 Ok(rates)
2742 }
2743
2744 pub async fn request_trades(
2750 &self,
2751 instrument_id: InstrumentId,
2752 start: Option<DateTime<Utc>>,
2753 end: Option<DateTime<Utc>>,
2754 limit: Option<u32>,
2755 ) -> anyhow::Result<Vec<TradeTick>> {
2756 const OKX_TRADES_MAX_LIMIT: u32 = 100;
2757 const MAX_PAGES: usize = 500;
2758 const MAX_CONSECUTIVE_EMPTY: usize = 3;
2759
2760 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2761 enum Mode {
2762 Latest,
2763 Backward,
2764 Range,
2765 }
2766
2767 let limit = if limit == Some(0) { None } else { limit };
2768
2769 if let (Some(s), Some(e)) = (start, end) {
2770 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
2771 }
2772
2773 let now = Utc::now();
2774
2775 if let Some(s) = start
2776 && s > now
2777 {
2778 return Ok(Vec::new());
2779 }
2780
2781 let end = if let Some(e) = end
2782 && e > now
2783 {
2784 Some(now)
2785 } else {
2786 end
2787 };
2788
2789 let mode = match (start, end) {
2790 (None, None) => Mode::Latest,
2791 (Some(_), None) => Mode::Backward,
2792 (None, Some(_)) => Mode::Backward,
2793 (Some(_), Some(_)) => Mode::Range,
2794 };
2795
2796 let start_ms = start.map(|s| s.timestamp_millis());
2797 let end_ms = end.map(|e| e.timestamp_millis());
2798
2799 let ts_init = self.generate_ts_init();
2800 let inst = self.instrument_from_cache_by_id(instrument_id)?;
2801
2802 if matches!(mode, Mode::Backward | Mode::Range) {
2805 let mut before_trade_id: Option<String> = None;
2806 let mut pages = 0usize;
2807 let mut page_results: Vec<Vec<TradeTick>> = Vec::new();
2808 let mut seen_trades: AHashSet<(String, i64)> = AHashSet::new();
2809 let mut unique_count = 0usize;
2810 let mut consecutive_empty_pages = 0usize;
2811
2812 let effective_limit = if start.is_some() {
2815 limit.unwrap_or(u32::MAX)
2816 } else {
2817 limit.unwrap_or(OKX_TRADES_MAX_LIMIT)
2818 };
2819
2820 log::debug!(
2821 "Starting trades pagination: mode={mode:?}, start={start:?}, end={end:?}, limit={limit:?}, effective_limit={effective_limit}"
2822 );
2823
2824 loop {
2825 if pages >= MAX_PAGES {
2826 log::warn!("Hit MAX_PAGES limit of {MAX_PAGES}");
2827 break;
2828 }
2829
2830 if effective_limit < u32::MAX && unique_count >= effective_limit as usize {
2831 log::debug!("Reached effective limit: unique_count={unique_count}");
2832 break;
2833 }
2834
2835 let remaining = (effective_limit as usize).saturating_sub(unique_count);
2836 let page_cap = remaining.min(OKX_TRADES_MAX_LIMIT as usize) as u32;
2837
2838 log::debug!(
2839 "Requesting page {}: before_id={:?}, page_cap={}, unique_count={}",
2840 pages + 1,
2841 before_trade_id,
2842 page_cap,
2843 unique_count
2844 );
2845
2846 let mut params_builder = GetTradesParamsBuilder::default();
2847 params_builder
2848 .inst_id(instrument_id.symbol.inner())
2849 .limit(page_cap)
2850 .pagination_type(1);
2851
2852 if let Some(ref before_id) = before_trade_id {
2854 params_builder.after(before_id.clone());
2855 }
2856
2857 let params = params_builder.build().map_err(anyhow::Error::new)?;
2858 let raw = self
2859 .inner
2860 .get_history_trades(params)
2861 .await
2862 .map_err(anyhow::Error::new)?;
2863
2864 log::debug!("Received {} raw trades from API", raw.len());
2865
2866 if let (Some(first), Some(last)) = (raw.first(), raw.last()) {
2867 log::debug!(
2868 "Raw response trade ID range: first={} (newest), last={} (oldest)",
2869 first.trade_id,
2870 last.trade_id,
2871 );
2872 }
2873
2874 if raw.is_empty() {
2875 log::debug!("API returned empty page, stopping pagination");
2876 break;
2877 }
2878
2879 pages += 1;
2880
2881 let mut page_trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
2882 let mut hit_start_boundary = false;
2883 let mut filtered_out = 0usize;
2884 let mut duplicates = 0usize;
2885
2886 for r in &raw {
2887 match parse_trade_tick(
2888 r,
2889 instrument_id,
2890 inst.price_precision(),
2891 inst.size_precision(),
2892 ts_init,
2893 ) {
2894 Ok(trade) => {
2895 let ts_ms = trade.ts_event.as_i64() / 1_000_000;
2896
2897 if let Some(e_ms) = end_ms
2898 && ts_ms > e_ms
2899 {
2900 filtered_out += 1;
2901 continue;
2902 }
2903
2904 if let Some(s_ms) = start_ms
2905 && ts_ms < s_ms
2906 {
2907 hit_start_boundary = true;
2908 filtered_out += 1;
2909 break;
2910 }
2911
2912 let trade_key = (trade.trade_id.to_string(), trade.ts_event.as_i64());
2913 if seen_trades.insert(trade_key) {
2914 unique_count += 1;
2915 page_trades.push(trade);
2916 } else {
2917 duplicates += 1;
2918 }
2919 }
2920 Err(e) => log::error!("{e}"),
2921 }
2922 }
2923
2924 log::debug!(
2925 "Page {} processed: {} trades kept, {} filtered out, {} duplicates, hit_start_boundary={}",
2926 pages,
2927 page_trades.len(),
2928 filtered_out,
2929 duplicates,
2930 hit_start_boundary
2931 );
2932
2933 let oldest_trade_id = if page_trades.is_empty() {
2935 if unique_count > 0 {
2938 consecutive_empty_pages += 1;
2939 if consecutive_empty_pages >= MAX_CONSECUTIVE_EMPTY {
2940 log::debug!(
2941 "Stopping: {consecutive_empty_pages} consecutive pages with no trades in range after collecting {unique_count} trades"
2942 );
2943 break;
2944 }
2945 }
2946 raw.last().map(|t| {
2948 let id = t.trade_id.to_string();
2949 log::debug!(
2950 "Setting cursor from raw response (no unique trades): oldest_id={id}"
2951 );
2952 id
2953 })
2954 } else {
2955 let oldest_id = page_trades.last().map(|t| {
2957 let id = t.trade_id.to_string();
2958 log::debug!(
2959 "Setting cursor from deduplicated trades: oldest_id={}, ts_event={}",
2960 id,
2961 t.ts_event.as_i64()
2962 );
2963 id
2964 });
2965 page_trades.reverse();
2966 page_results.push(page_trades);
2967 consecutive_empty_pages = 0;
2968 oldest_id
2969 };
2970
2971 if let Some(ref old_id) = before_trade_id
2972 && oldest_trade_id.as_ref() == Some(old_id)
2973 {
2974 break;
2975 }
2976
2977 if oldest_trade_id.is_none() {
2978 break;
2979 }
2980
2981 before_trade_id = oldest_trade_id;
2982
2983 if hit_start_boundary {
2984 break;
2985 }
2986
2987 tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
2988 }
2989
2990 log::debug!(
2991 "Pagination complete: {pages} pages, {unique_count} unique trades collected"
2992 );
2993
2994 let mut out: Vec<TradeTick> = Vec::new();
2995
2996 for page in page_results.into_iter().rev() {
2997 out.extend(page);
2998 }
2999
3000 let mut dedup_keys = AHashSet::new();
3002 let pre_dedup_len = out.len();
3003 out.retain(|trade| {
3004 dedup_keys.insert((trade.trade_id.to_string(), trade.ts_event.as_i64()))
3005 });
3006
3007 if out.len() < pre_dedup_len {
3008 log::debug!(
3009 "Removed {} duplicate trades during final dedup",
3010 pre_dedup_len - out.len()
3011 );
3012 }
3013
3014 if let Some(lim) = limit
3015 && lim > 0
3016 && out.len() > lim as usize
3017 {
3018 let excess = out.len() - lim as usize;
3019 log::debug!("Trimming {excess} oldest trades to respect limit={lim}");
3020 out.drain(0..excess);
3021 }
3022
3023 log::debug!("Returning {} trades", out.len());
3024 return Ok(out);
3025 }
3026
3027 let req_limit = limit
3028 .unwrap_or(OKX_TRADES_MAX_LIMIT)
3029 .min(OKX_TRADES_MAX_LIMIT);
3030 let params = GetTradesParamsBuilder::default()
3031 .inst_id(instrument_id.symbol.inner())
3032 .limit(req_limit)
3033 .build()
3034 .map_err(anyhow::Error::new)?;
3035
3036 let raw = self
3037 .inner
3038 .get_history_trades(params)
3039 .await
3040 .map_err(anyhow::Error::new)?;
3041
3042 let mut trades: Vec<TradeTick> = Vec::with_capacity(raw.len());
3043
3044 for r in &raw {
3045 match parse_trade_tick(
3046 r,
3047 instrument_id,
3048 inst.price_precision(),
3049 inst.size_precision(),
3050 ts_init,
3051 ) {
3052 Ok(trade) => trades.push(trade),
3053 Err(e) => log::error!("{e}"),
3054 }
3055 }
3056
3057 trades.reverse();
3059
3060 if let Some(lim) = limit
3061 && lim > 0
3062 && trades.len() > lim as usize
3063 {
3064 trades.drain(0..trades.len() - lim as usize);
3065 }
3066
3067 Ok(trades)
3068 }
3069
3070 pub async fn request_bars(
3111 &self,
3112 bar_type: BarType,
3113 start: Option<DateTime<Utc>>,
3114 mut end: Option<DateTime<Utc>>,
3115 limit: Option<u32>,
3116 ) -> anyhow::Result<Vec<Bar>> {
3117 const HISTORY_SPLIT_DAYS: i64 = 100;
3118 const MAX_PAGES_SOFT: usize = 500;
3119
3120 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
3121 enum Mode {
3122 Latest,
3123 Backward,
3124 Range,
3125 }
3126
3127 let limit = if limit == Some(0) { None } else { limit };
3128
3129 anyhow::ensure!(
3130 bar_type.aggregation_source() == AggregationSource::External,
3131 "Only EXTERNAL aggregation is supported"
3132 );
3133
3134 if let (Some(s), Some(e)) = (start, end) {
3135 anyhow::ensure!(s < e, "Invalid time range: start={s:?} end={e:?}");
3136 }
3137
3138 let now = Utc::now();
3139
3140 if let Some(s) = start
3141 && s > now
3142 {
3143 return Ok(Vec::new());
3144 }
3145
3146 if let Some(e) = end
3147 && e > now
3148 {
3149 end = Some(now);
3150 }
3151
3152 let spec = bar_type.spec();
3153 let step = spec.step.get();
3154 let bar_param = match spec.aggregation {
3155 BarAggregation::Second => format!("{step}s"),
3156 BarAggregation::Minute => format!("{step}m"),
3157 BarAggregation::Hour => format!("{step}H"),
3158 BarAggregation::Day => format!("{step}D"),
3159 BarAggregation::Week => format!("{step}W"),
3160 BarAggregation::Month => format!("{step}M"),
3161 a => anyhow::bail!("OKX does not support {a:?} aggregation"),
3162 };
3163
3164 let slot_ms: i64 = match spec.aggregation {
3165 BarAggregation::Second => (step as i64) * 1_000,
3166 BarAggregation::Minute => (step as i64) * 60_000,
3167 BarAggregation::Hour => (step as i64) * 3_600_000,
3168 BarAggregation::Day => (step as i64) * 86_400_000,
3169 BarAggregation::Week => (step as i64) * 7 * 86_400_000,
3170 BarAggregation::Month => (step as i64) * 30 * 86_400_000,
3171 _ => unreachable!("Unsupported aggregation should have been caught above"),
3172 };
3173 let slot_ns: i64 = slot_ms * 1_000_000;
3174
3175 let mode = match (start, end) {
3176 (None, None) => Mode::Latest,
3177 (Some(_), None) => Mode::Backward, (None, Some(_)) => Mode::Backward,
3179 (Some(_), Some(_)) => Mode::Range,
3180 };
3181
3182 let start_ns = start.and_then(|s| s.timestamp_nanos_opt());
3183 let end_ns = end.and_then(|e| e.timestamp_nanos_opt());
3184
3185 let start_ms = start.map(|s| {
3187 let ms = s.timestamp_millis();
3188
3189 if slot_ms > 0 {
3190 (ms / slot_ms) * slot_ms } else {
3192 ms
3193 }
3194 });
3195 let end_ms = end.map(|e| {
3196 let ms = e.timestamp_millis();
3197
3198 if slot_ms > 0 {
3199 ((ms + slot_ms - 1) / slot_ms) * slot_ms } else {
3201 ms
3202 }
3203 });
3204 let now_ms = now.timestamp_millis();
3205
3206 let instrument_id = bar_type.instrument_id();
3207 let symbol = instrument_id.symbol;
3208 let inst = self.instrument_from_cache_by_id(instrument_id)?;
3209
3210 let mut out: Vec<Bar> = Vec::new();
3211 let mut pages = 0usize;
3212
3213 let mut after_ms: Option<i64> = match mode {
3218 Mode::Range => end_ms.or(Some(now_ms)), _ => None,
3220 };
3221 let mut before_ms: Option<i64> = match mode {
3222 Mode::Backward => end_ms.map(|v| v.saturating_sub(1)),
3223 Mode::Range => start_ms, Mode::Latest => None,
3225 };
3226
3227 let mut forward_prepend_mode = matches!(mode, Mode::Range);
3229
3230 if matches!(mode, Mode::Backward | Mode::Range)
3234 && let Some(b) = before_ms
3235 {
3236 let buffer_ms = slot_ms.max(60_000); if b >= now_ms.saturating_sub(buffer_ms) {
3242 before_ms = Some(now_ms.saturating_sub(buffer_ms));
3243 }
3244 }
3245
3246 let mut have_latest_first_page = false;
3247 let mut progressless_loops = 0u8;
3248
3249 loop {
3250 if let Some(lim) = limit
3251 && lim > 0
3252 && out.len() >= lim as usize
3253 {
3254 break;
3255 }
3256
3257 if pages >= MAX_PAGES_SOFT {
3258 break;
3259 }
3260
3261 let pivot_ms = if let Some(a) = after_ms {
3262 a
3263 } else if let Some(b) = before_ms {
3264 b
3265 } else {
3266 now_ms
3267 };
3268 let age_ms = now_ms.saturating_sub(pivot_ms);
3274 let age_hours = age_ms / (60 * 60 * 1000);
3275 let using_history = age_hours > 1; let page_ceiling = if using_history { 100 } else { 300 };
3278 let remaining = limit
3279 .filter(|&l| l > 0) .map_or(page_ceiling, |l| (l as usize).saturating_sub(out.len()));
3281 let page_cap = remaining.min(page_ceiling);
3282
3283 let mut p = GetCandlesticksParamsBuilder::default();
3284 p.inst_id(symbol.as_str())
3285 .bar(&bar_param)
3286 .limit(page_cap as u32);
3287
3288 let mut req_used_before = false;
3290
3291 match mode {
3292 Mode::Latest => {
3293 if have_latest_first_page && let Some(b) = before_ms {
3294 p.before_ms(b);
3295 req_used_before = true;
3296 }
3297 }
3298 Mode::Backward => {
3299 if let Some(b) = before_ms {
3301 p.after_ms(b);
3302 }
3303 }
3304 Mode::Range => {
3305 if let Some(a) = after_ms {
3308 p.after_ms(a);
3309 }
3310
3311 if let Some(b) = before_ms {
3312 p.before_ms(b);
3313 req_used_before = true;
3314 }
3315 }
3316 }
3317
3318 let params = p.build().map_err(anyhow::Error::new)?;
3319
3320 let mut raw = if using_history {
3321 self.inner
3322 .get_history_candles(params.clone())
3323 .await
3324 .map_err(anyhow::Error::new)?
3325 } else {
3326 self.inner
3327 .get_candles(params.clone())
3328 .await
3329 .map_err(anyhow::Error::new)?
3330 };
3331
3332 if raw.is_empty() {
3334 if matches!(mode, Mode::Latest)
3336 && have_latest_first_page
3337 && !using_history
3338 && let Some(b) = before_ms
3339 {
3340 let mut p2 = GetCandlesticksParamsBuilder::default();
3341 p2.inst_id(symbol.as_str())
3342 .bar(&bar_param)
3343 .limit(page_cap as u32);
3344 p2.before_ms(b);
3345 let params2 = p2.build().map_err(anyhow::Error::new)?;
3346 let raw2 = self
3347 .inner
3348 .get_history_candles(params2)
3349 .await
3350 .map_err(anyhow::Error::new)?;
3351
3352 if raw2.is_empty() {
3353 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
3355 before_ms = Some(b.saturating_sub(jump));
3356 progressless_loops = progressless_loops.saturating_add(1);
3357 if progressless_loops >= 3 {
3358 break;
3359 }
3360 continue;
3361 } else {
3362 raw = raw2;
3363 }
3364 }
3365
3366 if raw.is_empty() && matches!(mode, Mode::Range) && pages > 0 {
3370 let backstep_ms = (page_cap as i64).saturating_mul(slot_ms.max(1));
3371 let pivot_back = after_ms.unwrap_or(now_ms).saturating_sub(backstep_ms);
3372
3373 let mut p2 = GetCandlesticksParamsBuilder::default();
3374 p2.inst_id(symbol.as_str())
3375 .bar(&bar_param)
3376 .limit(page_cap as u32)
3377 .before_ms(pivot_back);
3378 let params2 = p2.build().map_err(anyhow::Error::new)?;
3379 let raw2 = if (now_ms.saturating_sub(pivot_back)) / (24 * 60 * 60 * 1000)
3380 > HISTORY_SPLIT_DAYS
3381 {
3382 self.inner.get_history_candles(params2).await
3383 } else {
3384 self.inner.get_candles(params2).await
3385 }
3386 .map_err(anyhow::Error::new)?;
3387
3388 if raw2.is_empty() {
3389 break;
3390 } else {
3391 raw = raw2;
3392 forward_prepend_mode = true;
3393 req_used_before = true;
3394 }
3395 }
3396
3397 if raw.is_empty()
3399 && matches!(mode, Mode::Latest)
3400 && !have_latest_first_page
3401 && !using_history
3402 {
3403 let jump_days_ms = (HISTORY_SPLIT_DAYS + 1) * 86_400_000;
3404 before_ms = Some(now_ms.saturating_sub(jump_days_ms));
3405 have_latest_first_page = true;
3406 continue;
3407 }
3408
3409 if raw.is_empty() {
3411 break;
3412 }
3413 }
3414 pages += 1;
3417
3418 let ts_init = self.generate_ts_init();
3420 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
3421
3422 for r in &raw {
3423 page.push(parse_candlestick(
3424 r,
3425 bar_type,
3426 inst.price_precision(),
3427 inst.size_precision(),
3428 ts_init,
3429 )?);
3430 }
3431 page.reverse();
3432
3433 let page_oldest_ms = page.first().map(|b| b.ts_event.as_i64() / 1_000_000);
3434 let page_newest_ms = page.last().map(|b| b.ts_event.as_i64() / 1_000_000);
3435
3436 let mut filtered: Vec<Bar> = if matches!(mode, Mode::Range)
3440 && out.is_empty()
3441 && pages < 2
3442 {
3443 let tolerance_ns = slot_ns * 2; if let (Some(first), Some(last)) = (page.first(), page.last()) {
3450 log::debug!(
3451 "Range mode bootstrap page: {} bars from {} to {}, filtering with start={:?} end={:?}",
3452 page.len(),
3453 first.ts_event.as_i64() / 1_000_000,
3454 last.ts_event.as_i64() / 1_000_000,
3455 start_ms,
3456 end_ms,
3457 );
3458 }
3459
3460 let result: Vec<Bar> = page
3461 .clone()
3462 .into_iter()
3463 .filter(|b| {
3464 let ts = b.ts_event.as_i64();
3465 let ok_after =
3467 start_ns.is_none_or(|sns| ts >= sns.saturating_sub(tolerance_ns));
3468 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
3469 ok_after && ok_before
3470 })
3471 .collect();
3472
3473 result
3474 } else {
3475 page.clone()
3477 .into_iter()
3478 .filter(|b| {
3479 let ts = b.ts_event.as_i64();
3480 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
3481 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
3482 ok_after && ok_before
3483 })
3484 .collect()
3485 };
3486
3487 if !page.is_empty() && filtered.is_empty() {
3488 if matches!(mode, Mode::Range)
3490 && !forward_prepend_mode
3491 && let (Some(newest_ms), Some(start_ms)) = (page_newest_ms, start_ms)
3492 && newest_ms < start_ms.saturating_sub(slot_ms * 2)
3493 {
3494 break;
3496 }
3497 }
3498
3499 let contribution;
3501
3502 if out.is_empty() {
3503 contribution = filtered.len();
3504 out = filtered;
3505 } else {
3506 match mode {
3507 Mode::Backward | Mode::Latest => {
3508 if let Some(first) = out.first() {
3509 filtered.retain(|b| b.ts_event < first.ts_event);
3510 }
3511 contribution = filtered.len();
3512 if contribution != 0 {
3513 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
3514 new_out.extend_from_slice(&filtered);
3515 new_out.extend_from_slice(&out);
3516 out = new_out;
3517 }
3518 }
3519 Mode::Range => {
3520 if forward_prepend_mode || req_used_before {
3521 if let Some(first) = out.first() {
3523 filtered.retain(|b| b.ts_event < first.ts_event);
3524 }
3525 contribution = filtered.len();
3526 if contribution != 0 {
3527 let mut new_out = Vec::with_capacity(out.len() + filtered.len());
3528 new_out.extend_from_slice(&filtered);
3529 new_out.extend_from_slice(&out);
3530 out = new_out;
3531 }
3532 } else {
3533 if let Some(last) = out.last() {
3535 filtered.retain(|b| b.ts_event > last.ts_event);
3536 }
3537 contribution = filtered.len();
3538 out.extend(filtered);
3539 }
3540 }
3541 }
3542 }
3543
3544 if contribution == 0
3546 && matches!(mode, Mode::Latest | Mode::Backward | Mode::Range)
3547 && let Some(b) = before_ms
3548 {
3549 let jump = (page_cap as i64).saturating_mul(slot_ms.max(1));
3550 let new_b = b.saturating_sub(jump);
3551 if new_b != b {
3552 before_ms = Some(new_b);
3553 }
3554 }
3555
3556 if contribution == 0 {
3557 progressless_loops = progressless_loops.saturating_add(1);
3558 if progressless_loops >= 3 {
3559 break;
3560 }
3561 } else {
3562 progressless_loops = 0;
3563
3564 match mode {
3566 Mode::Latest | Mode::Backward => {
3567 if let Some(oldest) = page_oldest_ms {
3568 before_ms = Some(oldest.saturating_sub(1));
3569 have_latest_first_page = true;
3570 } else {
3571 break;
3572 }
3573 }
3574 Mode::Range => {
3575 if forward_prepend_mode || req_used_before {
3576 if let Some(oldest) = page_oldest_ms {
3577 let jump_back = slot_ms.max(60_000); before_ms = Some(oldest.saturating_sub(jump_back));
3580 after_ms = None;
3581 } else {
3582 break;
3583 }
3584 } else if let Some(newest) = page_newest_ms {
3585 after_ms = Some(newest.saturating_add(1));
3586 before_ms = None;
3587 } else {
3588 break;
3589 }
3590 }
3591 }
3592 }
3593
3594 if let Some(lim) = limit
3596 && lim > 0
3597 && out.len() >= lim as usize
3598 {
3599 break;
3600 }
3601
3602 if let Some(ens) = end_ns
3603 && let Some(last) = out.last()
3604 && last.ts_event.as_i64() >= ens
3605 {
3606 break;
3607 }
3608
3609 if let Some(sns) = start_ns
3610 && let Some(first) = out.first()
3611 && (matches!(mode, Mode::Backward) || forward_prepend_mode)
3612 && first.ts_event.as_i64() <= sns
3613 {
3614 if matches!(mode, Mode::Range) {
3616 if let Some(ens) = end_ns
3618 && let Some(last) = out.last()
3619 {
3620 let last_ts = last.ts_event.as_i64();
3621 if last_ts < ens {
3622 forward_prepend_mode = false;
3625 after_ms = Some((last_ts / 1_000_000).saturating_add(1));
3626 before_ms = None;
3627 continue;
3628 }
3629 }
3630 }
3631 break;
3632 }
3633
3634 tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
3635 }
3636
3637 if out.is_empty() && matches!(mode, Mode::Range) {
3639 let pivot = end_ms.unwrap_or(now_ms.saturating_sub(1));
3640 let hist = (now_ms.saturating_sub(pivot)) / (24 * 60 * 60 * 1000) > HISTORY_SPLIT_DAYS;
3641 let mut p = GetCandlesticksParamsBuilder::default();
3642 p.inst_id(symbol.as_str())
3643 .bar(&bar_param)
3644 .limit(300)
3645 .before_ms(pivot);
3646 let params = p.build().map_err(anyhow::Error::new)?;
3647 let raw = if hist {
3648 self.inner.get_history_candles(params).await
3649 } else {
3650 self.inner.get_candles(params).await
3651 }
3652 .map_err(anyhow::Error::new)?;
3653
3654 if !raw.is_empty() {
3655 let ts_init = self.generate_ts_init();
3656 let mut page: Vec<Bar> = Vec::with_capacity(raw.len());
3657
3658 for r in &raw {
3659 page.push(parse_candlestick(
3660 r,
3661 bar_type,
3662 inst.price_precision(),
3663 inst.size_precision(),
3664 ts_init,
3665 )?);
3666 }
3667 page.reverse();
3668 out = page
3669 .into_iter()
3670 .filter(|b| {
3671 let ts = b.ts_event.as_i64();
3672 let ok_after = start_ns.is_none_or(|sns| ts >= sns);
3673 let ok_before = end_ns.is_none_or(|ens| ts <= ens);
3674 ok_after && ok_before
3675 })
3676 .collect();
3677 }
3678 }
3679
3680 if let Some(ens) = end_ns {
3682 while out.last().is_some_and(|b| b.ts_event.as_i64() > ens) {
3683 out.pop();
3684 }
3685 }
3686
3687 if matches!(mode, Mode::Range)
3689 && !forward_prepend_mode
3690 && let Some(sns) = start_ns
3691 {
3692 let lower = sns.saturating_sub(slot_ns);
3693 while out.first().is_some_and(|b| b.ts_event.as_i64() < lower) {
3694 out.remove(0);
3695 }
3696 }
3697
3698 if let Some(lim) = limit
3700 && lim > 0
3701 && out.len() > lim as usize
3702 {
3703 let start = out.len() - lim as usize;
3704 out.drain(..start);
3705 }
3706
3707 Ok(out)
3708 }
3709
3710 #[expect(clippy::too_many_arguments)]
3721 pub async fn request_order_status_reports(
3722 &self,
3723 account_id: AccountId,
3724 instrument_type: Option<OKXInstrumentType>,
3725 instrument_id: Option<InstrumentId>,
3726 start: Option<DateTime<Utc>>,
3727 end: Option<DateTime<Utc>>,
3728 open_only: bool,
3729 limit: Option<u32>,
3730 ) -> anyhow::Result<Vec<OrderStatusReport>> {
3731 if instrument_id
3732 .as_ref()
3733 .is_some_and(|id| is_okx_spread_symbol(id.symbol.as_str()))
3734 || (instrument_id.is_none() && instrument_type.is_none())
3735 {
3736 return self
3737 .request_spread_order_status_reports(
3738 account_id,
3739 instrument_id,
3740 start,
3741 end,
3742 open_only,
3743 limit,
3744 )
3745 .await;
3746 }
3747
3748 let instrument_type = if let Some(instrument_type) = instrument_type {
3749 instrument_type
3750 } else {
3751 let instrument_id = instrument_id.ok_or_else(|| {
3752 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
3753 })?;
3754 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
3755 okx_instrument_type(&instrument)?
3756 };
3757
3758 let mut history_base = GetOrderHistoryParamsBuilder::default();
3759 history_base.inst_type(instrument_type);
3760
3761 if let Some(instrument_id) = instrument_id.as_ref() {
3762 history_base.inst_id(instrument_id.symbol.inner().to_string());
3763 }
3764 let history_base = history_base.build().map_err(|e| anyhow::anyhow!(e))?;
3765
3766 let mut pending_base = GetOrderListParamsBuilder::default();
3767 pending_base.inst_type(instrument_type);
3768
3769 if let Some(instrument_id) = instrument_id.as_ref() {
3770 pending_base.inst_id(instrument_id.symbol.inner().to_string());
3771 }
3772 let pending_base = pending_base.build().map_err(|e| anyhow::anyhow!(e))?;
3773
3774 let combined_resp = if open_only {
3775 self.paginate_orders_pending(&pending_base, limit).await?
3776 } else {
3777 let (history, pending) = tokio::try_join!(
3778 self.paginate_orders_history(&history_base, limit),
3779 self.paginate_orders_pending(&pending_base, limit),
3780 )?;
3781 let mut combined_resp = history;
3782 combined_resp.extend(pending);
3783 combined_resp
3784 };
3785
3786 let start_ns = start.map(UnixNanos::from);
3788 let end_ns = end.map(UnixNanos::from);
3789
3790 let ts_init = self.generate_ts_init();
3791 let mut reports = Vec::with_capacity(combined_resp.len());
3792
3793 let mut seen: AHashSet<String> = AHashSet::new();
3795
3796 for order in combined_resp {
3797 let seen_key = if !order.cl_ord_id.is_empty() {
3798 order.cl_ord_id.as_str().to_string()
3799 } else if let Some(algo_cl_ord_id) = order
3800 .algo_cl_ord_id
3801 .as_ref()
3802 .filter(|value| !value.as_str().is_empty())
3803 {
3804 algo_cl_ord_id.as_str().to_string()
3805 } else if let Some(algo_id) = order
3806 .algo_id
3807 .as_ref()
3808 .filter(|value| !value.as_str().is_empty())
3809 {
3810 algo_id.as_str().to_string()
3811 } else {
3812 order.ord_id.as_str().to_string()
3813 };
3814
3815 if !seen.insert(seen_key) {
3816 continue; }
3818
3819 let Ok(inst) = self.instrument_from_cache(order.inst_id) else {
3820 log::debug!(
3821 "Skipping order report for instrument not in cache: symbol={}",
3822 order.inst_id,
3823 );
3824 continue;
3825 };
3826
3827 let report = match parse_order_status_report(
3828 &order,
3829 account_id,
3830 inst.id(),
3831 inst.price_precision(),
3832 inst.size_precision(),
3833 ts_init,
3834 ) {
3835 Ok(report) => report,
3836 Err(e) => {
3837 log::error!("Failed to parse order status report: {e}");
3838 continue;
3839 }
3840 };
3841
3842 if let Some(start_ns) = start_ns
3843 && report.ts_last < start_ns
3844 {
3845 continue;
3846 }
3847
3848 if let Some(end_ns) = end_ns
3849 && report.ts_last > end_ns
3850 {
3851 continue;
3852 }
3853
3854 reports.push(report);
3855 }
3856
3857 Ok(reports)
3858 }
3859
3860 pub async fn request_spread_order_status_reports(
3866 &self,
3867 account_id: AccountId,
3868 instrument_id: Option<InstrumentId>,
3869 start: Option<DateTime<Utc>>,
3870 end: Option<DateTime<Utc>>,
3871 open_only: bool,
3872 limit: Option<u32>,
3873 ) -> anyhow::Result<Vec<OrderStatusReport>> {
3874 let mut pending_builder = GetSpreadOrdersParamsBuilder::default();
3875 let mut history_builder = GetSpreadOrdersParamsBuilder::default();
3876
3877 if let Some(instrument_id) = instrument_id.as_ref() {
3878 let sprd_id = instrument_id.symbol.inner().to_string();
3879 pending_builder.sprd_id(sprd_id.clone());
3880 history_builder.sprd_id(sprd_id);
3881 }
3882
3883 if let Some(start) = start {
3884 history_builder.begin(start.timestamp_millis().to_string());
3885 }
3886
3887 if let Some(end) = end {
3888 history_builder.end(end.timestamp_millis().to_string());
3889 }
3890
3891 if let Some(limit) = spread_page_limit(limit) {
3892 pending_builder.limit(limit);
3893 history_builder.limit(limit);
3894 }
3895
3896 let pending_base = pending_builder.build().map_err(|e| anyhow::anyhow!(e))?;
3897 let history_base = history_builder.build().map_err(|e| anyhow::anyhow!(e))?;
3898
3899 let combined_resp = if open_only {
3900 self.paginate_spread_orders_pending(&pending_base, limit)
3901 .await?
3902 } else {
3903 let (history, pending) = tokio::try_join!(
3904 self.paginate_spread_orders_history(&history_base, limit),
3905 self.paginate_spread_orders_pending(&pending_base, limit),
3906 )?;
3907 let mut combined_resp = history;
3908 combined_resp.extend(pending);
3909 combined_resp
3910 };
3911
3912 let start_ns = start.map(UnixNanos::from);
3913 let end_ns = end.map(UnixNanos::from);
3914 let ts_init = self.generate_ts_init();
3915 let mut reports = Vec::with_capacity(combined_resp.len());
3916 let mut seen: AHashSet<String> = AHashSet::new();
3917
3918 for order in combined_resp {
3919 let seen_key = if order.cl_ord_id.is_empty() {
3920 order.ord_id.as_str().to_string()
3921 } else {
3922 order.cl_ord_id.as_str().to_string()
3923 };
3924
3925 if !seen.insert(seen_key) {
3926 continue;
3927 }
3928
3929 let Ok(inst) = self.instrument_from_cache(order.sprd_id) else {
3930 log::debug!(
3931 "Skipping spread order report for instrument not in cache: symbol={}",
3932 order.sprd_id,
3933 );
3934 continue;
3935 };
3936
3937 let report = match parse_spread_order_status_report(
3938 &order,
3939 account_id,
3940 inst.id(),
3941 inst.price_precision(),
3942 inst.size_precision(),
3943 ts_init,
3944 ) {
3945 Ok(report) => report,
3946 Err(e) => {
3947 log::error!("Failed to parse spread order status report: {e}");
3948 continue;
3949 }
3950 };
3951
3952 if let Some(start_ns) = start_ns
3953 && report.ts_last < start_ns
3954 {
3955 continue;
3956 }
3957
3958 if let Some(end_ns) = end_ns
3959 && report.ts_last > end_ns
3960 {
3961 continue;
3962 }
3963
3964 reports.push(report);
3965 }
3966
3967 Ok(reports)
3968 }
3969
3970 async fn paginate_spread_orders_history(
3971 &self,
3972 base: &GetSpreadOrdersParams,
3973 limit: Option<u32>,
3974 ) -> anyhow::Result<Vec<OKXSpreadOrder>> {
3975 let mut all = Vec::new();
3976 let mut cursor: Option<String> = None;
3977 let mut exhausted = true;
3978
3979 for _ in 0..MAX_RECONCILIATION_PAGES {
3980 let mut params = base.clone();
3981 params.end_id = cursor.take();
3982
3983 let page = self
3984 .inner
3985 .get_spread_orders_history(params)
3986 .await
3987 .map_err(|e| anyhow::anyhow!(e))?;
3988
3989 let page_len = page.len();
3990 cursor = page.last().map(|o| o.ord_id.to_string());
3991 all.extend(page);
3992
3993 if page_len < OKX_PAGE_SIZE {
3994 exhausted = false;
3995 break;
3996 }
3997
3998 if let Some(lim) = limit
3999 && all.len() >= lim as usize
4000 {
4001 exhausted = false;
4002 break;
4003 }
4004 }
4005
4006 if exhausted && !all.is_empty() {
4007 log::warn!(
4008 "Spread order history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4009 results may be truncated ({} records)",
4010 all.len()
4011 );
4012 }
4013
4014 if let Some(lim) = limit {
4015 all.truncate(lim as usize);
4016 }
4017
4018 Ok(all)
4019 }
4020
4021 async fn paginate_spread_orders_pending(
4022 &self,
4023 base: &GetSpreadOrdersParams,
4024 limit: Option<u32>,
4025 ) -> anyhow::Result<Vec<OKXSpreadOrder>> {
4026 let mut all = Vec::new();
4027 let mut cursor: Option<String> = None;
4028 let mut exhausted = true;
4029
4030 for _ in 0..MAX_RECONCILIATION_PAGES {
4031 let mut params = base.clone();
4032 params.end_id = cursor.take();
4033
4034 let page = self
4035 .inner
4036 .get_spread_orders_pending(params)
4037 .await
4038 .map_err(|e| anyhow::anyhow!(e))?;
4039
4040 let page_len = page.len();
4041 cursor = page.last().map(|o| o.ord_id.to_string());
4042 all.extend(page);
4043
4044 if page_len < OKX_PAGE_SIZE {
4045 exhausted = false;
4046 break;
4047 }
4048
4049 if let Some(lim) = limit
4050 && all.len() >= lim as usize
4051 {
4052 exhausted = false;
4053 break;
4054 }
4055 }
4056
4057 if exhausted && !all.is_empty() {
4058 log::warn!(
4059 "Pending spread orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4060 results may be truncated ({} records)",
4061 all.len()
4062 );
4063 }
4064
4065 if let Some(lim) = limit {
4066 all.truncate(lim as usize);
4067 }
4068
4069 Ok(all)
4070 }
4071
4072 async fn paginate_orders_history(
4074 &self,
4075 base: &GetOrderHistoryParams,
4076 limit: Option<u32>,
4077 ) -> anyhow::Result<Vec<OKXOrderHistory>> {
4078 let mut all = Vec::new();
4079 let mut cursor: Option<String> = None;
4080 let mut exhausted = true;
4081
4082 for _ in 0..MAX_RECONCILIATION_PAGES {
4083 let mut params = base.clone();
4084 params.after = cursor.take();
4085
4086 let page = self
4087 .inner
4088 .get_orders_history(params)
4089 .await
4090 .map_err(|e| anyhow::anyhow!(e))?;
4091
4092 let page_len = page.len();
4093 cursor = page.last().map(|o| o.ord_id.to_string());
4094 all.extend(page);
4095
4096 if page_len < OKX_PAGE_SIZE {
4097 exhausted = false;
4098 break;
4099 }
4100
4101 if let Some(lim) = limit
4102 && all.len() >= lim as usize
4103 {
4104 exhausted = false;
4105 break;
4106 }
4107 }
4108
4109 if exhausted && !all.is_empty() {
4110 log::warn!(
4111 "Order history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4112 results may be truncated ({} records)",
4113 all.len()
4114 );
4115 }
4116
4117 if let Some(lim) = limit {
4118 all.truncate(lim as usize);
4119 }
4120
4121 Ok(all)
4122 }
4123
4124 async fn paginate_orders_pending(
4126 &self,
4127 base: &GetOrderListParams,
4128 limit: Option<u32>,
4129 ) -> anyhow::Result<Vec<OKXOrderHistory>> {
4130 let mut all = Vec::new();
4131 let mut cursor: Option<String> = None;
4132 let mut exhausted = true;
4133
4134 for _ in 0..MAX_RECONCILIATION_PAGES {
4135 let mut params = base.clone();
4136 params.after = cursor.take();
4137
4138 let page = self
4139 .inner
4140 .get_orders_pending(params)
4141 .await
4142 .map_err(|e| anyhow::anyhow!(e))?;
4143
4144 let page_len = page.len();
4145 cursor = page.last().map(|o| o.ord_id.to_string());
4146 all.extend(page);
4147
4148 if page_len < OKX_PAGE_SIZE {
4149 exhausted = false;
4150 break;
4151 }
4152
4153 if let Some(lim) = limit
4154 && all.len() >= lim as usize
4155 {
4156 exhausted = false;
4157 break;
4158 }
4159 }
4160
4161 if exhausted && !all.is_empty() {
4162 log::warn!(
4163 "Pending orders pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4164 results may be truncated ({} records)",
4165 all.len()
4166 );
4167 }
4168
4169 if let Some(lim) = limit {
4170 all.truncate(lim as usize);
4171 }
4172
4173 Ok(all)
4174 }
4175
4176 async fn paginate_fills(
4178 &self,
4179 base: &GetTransactionDetailsParams,
4180 limit: Option<u32>,
4181 ) -> anyhow::Result<Vec<OKXTransactionDetail>> {
4182 let mut all = Vec::new();
4183 let mut cursor: Option<String> = None;
4184 let mut exhausted = true;
4185
4186 for _ in 0..MAX_RECONCILIATION_PAGES {
4187 let mut params = base.clone();
4188 params.after = cursor.take();
4189
4190 let page = self
4191 .inner
4192 .get_fills(params)
4193 .await
4194 .map_err(|e| anyhow::anyhow!(e))?;
4195
4196 let page_len = page.len();
4197 cursor = page.last().map(|o| o.bill_id.to_string());
4198 all.extend(page);
4199
4200 if page_len < OKX_PAGE_SIZE {
4201 exhausted = false;
4202 break;
4203 }
4204
4205 if let Some(lim) = limit
4206 && all.len() >= lim as usize
4207 {
4208 exhausted = false;
4209 break;
4210 }
4211 }
4212
4213 if exhausted && !all.is_empty() {
4214 log::warn!(
4215 "Fill pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4216 results may be truncated ({} records)",
4217 all.len()
4218 );
4219 }
4220
4221 if let Some(lim) = limit {
4222 all.truncate(lim as usize);
4223 }
4224
4225 Ok(all)
4226 }
4227
4228 async fn paginate_algo_pending(
4230 &self,
4231 base: &GetAlgoOrdersParams,
4232 limit: Option<usize>,
4233 ) -> anyhow::Result<Vec<OKXOrderAlgo>> {
4234 let mut all = Vec::new();
4235 let mut cursor: Option<String> = None;
4236 let mut exhausted = true;
4237
4238 for _ in 0..MAX_RECONCILIATION_PAGES {
4239 let mut params = base.clone();
4240 params.after = cursor.take();
4241
4242 let page = match self.inner.get_order_algo_pending(params).await {
4243 Ok(result) => result,
4244 Err(OKXHttpError::UnexpectedStatus { status, .. })
4245 if status == StatusCode::NOT_FOUND =>
4246 {
4247 exhausted = false;
4248 break;
4249 }
4250 Err(e) => return Err(e.into()),
4251 };
4252
4253 let page_len = page.len();
4254 cursor = page.last().map(|o| o.algo_id.clone());
4255 all.extend(page);
4256
4257 if page_len < OKX_PAGE_SIZE {
4258 exhausted = false;
4259 break;
4260 }
4261
4262 if let Some(lim) = limit
4263 && all.len() >= lim
4264 {
4265 exhausted = false;
4266 break;
4267 }
4268 }
4269
4270 if exhausted && !all.is_empty() {
4271 log::warn!(
4272 "Algo pending pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4273 results may be truncated ({} records)",
4274 all.len()
4275 );
4276 }
4277
4278 Ok(all)
4279 }
4280
4281 async fn paginate_algo_history(
4283 &self,
4284 base: &GetAlgoOrdersParams,
4285 limit: Option<usize>,
4286 ) -> anyhow::Result<Vec<OKXOrderAlgo>> {
4287 let mut all = Vec::new();
4288 let mut cursor: Option<String> = None;
4289 let mut exhausted = true;
4290
4291 for _ in 0..MAX_RECONCILIATION_PAGES {
4292 let mut params = base.clone();
4293 params.after = cursor.take();
4294
4295 let page = match self.inner.get_order_algo_history(params).await {
4296 Ok(result) => result,
4297 Err(OKXHttpError::UnexpectedStatus { status, .. })
4298 if status == StatusCode::NOT_FOUND =>
4299 {
4300 exhausted = false;
4301 break;
4302 }
4303 Err(e) => return Err(e.into()),
4304 };
4305
4306 let page_len = page.len();
4307 cursor = page.last().map(|o| o.algo_id.clone());
4308 all.extend(page);
4309
4310 if page_len < OKX_PAGE_SIZE {
4311 exhausted = false;
4312 break;
4313 }
4314
4315 if let Some(lim) = limit
4316 && all.len() >= lim
4317 {
4318 exhausted = false;
4319 break;
4320 }
4321 }
4322
4323 if exhausted && !all.is_empty() {
4324 log::warn!(
4325 "Algo history pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4326 results may be truncated ({} records)",
4327 all.len()
4328 );
4329 }
4330
4331 Ok(all)
4332 }
4333
4334 pub async fn request_fill_reports(
4344 &self,
4345 account_id: AccountId,
4346 instrument_type: Option<OKXInstrumentType>,
4347 instrument_id: Option<InstrumentId>,
4348 start: Option<DateTime<Utc>>,
4349 end: Option<DateTime<Utc>>,
4350 limit: Option<u32>,
4351 ) -> anyhow::Result<Vec<FillReport>> {
4352 if instrument_id
4353 .as_ref()
4354 .is_some_and(|id| is_okx_spread_symbol(id.symbol.as_str()))
4355 || (instrument_id.is_none() && instrument_type.is_none())
4356 {
4357 return self
4358 .request_spread_fill_reports(account_id, instrument_id, start, end, limit)
4359 .await;
4360 }
4361
4362 let mut params = GetTransactionDetailsParamsBuilder::default();
4363
4364 let instrument_type = if let Some(instrument_type) = instrument_type {
4365 instrument_type
4366 } else {
4367 let instrument_id = instrument_id.ok_or_else(|| {
4368 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
4369 })?;
4370 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4371 okx_instrument_type(&instrument)?
4372 };
4373
4374 params.inst_type(instrument_type);
4375
4376 if let Some(instrument_id) = instrument_id {
4377 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4378 let instrument_type = okx_instrument_type(&instrument)?;
4379 params.inst_type(instrument_type);
4380 params.inst_id(instrument_id.symbol.inner().to_string());
4381 }
4382
4383 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
4384
4385 let resp = self.paginate_fills(¶ms, limit).await?;
4386
4387 let start_ns = start.map(UnixNanos::from);
4389 let end_ns = end.map(UnixNanos::from);
4390
4391 let ts_init = self.generate_ts_init();
4392 let mut reports = Vec::with_capacity(resp.len());
4393
4394 for detail in resp {
4395 if detail.fill_sz.is_empty() {
4397 continue;
4398 }
4399
4400 if let Ok(qty) = detail.fill_sz.parse::<f64>() {
4401 if qty <= 0.0 {
4402 continue;
4403 }
4404 } else {
4405 continue;
4407 }
4408
4409 let Ok(inst) = self.instrument_from_cache(detail.inst_id) else {
4410 log::debug!(
4411 "Skipping fill report for instrument not in cache: symbol={}",
4412 detail.inst_id,
4413 );
4414 continue;
4415 };
4416
4417 let report = match parse_fill_report(
4418 &detail,
4419 account_id,
4420 inst.id(),
4421 inst.price_precision(),
4422 inst.size_precision(),
4423 ts_init,
4424 ) {
4425 Ok(report) => report,
4426 Err(e) => {
4427 log::error!("Failed to parse fill report: {e}");
4428 continue;
4429 }
4430 };
4431
4432 if let Some(start_ns) = start_ns
4433 && report.ts_event < start_ns
4434 {
4435 continue;
4436 }
4437
4438 if let Some(end_ns) = end_ns
4439 && report.ts_event > end_ns
4440 {
4441 continue;
4442 }
4443
4444 reports.push(report);
4445 }
4446
4447 Ok(reports)
4448 }
4449
4450 pub async fn request_spread_fill_reports(
4456 &self,
4457 account_id: AccountId,
4458 instrument_id: Option<InstrumentId>,
4459 start: Option<DateTime<Utc>>,
4460 end: Option<DateTime<Utc>>,
4461 limit: Option<u32>,
4462 ) -> anyhow::Result<Vec<FillReport>> {
4463 let mut builder = GetSpreadTradesParamsBuilder::default();
4464
4465 if let Some(instrument_id) = instrument_id.as_ref() {
4466 builder.sprd_id(instrument_id.symbol.inner().to_string());
4467 }
4468
4469 if let Some(start) = start {
4470 builder.begin(start.timestamp_millis().to_string());
4471 }
4472
4473 if let Some(end) = end {
4474 builder.end(end.timestamp_millis().to_string());
4475 }
4476
4477 if let Some(limit) = spread_page_limit(limit) {
4478 builder.limit(limit);
4479 }
4480
4481 let params = builder.build().map_err(|e| anyhow::anyhow!(e))?;
4482 let resp = self.paginate_spread_fills(¶ms, limit).await?;
4483
4484 let start_ns = start.map(UnixNanos::from);
4485 let end_ns = end.map(UnixNanos::from);
4486 let ts_init = self.generate_ts_init();
4487 let mut reports = Vec::with_capacity(resp.len());
4488
4489 for detail in resp {
4490 if detail.fill_sz.is_empty() {
4491 continue;
4492 }
4493
4494 let Ok(qty) = detail.fill_sz.parse::<f64>() else {
4495 continue;
4496 };
4497
4498 if qty <= 0.0 {
4499 continue;
4500 }
4501
4502 let Ok(inst) = self.instrument_from_cache(detail.sprd_id) else {
4503 log::debug!(
4504 "Skipping spread fill report for instrument not in cache: symbol={}",
4505 detail.sprd_id,
4506 );
4507 continue;
4508 };
4509
4510 let report = match parse_spread_fill_report(
4511 &detail,
4512 account_id,
4513 inst.id(),
4514 inst.price_precision(),
4515 inst.size_precision(),
4516 ts_init,
4517 ) {
4518 Ok(report) => report,
4519 Err(e) => {
4520 log::error!("Failed to parse spread fill report: {e}");
4521 continue;
4522 }
4523 };
4524
4525 if let Some(start_ns) = start_ns
4526 && report.ts_event < start_ns
4527 {
4528 continue;
4529 }
4530
4531 if let Some(end_ns) = end_ns
4532 && report.ts_event > end_ns
4533 {
4534 continue;
4535 }
4536
4537 reports.push(report);
4538 }
4539
4540 Ok(reports)
4541 }
4542
4543 async fn paginate_spread_fills(
4544 &self,
4545 base: &GetSpreadTradesParams,
4546 limit: Option<u32>,
4547 ) -> anyhow::Result<Vec<OKXSpreadTrade>> {
4548 let mut all = Vec::new();
4549 let mut cursor: Option<String> = None;
4550 let mut exhausted = true;
4551
4552 for _ in 0..MAX_RECONCILIATION_PAGES {
4553 let mut params = base.clone();
4554 params.end_id = cursor.take();
4555
4556 let page = self
4557 .inner
4558 .get_spread_trades(params)
4559 .await
4560 .map_err(|e| anyhow::anyhow!(e))?;
4561
4562 let page_len = page.len();
4563 cursor = page.last().map(|o| o.trade_id.to_string());
4564 all.extend(page);
4565
4566 if page_len < OKX_PAGE_SIZE {
4567 exhausted = false;
4568 break;
4569 }
4570
4571 if let Some(lim) = limit
4572 && all.len() >= lim as usize
4573 {
4574 exhausted = false;
4575 break;
4576 }
4577 }
4578
4579 if exhausted && !all.is_empty() {
4580 log::warn!(
4581 "Spread fill pagination hit {MAX_RECONCILIATION_PAGES} page cap, \
4582 results may be truncated ({} records)",
4583 all.len()
4584 );
4585 }
4586
4587 if let Some(lim) = limit {
4588 all.truncate(lim as usize);
4589 }
4590
4591 Ok(all)
4592 }
4593
4594 pub async fn request_position_status_reports(
4621 &self,
4622 account_id: AccountId,
4623 instrument_type: Option<OKXInstrumentType>,
4624 instrument_id: Option<InstrumentId>,
4625 ) -> anyhow::Result<Vec<PositionStatusReport>> {
4626 let mut params = GetPositionsParamsBuilder::default();
4627
4628 let instrument_type = if let Some(instrument_type) = instrument_type {
4629 instrument_type
4630 } else {
4631 let instrument_id = instrument_id.ok_or_else(|| {
4632 anyhow::anyhow!("Instrument ID required if `instrument_type` not provided")
4633 })?;
4634 let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
4635 okx_instrument_type(&instrument)?
4636 };
4637
4638 params.inst_type(instrument_type);
4639
4640 instrument_id
4641 .as_ref()
4642 .map(|i| params.inst_id(i.symbol.inner()));
4643
4644 let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
4645
4646 let resp = self
4647 .inner
4648 .get_positions(params)
4649 .await
4650 .map_err(|e| anyhow::anyhow!(e))?;
4651
4652 let ts_init = self.generate_ts_init();
4653 let mut reports = Vec::with_capacity(resp.len());
4654
4655 for position in resp {
4656 let Ok(inst) = self.instrument_from_cache(position.inst_id) else {
4657 log::debug!(
4658 "Skipping position report for instrument not in cache: symbol={}",
4659 position.inst_id,
4660 );
4661 continue;
4662 };
4663
4664 match parse_position_status_report(
4665 &position,
4666 account_id,
4667 inst.id(),
4668 inst.size_precision(),
4669 ts_init,
4670 ) {
4671 Ok(report) => reports.push(report),
4672 Err(e) => {
4673 log::error!("Failed to parse position status report: {e}");
4674 }
4675 }
4676 }
4677
4678 Ok(reports)
4679 }
4680
4681 pub async fn request_spot_margin_position_reports(
4696 &self,
4697 account_id: AccountId,
4698 ) -> anyhow::Result<Vec<PositionStatusReport>> {
4699 let accounts = self
4700 .inner
4701 .get_balance()
4702 .await
4703 .map_err(|e| anyhow::anyhow!(e))?;
4704
4705 let ts_init = self.generate_ts_init();
4706 let mut reports = Vec::new();
4707
4708 let cache_snapshot = self.instruments_cache.load();
4722 let mut candidates: Vec<&InstrumentAny> = cache_snapshot
4723 .values()
4724 .filter(|inst| matches!(inst, InstrumentAny::CurrencyPair(_)))
4725 .collect();
4726 candidates.sort_by(|a, b| {
4727 let a_sym = a.id().symbol.as_str().to_string();
4728 let b_sym = b.id().symbol.as_str().to_string();
4729 spot_quote_priority(&a_sym)
4730 .cmp(&spot_quote_priority(&b_sym))
4731 .then_with(|| a_sym.cmp(&b_sym))
4732 });
4733
4734 let mut by_base: AHashMap<Ustr, (InstrumentId, u8)> = AHashMap::new();
4735
4736 for inst in candidates {
4737 if let Some(base) = inst.base_currency() {
4738 let base_code = Ustr::from(base.code.as_str());
4739 by_base
4740 .entry(base_code)
4741 .or_insert_with(|| (inst.id(), inst.size_precision()));
4742 }
4743 }
4744
4745 for account in accounts {
4746 for balance in account.details {
4747 let ccy_str = balance.ccy.as_str();
4748
4749 let Some((instrument_id, size_precision)) =
4750 by_base.get(&Ustr::from(ccy_str)).copied()
4751 else {
4752 log::debug!("Skipping balance for {ccy_str} - no matching instrument in cache");
4753 continue;
4754 };
4755
4756 match parse_spot_margin_position_from_balance(
4757 &balance,
4758 account_id,
4759 instrument_id,
4760 size_precision,
4761 ts_init,
4762 ) {
4763 Ok(Some(report)) => reports.push(report),
4764 Ok(None) => {} Err(e) => {
4766 log::error!(
4767 "Failed to parse spot margin position from balance for {ccy_str}: {e}"
4768 );
4769 }
4770 }
4771 }
4772 }
4773
4774 Ok(reports)
4775 }
4776
4777 pub async fn place_order(
4787 &self,
4788 request: OKXPlaceOrderRequest,
4789 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
4790 let body =
4791 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
4792
4793 let resp: Vec<OKXPlaceOrderResponse> = self
4794 .inner
4795 .send_request::<_, ()>(Method::POST, "/api/v5/trade/order", None, Some(body), true)
4796 .await?;
4797
4798 resp.into_iter()
4799 .next()
4800 .ok_or_else(|| OKXHttpError::ValidationError("Empty response".to_string()))
4801 }
4802
4803 pub async fn place_spread_order(
4809 &self,
4810 request: OKXPlaceSpreadOrderRequest,
4811 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
4812 let resp = self.inner.place_spread_order(request).await?;
4813 let item = resp
4814 .into_iter()
4815 .next()
4816 .ok_or_else(|| OKXHttpError::ValidationError("Empty response".to_string()))?;
4817
4818 if let Some(ref code) = item.s_code
4819 && code != OKX_SUCCESS_CODE
4820 {
4821 let msg = item.s_msg.clone().unwrap_or_default();
4822 return Err(OKXHttpError::OkxError {
4823 error_code: code.clone(),
4824 message: msg,
4825 });
4826 }
4827
4828 Ok(item)
4829 }
4830
4831 pub async fn cancel_spread_order(
4837 &self,
4838 request: OKXCancelSpreadOrderRequest,
4839 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
4840 let resp = self.inner.cancel_spread_order(request).await?;
4841 let item = resp
4842 .into_iter()
4843 .next()
4844 .ok_or_else(|| OKXHttpError::ValidationError("Empty response".to_string()))?;
4845
4846 if let Some(ref code) = item.s_code
4847 && code != OKX_SUCCESS_CODE
4848 {
4849 let msg = item.s_msg.clone().unwrap_or_default();
4850 return Err(OKXHttpError::OkxError {
4851 error_code: code.clone(),
4852 message: msg,
4853 });
4854 }
4855
4856 Ok(item)
4857 }
4858
4859 pub async fn cancel_all_spread_orders(
4865 &self,
4866 spread_id: InstrumentId,
4867 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
4868 let request = OKXCancelAllSpreadOrdersRequest {
4869 sprd_id: spread_id.symbol.as_str().to_string(),
4870 };
4871
4872 self.inner.cancel_all_spread_orders(request).await
4873 }
4874
4875 pub async fn cancel_order(
4881 &self,
4882 instrument_id: InstrumentId,
4883 client_order_id: Option<ClientOrderId>,
4884 venue_order_id: Option<VenueOrderId>,
4885 ) -> Result<OKXCancelOrderResponse, OKXHttpError> {
4886 if client_order_id.is_none() && venue_order_id.is_none() {
4887 return Err(OKXHttpError::ValidationError(
4888 "Either `client_order_id` or `venue_order_id` is required".to_string(),
4889 ));
4890 }
4891
4892 let ord_id = venue_order_id.as_ref().map(ToString::to_string);
4893 let cl_ord_id = if venue_order_id.is_none() {
4894 client_order_id.as_ref().map(|id| id.as_str().to_string())
4895 } else {
4896 None
4897 };
4898
4899 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
4900 return self
4901 .cancel_spread_order(OKXCancelSpreadOrderRequest { ord_id, cl_ord_id })
4902 .await;
4903 }
4904
4905 let request = OKXCancelOrderRequest {
4906 inst_id: instrument_id.symbol.as_str().to_string(),
4907 inst_id_code: None,
4908 ord_id,
4909 cl_ord_id,
4910 };
4911 let mut resp = self.cancel_orders(vec![request]).await?;
4912 let item = resp
4913 .pop()
4914 .ok_or_else(|| OKXHttpError::ValidationError("Empty response".to_string()))?;
4915
4916 if let Some(ref code) = item.s_code
4917 && code != OKX_SUCCESS_CODE
4918 {
4919 let msg = item.s_msg.clone().unwrap_or_default();
4920 return Err(OKXHttpError::OkxError {
4921 error_code: code.clone(),
4922 message: msg,
4923 });
4924 }
4925
4926 Ok(item)
4927 }
4928
4929 pub async fn cancel_all_orders(
4935 &self,
4936 instrument_id: InstrumentId,
4937 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
4938 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
4939 return self.cancel_all_spread_orders(instrument_id).await;
4940 }
4941
4942 let instrument = self
4943 .instrument_from_cache(instrument_id.symbol.inner())
4944 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
4945 let instrument_type = okx_instrument_type(&instrument)
4946 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
4947
4948 let mut pending_base = GetOrderListParamsBuilder::default();
4949 pending_base.inst_type(instrument_type);
4950 pending_base.inst_id(instrument_id.symbol.inner().to_string());
4951 let pending_base = pending_base
4952 .build()
4953 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
4954
4955 let pending = self
4956 .paginate_orders_pending(&pending_base, None)
4957 .await
4958 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
4959 let requests = pending
4960 .into_iter()
4961 .map(|order| OKXCancelOrderRequest {
4962 inst_id: order.inst_id.to_string(),
4963 inst_id_code: None,
4964 ord_id: if order.ord_id.is_empty() {
4965 None
4966 } else {
4967 Some(order.ord_id.to_string())
4968 },
4969 cl_ord_id: if order.ord_id.is_empty() && !order.cl_ord_id.is_empty() {
4970 Some(order.cl_ord_id.to_string())
4971 } else {
4972 None
4973 },
4974 })
4975 .collect();
4976
4977 self.cancel_orders(requests).await
4978 }
4979
4980 pub async fn cancel_orders(
4993 &self,
4994 requests: Vec<OKXCancelOrderRequest>,
4995 ) -> Result<Vec<OKXCancelOrderResponse>, OKXHttpError> {
4996 if requests.is_empty() {
4997 return Ok(Vec::new());
4998 }
4999
5000 let body =
5001 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5002
5003 let resp: Vec<OKXCancelOrderResponse> = self
5004 .inner
5005 .send_request::<_, ()>(
5006 Method::POST,
5007 "/api/v5/trade/cancel-batch-orders",
5008 None,
5009 Some(body),
5010 true,
5011 )
5012 .await?;
5013
5014 for item in &resp {
5015 if let Some(ref code) = item.s_code
5016 && code != OKX_SUCCESS_CODE
5017 {
5018 let msg = item.s_msg.as_deref().unwrap_or("");
5019 log::warn!(
5020 "Order cancel rejected: ord_id={} sCode={code} sMsg={msg}",
5021 item.ord_id
5022 );
5023 }
5024 }
5025
5026 Ok(resp)
5027 }
5028
5029 pub async fn place_algo_order(
5039 &self,
5040 request: OKXPlaceAlgoOrderRequest,
5041 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
5042 let body =
5043 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5044
5045 let resp: Vec<OKXPlaceAlgoOrderResponse> = self
5046 .inner
5047 .send_request::<_, ()>(
5048 Method::POST,
5049 "/api/v5/trade/order-algo",
5050 None,
5051 Some(body),
5052 true,
5053 )
5054 .await?;
5055
5056 let item = resp
5057 .into_iter()
5058 .next()
5059 .ok_or_else(|| OKXHttpError::ValidationError("Empty response".to_string()))?;
5060
5061 if let Some(ref code) = item.s_code
5062 && code != "0"
5063 {
5064 let msg = item.s_msg.clone().unwrap_or_default();
5065 return Err(OKXHttpError::OkxError {
5066 error_code: code.clone(),
5067 message: msg,
5068 });
5069 }
5070
5071 Ok(item)
5072 }
5073
5074 pub async fn cancel_algo_order(
5084 &self,
5085 request: OKXCancelAlgoOrderRequest,
5086 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
5087 let body =
5090 serde_json::to_vec(&[request]).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5091
5092 let resp: Vec<OKXCancelAlgoOrderResponse> = self
5093 .inner
5094 .send_request::<_, ()>(
5095 Method::POST,
5096 "/api/v5/trade/cancel-algos",
5097 None,
5098 Some(body),
5099 true,
5100 )
5101 .await?;
5102
5103 let item = resp
5104 .into_iter()
5105 .next()
5106 .ok_or_else(|| OKXHttpError::ValidationError("Empty response".to_string()))?;
5107
5108 if let Some(ref code) = item.s_code
5109 && code != "0"
5110 {
5111 let msg = item.s_msg.clone().unwrap_or_default();
5112 return Err(OKXHttpError::OkxError {
5113 error_code: code.clone(),
5114 message: msg,
5115 });
5116 }
5117
5118 Ok(item)
5119 }
5120
5121 pub async fn cancel_algo_orders(
5134 &self,
5135 requests: Vec<OKXCancelAlgoOrderRequest>,
5136 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
5137 if requests.is_empty() {
5138 return Ok(Vec::new());
5139 }
5140
5141 let body =
5142 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5143
5144 let resp: Vec<OKXCancelAlgoOrderResponse> = self
5145 .inner
5146 .send_request::<_, ()>(
5147 Method::POST,
5148 "/api/v5/trade/cancel-algos",
5149 None,
5150 Some(body),
5151 true,
5152 )
5153 .await?;
5154
5155 for item in &resp {
5156 if let Some(ref code) = item.s_code
5157 && code != "0"
5158 {
5159 let msg = item.s_msg.as_deref().unwrap_or("");
5160 log::warn!(
5161 "Algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
5162 item.algo_id
5163 );
5164 }
5165 }
5166
5167 Ok(resp)
5168 }
5169
5170 pub async fn cancel_advance_algo_orders(
5183 &self,
5184 requests: Vec<OKXCancelAlgoOrderRequest>,
5185 ) -> Result<Vec<OKXCancelAlgoOrderResponse>, OKXHttpError> {
5186 if requests.is_empty() {
5187 return Ok(Vec::new());
5188 }
5189
5190 let body =
5191 serde_json::to_vec(&requests).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5192
5193 let resp: Vec<OKXCancelAlgoOrderResponse> = self
5194 .inner
5195 .send_request::<_, ()>(
5196 Method::POST,
5197 "/api/v5/trade/cancel-advance-algos",
5198 None,
5199 Some(body),
5200 true,
5201 )
5202 .await?;
5203
5204 for item in &resp {
5205 if let Some(ref code) = item.s_code
5206 && code != "0"
5207 {
5208 let msg = item.s_msg.as_deref().unwrap_or("");
5209 log::warn!(
5210 "Advance algo cancel rejected: algo_id={} sCode={code} sMsg={msg}",
5211 item.algo_id
5212 );
5213 }
5214 }
5215
5216 Ok(resp)
5217 }
5218
5219 pub async fn amend_algo_order(
5229 &self,
5230 request: OKXAmendAlgoOrderRequest,
5231 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
5232 let body =
5233 serde_json::to_vec(&request).map_err(|e| OKXHttpError::JsonError(e.to_string()))?;
5234
5235 let resp: Vec<OKXAmendAlgoOrderResponse> = self
5236 .inner
5237 .send_request::<_, ()>(
5238 Method::POST,
5239 "/api/v5/trade/amend-algos",
5240 None,
5241 Some(body),
5242 true,
5243 )
5244 .await?;
5245
5246 resp.into_iter()
5247 .next()
5248 .ok_or_else(|| OKXHttpError::ValidationError("Empty response".to_string()))
5249 }
5250
5251 #[expect(clippy::too_many_arguments)]
5260 pub async fn amend_algo_order_with_domain_types(
5261 &self,
5262 instrument_id: InstrumentId,
5263 algo_id: String,
5264 new_trigger_price: Option<Price>,
5265 new_sl_trigger_price: Option<Price>,
5266 new_limit_price: Option<Price>,
5267 new_quantity: Option<Quantity>,
5268 new_callback_ratio: Option<String>,
5269 new_callback_spread: Option<String>,
5270 new_activation_price: Option<Price>,
5271 new_tp_trigger_price: Option<Price>,
5272 new_tp_order_price: Option<String>,
5273 new_tp_trigger_px_type: Option<String>,
5274 new_sl_order_price: Option<String>,
5275 new_sl_trigger_px_type: Option<String>,
5276 ) -> Result<OKXAmendAlgoOrderResponse, OKXHttpError> {
5277 let request = OKXAmendAlgoOrderRequest {
5278 inst_id: instrument_id.symbol.as_str().to_string(),
5279 algo_id,
5280 algo_cl_ord_id: None,
5281 new_sz: new_quantity.map(|q| q.to_string()),
5282 new_trigger_px: new_trigger_price.map(|p| p.to_string()),
5283 new_tp_trigger_px: new_tp_trigger_price.map(|p| p.to_string()),
5284 new_tp_ord_px: new_tp_order_price,
5285 new_tp_trigger_px_type,
5286 new_sl_trigger_px: new_sl_trigger_price.map(|p| p.to_string()),
5287 new_sl_ord_px: new_sl_order_price,
5288 new_sl_trigger_px_type,
5289 new_order_px: new_limit_price.map(|p| p.to_string()),
5290 new_callback_ratio,
5291 new_callback_spread,
5292 new_active_px: new_activation_price.map(|p| p.to_string()),
5293 };
5294
5295 self.amend_algo_order(request).await
5296 }
5297
5298 #[expect(clippy::too_many_arguments)]
5307 pub async fn place_order_with_domain_types(
5308 &self,
5309 instrument_id: InstrumentId,
5310 td_mode: OKXTradeMode,
5311 client_order_id: ClientOrderId,
5312 order_side: OrderSide,
5313 order_type: OrderType,
5314 quantity: Quantity,
5315 time_in_force: Option<TimeInForce>,
5316 price: Option<Price>,
5317 post_only: Option<bool>,
5318 reduce_only: Option<bool>,
5319 quote_quantity: Option<bool>,
5320 position_side: Option<PositionSide>,
5321 attach_algo_ords: Option<Vec<OKXAttachAlgoOrdRequest>>,
5322 px_usd: Option<String>,
5323 px_vol: Option<String>,
5324 speed_bump: Option<String>,
5325 outcome: Option<String>,
5326 slippage_pct: Option<String>,
5327 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5328 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
5329 if reduce_only.unwrap_or(false)
5330 || quote_quantity.unwrap_or(false)
5331 || attach_algo_ords
5332 .as_ref()
5333 .is_some_and(|orders| !orders.is_empty())
5334 || px_usd.is_some()
5335 || px_vol.is_some()
5336 || speed_bump.is_some()
5337 || outcome.is_some()
5338 || slippage_pct.is_some()
5339 {
5340 return Err(OKXHttpError::ValidationError(
5341 "OKX spread orders do not support regular order extensions".to_string(),
5342 ));
5343 }
5344
5345 return self
5346 .place_spread_order_with_domain_types(
5347 instrument_id,
5348 client_order_id,
5349 order_side,
5350 order_type,
5351 quantity,
5352 time_in_force,
5353 price,
5354 post_only,
5355 )
5356 .await;
5357 }
5358
5359 if !OKX_SUPPORTED_ORDER_TYPES.contains(&order_type) {
5360 return Err(OKXHttpError::ValidationError(format!(
5361 "Unsupported order type: {order_type:?}",
5362 )));
5363 }
5364
5365 if matches!(
5366 order_type,
5367 OrderType::StopMarket
5368 | OrderType::StopLimit
5369 | OrderType::MarketIfTouched
5370 | OrderType::LimitIfTouched
5371 | OrderType::TrailingStopMarket
5372 ) {
5373 return Err(OKXHttpError::ValidationError(
5374 "Conditional order types must use OKX algo order placement".to_string(),
5375 ));
5376 }
5377
5378 if let Some(tif) = time_in_force
5379 && !OKX_SUPPORTED_TIME_IN_FORCE.contains(&tif)
5380 {
5381 return Err(OKXHttpError::ValidationError(format!(
5382 "Unsupported time in force: {tif:?}",
5383 )));
5384 }
5385
5386 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
5387 return Err(OKXHttpError::ValidationError(
5388 "Invalid order side".to_string(),
5389 ));
5390 }
5391
5392 let instrument = self
5393 .instrument_from_cache(instrument_id.symbol.inner())
5394 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5395 let instrument_type = okx_instrument_type(&instrument)
5396 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5397
5398 if instrument_type == OKXInstrumentType::Option
5400 && matches!(order_type, OrderType::Market | OrderType::MarketToLimit)
5401 {
5402 return Err(OKXHttpError::ValidationError(
5403 "Market orders are not supported for OKX options, use Limit orders instead"
5404 .to_string(),
5405 ));
5406 }
5407
5408 let side = OKXSide::from(order_side.as_specified());
5409 let pos_side = position_side.map(Into::into).or({
5410 if matches!(
5411 instrument_type,
5412 OKXInstrumentType::Swap | OKXInstrumentType::Futures | OKXInstrumentType::Option
5413 ) {
5414 Some(OKXPositionSide::Net)
5415 } else {
5416 None
5417 }
5418 });
5419
5420 let tgt_ccy = if instrument_type == OKXInstrumentType::Spot
5421 && order_type == OrderType::Market
5422 && td_mode == OKXTradeMode::Cash
5423 {
5424 match quote_quantity {
5425 Some(true) => Some(OKXTargetCurrency::QuoteCcy),
5426 Some(false) if order_side == OrderSide::Buy => Some(OKXTargetCurrency::BaseCcy),
5427 _ => None,
5428 }
5429 } else {
5430 None
5431 };
5432
5433 let (ord_type, px) = if post_only.unwrap_or(false) {
5434 (OKXOrderType::PostOnly, price)
5435 } else if let Some(tif) = time_in_force {
5436 match (order_type, tif) {
5437 (OrderType::Market, TimeInForce::Fok) => {
5438 return Err(OKXHttpError::ValidationError(
5439 "Market orders with FOK time-in-force are not supported by OKX. Use Limit order with FOK instead.".to_string(),
5440 ));
5441 }
5442 (OrderType::Market, TimeInForce::Ioc) => {
5443 if matches!(
5445 instrument_type,
5446 OKXInstrumentType::Spot | OKXInstrumentType::Option
5447 ) {
5448 (OKXOrderType::Market, price)
5449 } else {
5450 (OKXOrderType::OptimalLimitIoc, price)
5451 }
5452 }
5453 (OrderType::Limit, TimeInForce::Fok) => {
5454 if instrument_type == OKXInstrumentType::Option {
5456 (OKXOrderType::OpFok, price)
5457 } else {
5458 (OKXOrderType::Fok, price)
5459 }
5460 }
5461 (OrderType::Limit, TimeInForce::Ioc) => (OKXOrderType::Ioc, price),
5462 _ => (OKXOrderType::from(order_type), price),
5463 }
5464 } else {
5465 (OKXOrderType::from(order_type), price)
5466 };
5467
5468 let speed_bump = if instrument_type == OKXInstrumentType::Events {
5469 if outcome.is_none() {
5470 return Err(OKXHttpError::ValidationError(
5471 "OKX event contract orders require `outcome`".to_string(),
5472 ));
5473 }
5474
5475 if ord_type == OKXOrderType::PostOnly {
5476 speed_bump
5477 } else {
5478 Some(speed_bump.unwrap_or_else(|| "1".to_string()))
5479 }
5480 } else {
5481 speed_bump
5482 };
5483
5484 let reduce_only = if instrument_type == OKXInstrumentType::Option {
5486 None
5487 } else {
5488 reduce_only
5489 };
5490
5491 let (px, px_usd, px_vol) = if px_usd.is_some() {
5493 (None, px_usd, None)
5494 } else if px_vol.is_some() {
5495 (None, None, px_vol)
5496 } else {
5497 (px.map(|p| p.to_string()), None, None)
5498 };
5499
5500 let request = OKXPlaceOrderRequest {
5501 inst_id: instrument_id.symbol.as_str().to_string(),
5502 td_mode,
5503 ccy: None,
5504 cl_ord_id: Some(client_order_id.as_str().to_string()),
5505 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
5506 side,
5507 pos_side,
5508 ord_type,
5509 sz: quantity.to_string(),
5510 px,
5511 px_usd,
5512 px_vol,
5513 reduce_only,
5514 tgt_ccy,
5515 attach_algo_ords,
5516 speed_bump,
5517 outcome,
5518 slippage_pct,
5519 };
5520
5521 self.place_order(request).await
5522 }
5523
5524 #[expect(clippy::too_many_arguments)]
5531 pub async fn place_spread_order_with_domain_types(
5532 &self,
5533 instrument_id: InstrumentId,
5534 client_order_id: ClientOrderId,
5535 order_side: OrderSide,
5536 order_type: OrderType,
5537 quantity: Quantity,
5538 time_in_force: Option<TimeInForce>,
5539 price: Option<Price>,
5540 post_only: Option<bool>,
5541 ) -> Result<OKXPlaceOrderResponse, OKXHttpError> {
5542 if !is_okx_spread_symbol(instrument_id.symbol.as_str()) {
5543 return Err(OKXHttpError::ValidationError(format!(
5544 "Instrument is not an OKX spread: {instrument_id}",
5545 )));
5546 }
5547
5548 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
5549 return Err(OKXHttpError::ValidationError(
5550 "Invalid order side".to_string(),
5551 ));
5552 }
5553
5554 if !matches!(order_type, OrderType::Limit) {
5555 return Err(OKXHttpError::ValidationError(
5556 "OKX spread orders support Limit orders only".to_string(),
5557 ));
5558 }
5559
5560 let ord_type = if post_only.unwrap_or(false) {
5561 OKXOrderType::PostOnly
5562 } else if matches!(time_in_force, Some(TimeInForce::Ioc)) {
5563 OKXOrderType::Ioc
5564 } else if matches!(time_in_force, Some(TimeInForce::Fok)) {
5565 return Err(OKXHttpError::ValidationError(
5566 "OKX spread orders do not support FOK time-in-force".to_string(),
5567 ));
5568 } else {
5569 OKXOrderType::Limit
5570 };
5571
5572 let price = price.ok_or_else(|| {
5573 OKXHttpError::ValidationError("OKX spread orders require a limit price".to_string())
5574 })?;
5575
5576 let request = OKXPlaceSpreadOrderRequest {
5577 sprd_id: instrument_id.symbol.as_str().to_string(),
5578 cl_ord_id: Some(client_order_id.as_str().to_string()),
5579 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
5580 side: OKXSide::from(order_side.as_specified()),
5581 ord_type,
5582 sz: quantity.to_string(),
5583 px: Some(price.to_string()),
5584 };
5585
5586 self.place_spread_order(request).await
5587 }
5588
5589 #[expect(clippy::too_many_arguments)]
5598 pub async fn place_algo_order_with_domain_types(
5599 &self,
5600 instrument_id: InstrumentId,
5601 td_mode: OKXTradeMode,
5602 client_order_id: ClientOrderId,
5603 order_side: OrderSide,
5604 order_type: OrderType,
5605 quantity: Quantity,
5606 trigger_price: Option<Price>,
5607 trigger_type: Option<TriggerType>,
5608 limit_price: Option<Price>,
5609 reduce_only: Option<bool>,
5610 close_fraction: Option<String>,
5611 callback_ratio: Option<String>,
5612 callback_spread: Option<String>,
5613 activation_price: Option<Price>,
5614 ) -> Result<OKXPlaceAlgoOrderResponse, OKXHttpError> {
5615 if !matches!(order_side, OrderSide::Buy | OrderSide::Sell) {
5616 return Err(OKXHttpError::ValidationError(
5617 "Invalid order side".to_string(),
5618 ));
5619 }
5620
5621 let okx_side = OKXSide::from(order_side.as_specified());
5622
5623 let trigger_px_type_enum = trigger_type.map_or(OKXTriggerType::Last, Into::into);
5625
5626 let uses_close_fraction = close_fraction.is_some();
5627 let (
5628 algo_type,
5629 sz,
5630 trigger_px,
5631 order_px,
5632 trigger_px_type,
5633 sl_trigger_px,
5634 sl_ord_px,
5635 sl_trigger_px_type,
5636 tp_trigger_px,
5637 tp_ord_px,
5638 tp_trigger_px_type,
5639 pos_side,
5640 reduce_only,
5641 ) = if uses_close_fraction {
5642 if order_type == OrderType::TrailingStopMarket {
5643 return Err(OKXHttpError::ValidationError(
5644 "OKX close_fraction does not support TrailingStopMarket".to_string(),
5645 ));
5646 }
5647
5648 let trigger_px = trigger_price.map(|p| p.to_string()).ok_or_else(|| {
5649 OKXHttpError::ValidationError(
5650 "OKX close_fraction orders require trigger_price".to_string(),
5651 )
5652 })?;
5653
5654 let close_order_px =
5655 if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched) {
5656 limit_price.map(|p| p.to_string()).ok_or_else(|| {
5657 OKXHttpError::ValidationError(format!(
5658 "OKX {order_type:?} close_fraction orders require limit_price"
5659 ))
5660 })?
5661 } else {
5662 "-1".to_string()
5663 };
5664
5665 let (
5666 sl_trigger_px,
5667 sl_ord_px,
5668 sl_trigger_px_type,
5669 tp_trigger_px,
5670 tp_ord_px,
5671 tp_trigger_px_type,
5672 ) = match order_type {
5673 OrderType::StopMarket | OrderType::StopLimit => (
5674 Some(trigger_px),
5675 Some(close_order_px),
5676 Some(trigger_px_type_enum),
5677 None,
5678 None,
5679 None,
5680 ),
5681 OrderType::MarketIfTouched | OrderType::LimitIfTouched => (
5682 None,
5683 None,
5684 None,
5685 Some(trigger_px),
5686 Some(close_order_px),
5687 Some(trigger_px_type_enum),
5688 ),
5689 _ => {
5690 return Err(OKXHttpError::ValidationError(format!(
5691 "OKX close_fraction is only supported for stop/touched conditional orders, received {order_type:?}"
5692 )));
5693 }
5694 };
5695
5696 (
5697 OKXAlgoOrderType::Conditional,
5698 None,
5699 None,
5700 None,
5701 None,
5702 sl_trigger_px,
5703 sl_ord_px,
5704 sl_trigger_px_type,
5705 tp_trigger_px,
5706 tp_ord_px,
5707 tp_trigger_px_type,
5708 Some(OKXPositionSide::Net),
5709 Some(true),
5710 )
5711 } else {
5712 let algo_type = conditional_order_to_algo_type(order_type)
5713 .map_err(|e| OKXHttpError::ValidationError(e.to_string()))?;
5714
5715 let order_px = if matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
5716 {
5717 limit_price.map(|p| p.to_string())
5718 } else if order_type == OrderType::TrailingStopMarket {
5719 None
5720 } else {
5721 Some("-1".to_string())
5722 };
5723
5724 (
5725 algo_type,
5726 Some(quantity.to_string()),
5727 trigger_price.map(|p| p.to_string()),
5728 order_px,
5729 Some(trigger_px_type_enum),
5730 None,
5731 None,
5732 None,
5733 None,
5734 None,
5735 None,
5736 None,
5737 reduce_only,
5738 )
5739 };
5740
5741 let request = OKXPlaceAlgoOrderRequest {
5742 inst_id: instrument_id.symbol.as_str().to_string(),
5743 inst_id_code: None,
5744 td_mode,
5745 side: okx_side,
5746 ord_type: algo_type,
5747 sz,
5748 algo_cl_ord_id: Some(client_order_id.as_str().to_string()),
5749 trigger_px,
5750 order_px,
5751 trigger_px_type,
5752 sl_trigger_px,
5753 sl_ord_px,
5754 sl_trigger_px_type,
5755 tp_trigger_px,
5756 tp_ord_px,
5757 tp_trigger_px_type,
5758 tgt_ccy: None,
5759 pos_side,
5760 close_position: None,
5761 tag: Some(OKX_NAUTILUS_BROKER_ID.to_string()),
5762 reduce_only,
5763 close_fraction,
5764 callback_ratio,
5765 callback_spread,
5766 active_px: activation_price.map(|p| p.to_string()),
5767 };
5768
5769 self.place_algo_order(request).await
5770 }
5771
5772 pub async fn cancel_algo_order_with_domain_types(
5781 &self,
5782 instrument_id: InstrumentId,
5783 algo_id: String,
5784 ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
5785 let request = OKXCancelAlgoOrderRequest {
5786 inst_id: instrument_id.symbol.to_string(),
5787 inst_id_code: None,
5788 algo_id: Some(algo_id),
5789 algo_cl_ord_id: None,
5790 };
5791
5792 self.cancel_algo_order(request).await
5793 }
5794
5795 #[expect(clippy::too_many_arguments)]
5801 pub async fn request_algo_order_status_reports(
5802 &self,
5803 account_id: AccountId,
5804 instrument_type: Option<OKXInstrumentType>,
5805 instrument_id: Option<InstrumentId>,
5806 algo_id: Option<String>,
5807 algo_client_order_id: Option<ClientOrderId>,
5808 state: Option<OKXOrderStatus>,
5809 limit: Option<u32>,
5810 ) -> anyhow::Result<Vec<OrderStatusReport>> {
5811 let mut instruments_cache: AHashMap<Ustr, InstrumentAny> = AHashMap::new();
5812 let has_specific_lookup = algo_id.is_some() || algo_client_order_id.is_some();
5813
5814 let inst_type = if let Some(inst_type) = instrument_type {
5815 inst_type
5816 } else if let Some(inst_id) = instrument_id {
5817 let instrument = self.instrument_from_cache(inst_id.symbol.inner())?;
5818 let inst_type = okx_instrument_type(&instrument)?;
5819 instruments_cache.insert(inst_id.symbol.inner(), instrument);
5820 inst_type
5821 } else {
5822 anyhow::bail!("instrument_type or instrument_id required for algo order query")
5823 };
5824
5825 let ts_init = self.generate_ts_init();
5826 let mut reports = Vec::new();
5827 let mut seen: AHashSet<(String, String)> = AHashSet::new();
5828
5829 for ord_type in [
5830 OKXAlgoOrderType::Oco,
5831 OKXAlgoOrderType::Conditional,
5832 OKXAlgoOrderType::Trigger,
5833 OKXAlgoOrderType::MoveOrderStop,
5834 ] {
5835 let mut params_builder = GetAlgoOrdersParamsBuilder::default();
5836 params_builder.inst_type(inst_type);
5837 params_builder.ord_type(ord_type);
5838
5839 if let Some(inst_id) = instrument_id {
5840 params_builder.inst_id(inst_id.symbol.inner().to_string());
5841 }
5842
5843 if let Some(algo_id) = algo_id.as_ref() {
5844 params_builder.algo_id(algo_id.clone());
5845 }
5846
5847 if let Some(client_order_id) = algo_client_order_id.as_ref() {
5848 params_builder.algo_cl_ord_id(client_order_id.as_str().to_string());
5849 }
5850
5851 if let Some(state) = state {
5852 params_builder.state(state);
5853 }
5854
5855 let params = params_builder
5856 .build()
5857 .map_err(|e| anyhow::anyhow!(format!("Failed to build algo order params: {e}")))?;
5858
5859 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
5860 let pending = self.paginate_algo_pending(¶ms, remaining).await?;
5861 self.collect_algo_reports(
5862 account_id,
5863 &pending,
5864 &mut instruments_cache,
5865 ts_init,
5866 &mut seen,
5867 &mut reports,
5868 )
5869 .await?;
5870
5871 if has_specific_lookup && !reports.is_empty() {
5872 return Ok(reports);
5873 }
5874
5875 if let Some(lim) = limit
5876 && reports.len() >= lim as usize
5877 {
5878 reports.truncate(lim as usize);
5879 return Ok(reports);
5880 }
5881
5882 if state.is_some() || algo_id.is_some() {
5885 let remaining = limit.map(|l| (l as usize).saturating_sub(reports.len()));
5886 let history = self.paginate_algo_history(¶ms, remaining).await?;
5887 self.collect_algo_reports(
5888 account_id,
5889 &history,
5890 &mut instruments_cache,
5891 ts_init,
5892 &mut seen,
5893 &mut reports,
5894 )
5895 .await?;
5896
5897 if has_specific_lookup && !reports.is_empty() {
5898 return Ok(reports);
5899 }
5900
5901 if let Some(lim) = limit
5902 && reports.len() >= lim as usize
5903 {
5904 reports.truncate(lim as usize);
5905 return Ok(reports);
5906 }
5907 }
5908 }
5909
5910 Ok(reports)
5911 }
5912
5913 pub async fn request_algo_order_status_report(
5919 &self,
5920 account_id: AccountId,
5921 instrument_id: InstrumentId,
5922 algo_client_order_id: ClientOrderId,
5923 ) -> anyhow::Result<Option<OrderStatusReport>> {
5924 let reports = self
5925 .request_algo_order_status_reports(
5926 account_id,
5927 None,
5928 Some(instrument_id),
5929 None,
5930 Some(algo_client_order_id),
5931 None,
5932 Some(50_u32),
5933 )
5934 .await?;
5935
5936 Ok(reports.into_iter().next())
5937 }
5938
5939 pub fn raw_client(&self) -> &Arc<OKXRawHttpClient> {
5941 &self.inner
5942 }
5943
5944 async fn collect_algo_reports(
5945 &self,
5946 account_id: AccountId,
5947 orders: &[OKXOrderAlgo],
5948 instruments_cache: &mut AHashMap<Ustr, InstrumentAny>,
5949 ts_init: UnixNanos,
5950 seen: &mut AHashSet<(String, String)>,
5951 reports: &mut Vec<OrderStatusReport>,
5952 ) -> anyhow::Result<()> {
5953 for order in orders {
5954 let key = (order.algo_id.clone(), order.algo_cl_ord_id.clone());
5955 if !seen.insert(key) {
5956 continue;
5957 }
5958
5959 let instrument = if let Some(instrument) = instruments_cache.get(&order.inst_id) {
5960 instrument.clone()
5961 } else {
5962 let Ok(instrument) = self.instrument_from_cache(order.inst_id) else {
5963 log::debug!(
5964 "Skipping algo order report for instrument not in cache: symbol={}",
5965 order.inst_id,
5966 );
5967 continue;
5968 };
5969 instruments_cache.insert(order.inst_id, instrument.clone());
5970 instrument
5971 };
5972
5973 match parse_http_algo_order(order, account_id, &instrument, ts_init) {
5974 Ok(report) => reports.push(report),
5975 Err(e) => {
5976 log::error!("Failed to parse algo order report: {e}");
5977 }
5978 }
5979 }
5980
5981 Ok(())
5982 }
5983}
5984
5985fn spread_page_limit(limit: Option<u32>) -> Option<u32> {
5986 limit.map(|limit| limit.min(OKX_PAGE_SIZE as u32))
5987}
5988
5989fn parse_http_algo_order(
5990 order: &OKXOrderAlgo,
5991 account_id: AccountId,
5992 instrument: &InstrumentAny,
5993 ts_init: UnixNanos,
5994) -> anyhow::Result<OrderStatusReport> {
5995 let ord_px = if order.ord_px.is_empty() {
5996 "-1".to_string()
5997 } else {
5998 order.ord_px.clone()
5999 };
6000
6001 let reduce_only = if order.reduce_only.is_empty() {
6002 "false".to_string()
6003 } else {
6004 order.reduce_only.clone()
6005 };
6006
6007 let msg = OKXAlgoOrderMsg {
6008 algo_id: order.algo_id.clone(),
6009 algo_cl_ord_id: order.algo_cl_ord_id.clone(),
6010 cl_ord_id: order.cl_ord_id.clone(),
6011 ord_id: order.ord_id.clone(),
6012 inst_id: order.inst_id,
6013 inst_type: order.inst_type,
6014 ord_type: order.ord_type,
6015 state: order.state,
6016 side: order.side,
6017 pos_side: order.pos_side,
6018 sz: order.sz.clone(),
6019 trigger_px: order.trigger_px.clone(),
6020 trigger_px_type: order.trigger_px_type.unwrap_or(OKXTriggerType::None),
6021 sl_trigger_px: order.sl_trigger_px.clone(),
6022 sl_ord_px: order.sl_ord_px.clone(),
6023 sl_trigger_px_type: order.sl_trigger_px_type.unwrap_or(OKXTriggerType::None),
6024 tp_trigger_px: order.tp_trigger_px.clone(),
6025 tp_ord_px: order.tp_ord_px.clone(),
6026 tp_trigger_px_type: order.tp_trigger_px_type.unwrap_or(OKXTriggerType::None),
6027 ord_px,
6028 td_mode: order.td_mode,
6029 lever: order.lever.clone(),
6030 reduce_only,
6031 close_fraction: order.close_fraction.clone(),
6032 actual_px: order.actual_px.clone(),
6033 actual_sz: order.actual_sz.clone(),
6034 notional_usd: order.notional_usd.clone(),
6035 c_time: order.c_time,
6036 u_time: order.u_time,
6037 trigger_time: order.trigger_time.clone(),
6038 tag: order.tag.clone(),
6039 callback_ratio: order.callback_ratio.clone(),
6040 callback_spread: order.callback_spread.clone(),
6041 active_px: order.active_px.clone(),
6042 ccy: None,
6043 tgt_ccy: None,
6044 fee: None,
6045 fee_ccy: None,
6046 advance_ord_type: None,
6047 };
6048
6049 parse_algo_order_status_report(&msg, instrument, account_id, ts_init)
6050}