Skip to main content

nautilus_okx/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides an ergonomic wrapper around the **OKX v5 REST API** –
17//! <https://www.okx.com/docs-v5/en/>.
18//!
19//! The core type exported by this module is [`OKXHttpClient`].  It offers an
20//! interface to all exchange endpoints currently required by NautilusTrader.
21//!
22//! Key responsibilities handled internally:
23//! • Request signing and header composition for private routes (HMAC-SHA256).
24//! • Rate-limiting based on the public OKX specification.
25//! • Deserialization of JSON payloads into domain models.
26//! • Conversion of raw exchange errors into the rich [`OKXHttpError`] enum.
27//!
28//! # Official Documentation
29//!
30//! | Endpoint                             | Reference                                              |
31//! |--------------------------------------|--------------------------------------------------------|
32//! | Market data                          | <https://www.okx.com/docs-v5/en/#rest-api-market-data> |
33//! | Account & positions                  | <https://www.okx.com/docs-v5/en/#rest-api-account>     |
34//! | Funding & asset balances             | <https://www.okx.com/docs-v5/en/#rest-api-funding>     |
35
36use 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
161/// Ranks a spot instrument's quote currency for deterministic tie-breaking
162/// when multiple pairs share the same base. Matches OKX's dominant-quote
163/// ordering so spot-margin position reports stay on a stable instrument id
164/// across restarts.
165fn 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
299/// Default OKX REST API rate limit: 500 requests per 2 seconds.
300///
301/// - Sub-account order limit: 1000 requests per 2 seconds.
302/// - Account balance: 10 requests per 2 seconds.
303/// - Account instruments: 20 requests per 2 seconds.
304///
305/// We use a conservative 250 requests per second (500 per 2 seconds) as a general limit
306/// that should accommodate most use cases while respecting OKX's documented limits.
307pub 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
313// OKX returns at most 100 records per page for order, fill, and algo endpoints
314const OKX_PAGE_SIZE: usize = 100;
315
316// Safety cap on paginated reconciliation fetches to avoid unbounded loops
317const MAX_RECONCILIATION_PAGES: usize = 50;
318
319/// Represents an OKX HTTP response.
320#[derive(Debug, Serialize, Deserialize)]
321pub struct OKXResponse<T> {
322    /// The OKX response code, which is `"0"` for success.
323    pub code: String,
324    /// A message string which can be informational or describe an error cause.
325    pub msg: String,
326    /// The typed data returned by the OKX endpoint.
327    pub data: Vec<T>,
328}
329
330/// Provides a raw HTTP client for interacting with the [OKX](https://okx.com) REST API.
331///
332/// This client wraps the underlying [`HttpClient`] to handle functionality
333/// specific to OKX, such as request signing (for authenticated endpoints),
334/// forming request URLs, and deserializing responses into OKX specific data models.
335pub 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    /// Cancel all pending HTTP requests.
524    pub fn cancel_all_requests(&self) {
525        self.cancellation_token.cancel();
526    }
527
528    /// Get the cancellation token for this client.
529    pub fn cancellation_token(&self) -> &CancellationToken {
530        &self.cancellation_token
531    }
532
533    /// Creates a new [`OKXHttpClient`] using the default OKX HTTP URL,
534    /// optionally overridden with a custom base URL.
535    ///
536    /// This version of the client has **no credentials**, so it can only
537    /// call publicly accessible endpoints.
538    ///
539    /// # Errors
540    ///
541    /// Returns an error if the retry manager cannot be created.
542    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    /// Creates a new [`OKXHttpClient`] configured with credentials
585    /// for authenticated requests, optionally using a custom base URL.
586    ///
587    /// # Errors
588    ///
589    /// Returns an error if the retry manager cannot be created.
590    #[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    /// Builds the default headers to include with each request (e.g., `User-Agent`).
637    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    /// Signs an OKX request with timestamp, API key, passphrase, and signature.
649    ///
650    /// # Errors
651    ///
652    /// Returns [`OKXHttpError::MissingCredentials`] if no credentials are set
653    /// but the request requires authentication.
654    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        // OKX requires milliseconds in the timestamp (ISO 8601 with milliseconds)
669        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(&timestamp, 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    /// Sends an HTTP request to OKX and parses the response into `Vec<T>`.
684    ///
685    /// Internally, this method handles:
686    /// - Building the URL from `base_url` + `path`.
687    /// - Optionally signing the request.
688    /// - Deserializing JSON responses into typed models, or returning a [`OKXHttpError`].
689    /// - Retrying with exponential backoff on transient errors.
690    ///
691    /// # Errors
692    ///
693    /// Returns an error if:
694    /// - The HTTP request fails.
695    /// - Authentication is required but credentials are missing.
696    /// - The response cannot be deserialized into the expected type.
697    /// - The OKX API returns an error response.
698    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        // Pre-compute rate limit keys once outside the retry closure
709        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                // Serialize params to query string for signing (if needed)
722                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                // Build full path with query string for signing
731                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                // Always set Content-Type header when body is present
744                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                        // Fall back to 500 if the venue returns a non-standard
798                        // code so we never panic in the error path.
799                        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        // Retry strategy based on OKX error responses and HTTP status codes:
808        //
809        // 1. Network errors: always retry (transient connection issues)
810        // 2. HTTP 5xx/429: server errors and rate limiting should be retried
811        // 3. OKX specific retryable error codes (defined in common::consts)
812        //
813        // Note: OKX returns many permanent errors which should NOT be retried
814        // (e.g., "Invalid instrument", "Insufficient balance", "Invalid API Key")
815        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    /// Sets the position mode for an account.
846    ///
847    /// # Errors
848    ///
849    /// Returns an error if JSON serialization of `params` fails, if the HTTP
850    /// request fails, or if the response body cannot be deserialized.
851    ///
852    /// # References
853    ///
854    /// <https://www.okx.com/docs-v5/en/#trading-account-rest-api-set-position-mode>
855    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(&params)?;
861        self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
862            .await
863    }
864
865    /// Requests position tiers information, maximum leverage depends on your borrowings and margin ratio.
866    ///
867    /// # Errors
868    ///
869    /// Returns an error if the HTTP request fails, authentication is rejected
870    /// or the response cannot be deserialized.
871    ///
872    /// # References
873    ///
874    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-position-tiers>
875    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(&params),
883            None,
884            false,
885        )
886        .await
887    }
888
889    /// Requests a list of instruments with open contracts.
890    ///
891    /// # Errors
892    ///
893    /// Returns an error if JSON serialization of `params` fails, if the HTTP
894    /// request fails, or if the response body cannot be deserialized.
895    ///
896    /// # References
897    ///
898    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-instruments>
899    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(&params),
907            None,
908            false,
909        )
910        .await
911    }
912
913    /// Requests a list of spread trading instruments.
914    ///
915    /// # Errors
916    ///
917    /// Returns an error if the HTTP request fails or if the response body cannot
918    /// be deserialized.
919    ///
920    /// # References
921    ///
922    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-get-spreads-public>
923    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(&params),
931            None,
932            false,
933        )
934        .await
935    }
936
937    /// Places a spread order.
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if the request fails or the response cannot be deserialized.
942    ///
943    /// # References
944    ///
945    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-post-place-order>
946    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    /// Cancels a spread order.
964    ///
965    /// # Errors
966    ///
967    /// Returns an error if the request fails or the response cannot be deserialized.
968    ///
969    /// # References
970    ///
971    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-post-cancel-order>
972    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    /// Cancels all orders for a spread.
990    ///
991    /// # Errors
992    ///
993    /// Returns an error if the request fails or the response cannot be deserialized.
994    ///
995    /// # References
996    ///
997    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-post-mass-cancel>
998    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    /// Requests spread order details.
1016    ///
1017    /// # Errors
1018    ///
1019    /// Returns an error if the request fails or the response cannot be deserialized.
1020    ///
1021    /// # References
1022    ///
1023    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-get-order-details>
1024    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(&params), None, true)
1029            .await
1030    }
1031
1032    /// Requests pending spread orders.
1033    ///
1034    /// # Errors
1035    ///
1036    /// Returns an error if the request fails or the response cannot be deserialized.
1037    ///
1038    /// # References
1039    ///
1040    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-get-active-orders>
1041    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(&params),
1049            None,
1050            true,
1051        )
1052        .await
1053    }
1054
1055    /// Requests historical spread orders.
1056    ///
1057    /// # Errors
1058    ///
1059    /// Returns an error if the request fails or the response cannot be deserialized.
1060    ///
1061    /// # References
1062    ///
1063    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-get-orders-history-last-3-months>
1064    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(&params),
1072            None,
1073            true,
1074        )
1075        .await
1076    }
1077
1078    /// Requests spread trades.
1079    ///
1080    /// # Errors
1081    ///
1082    /// Returns an error if the request fails or the response cannot be deserialized.
1083    ///
1084    /// # References
1085    ///
1086    /// <https://www.okx.com/docs-v5/en/#spread-trading-rest-api-get-trades-last-7-days>
1087    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(&params),
1095            None,
1096            true,
1097        )
1098        .await
1099    }
1100
1101    /// Requests OKX event contract series.
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
1106    ///
1107    /// # References
1108    ///
1109    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-series>.
1110    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(&params),
1118            None,
1119            false,
1120        )
1121        .await
1122    }
1123
1124    /// Requests OKX event contract events for a series.
1125    ///
1126    /// # Errors
1127    ///
1128    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
1129    ///
1130    /// # References
1131    ///
1132    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-events>.
1133    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(&params),
1141            None,
1142            false,
1143        )
1144        .await
1145    }
1146
1147    /// Requests OKX event contract markets for a series.
1148    ///
1149    /// # Errors
1150    ///
1151    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
1152    ///
1153    /// # References
1154    ///
1155    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-markets>.
1156    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(&params),
1164            None,
1165            false,
1166        )
1167        .await
1168    }
1169
1170    /// Requests option market data for an instrument family.
1171    ///
1172    /// # Errors
1173    ///
1174    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
1175    ///
1176    /// # References
1177    ///
1178    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-option-market-data>
1179    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(&params),
1187            None,
1188            false,
1189        )
1190        .await
1191    }
1192
1193    /// Requests the current server time from OKX.
1194    ///
1195    /// Retrieves the OKX system time in Unix timestamp (milliseconds). This is useful for
1196    /// synchronizing local clocks with the exchange server and logging time drift.
1197    ///
1198    /// # Errors
1199    ///
1200    /// Returns an error if the HTTP request fails or if the response body
1201    /// cannot be parsed into [`OKXServerTime`].
1202    ///
1203    /// # References
1204    ///
1205    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-system-time>
1206    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    /// Requests a mark price.
1217    ///
1218    /// We set the mark price based on the SPOT index and at a reasonable basis to prevent individual
1219    /// users from manipulating the market and causing the contract price to fluctuate.
1220    ///
1221    /// # Errors
1222    ///
1223    /// Returns an error if the HTTP request fails or if the response body
1224    /// cannot be parsed into [`OKXMarkPrice`].
1225    ///
1226    /// # References
1227    ///
1228    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-mark-price>
1229    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(&params),
1237            None,
1238            false,
1239        )
1240        .await
1241    }
1242
1243    /// Requests the latest index price.
1244    ///
1245    /// # Errors
1246    ///
1247    /// Returns an error if the operation fails.
1248    ///
1249    /// # References
1250    ///
1251    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-index-tickers>
1252    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(&params),
1260            None,
1261            false,
1262        )
1263        .await
1264    }
1265
1266    /// Requests trades history.
1267    ///
1268    /// # Errors
1269    ///
1270    /// Returns an error if the operation fails.
1271    ///
1272    /// # References
1273    ///
1274    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-get-trades-history>
1275    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(&params),
1283            None,
1284            false,
1285        )
1286        .await
1287    }
1288
1289    /// Requests order book snapshot.
1290    ///
1291    /// # Errors
1292    ///
1293    /// Returns an error if the operation fails.
1294    ///
1295    /// # References
1296    ///
1297    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-get-order-book>
1298    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(&params),
1306            None,
1307            false,
1308        )
1309        .await
1310    }
1311
1312    /// Requests funding rate history.
1313    ///
1314    /// # Errors
1315    ///
1316    /// Returns an error if the operation fails.
1317    ///
1318    /// # References
1319    ///
1320    /// <https://www.okx.com/docs-v5/en/#public-data-rest-api-get-funding-rate-history>
1321    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(&params),
1329            None,
1330            false,
1331        )
1332        .await
1333    }
1334
1335    /// Requests recent candlestick data.
1336    ///
1337    /// # Errors
1338    ///
1339    /// Returns an error if the operation fails.
1340    ///
1341    /// # References
1342    ///
1343    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-get-candlesticks>
1344    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(&params),
1352            None,
1353            false,
1354        )
1355        .await
1356    }
1357
1358    /// Requests historical candlestick data.
1359    ///
1360    /// # Errors
1361    ///
1362    /// Returns an error if the operation fails.
1363    ///
1364    /// # References
1365    ///
1366    /// <https://www.okx.com/docs-v5/en/#order-book-trading-market-data-get-candlesticks-history>
1367    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(&params),
1375            None,
1376            false,
1377        )
1378        .await
1379    }
1380
1381    /// Requests a list of assets (with non-zero balance), remaining balance, and available amount
1382    /// in the trading account.
1383    ///
1384    /// # Errors
1385    ///
1386    /// Returns an error if the operation fails.
1387    ///
1388    /// # References
1389    ///
1390    /// <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-balance>
1391    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    /// Requests fee rates for the account.
1398    ///
1399    /// Returns fee rates for the specified instrument type and the user's VIP level.
1400    ///
1401    /// # Errors
1402    ///
1403    /// Returns an error if the operation fails.
1404    ///
1405    /// # References
1406    ///
1407    /// <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-fee-rates>
1408    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(&params),
1416            None,
1417            true,
1418        )
1419        .await
1420    }
1421
1422    /// Retrieves a single order’s details.
1423    ///
1424    /// # Errors
1425    ///
1426    /// Returns an error if the operation fails.
1427    ///
1428    /// # References
1429    ///
1430    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-order>
1431    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(&params),
1439            None,
1440            true,
1441        )
1442        .await
1443    }
1444
1445    /// Requests order list (pending orders).
1446    ///
1447    /// # Errors
1448    ///
1449    /// Returns an error if the operation fails.
1450    ///
1451    /// # References
1452    ///
1453    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-order-list>
1454    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(&params),
1462            None,
1463            true,
1464        )
1465        .await
1466    }
1467
1468    /// Requests historical order records.
1469    ///
1470    /// # Errors
1471    ///
1472    /// Returns an error if the operation fails.
1473    ///
1474    /// # References
1475    ///
1476    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-orders-history>
1477    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(&params),
1485            None,
1486            true,
1487        )
1488        .await
1489    }
1490
1491    /// Requests pending algo orders.
1492    ///
1493    /// # Errors
1494    ///
1495    /// Returns an error if the operation fails.
1496    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(&params),
1504            None,
1505            true,
1506        )
1507        .await
1508    }
1509
1510    /// Requests historical algo orders.
1511    ///
1512    /// # Errors
1513    ///
1514    /// Returns an error if the operation fails.
1515    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(&params),
1523            None,
1524            true,
1525        )
1526        .await
1527    }
1528
1529    /// Requests transaction details (fills) for the given parameters.
1530    ///
1531    /// # Errors
1532    ///
1533    /// Returns an error if the operation fails.
1534    ///
1535    /// # References
1536    ///
1537    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-transaction-details-last-3-days>
1538    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(&params),
1546            None,
1547            true,
1548        )
1549        .await
1550    }
1551
1552    /// Requests information on your positions. When the account is in net mode, net positions will
1553    /// be displayed, and when the account is in long/short mode, long or short positions will be
1554    /// displayed. Returns in reverse chronological order using ctime.
1555    ///
1556    /// # Errors
1557    ///
1558    /// Returns an error if the operation fails.
1559    ///
1560    /// # References
1561    ///
1562    /// <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-positions>
1563    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(&params),
1571            None,
1572            true,
1573        )
1574        .await
1575    }
1576
1577    /// Requests closed or historical position data.
1578    ///
1579    /// # Errors
1580    ///
1581    /// Returns an error if the operation fails.
1582    ///
1583    /// # References
1584    ///
1585    /// <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-positions-history>
1586    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(&params),
1594            None,
1595            true,
1596        )
1597        .await
1598    }
1599}
1600
1601/// Provides a higher-level HTTP client for the [OKX](https://okx.com) REST API.
1602///
1603/// This client wraps the underlying `OKXHttpInnerClient` to handle conversions
1604/// into the Nautilus domain model.
1605#[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    /// Creates a new [`OKXHttpClient`] using the default OKX HTTP URL,
1648    /// optionally overridden with a custom base url.
1649    ///
1650    /// This version of the client has **no credentials**, so it can only
1651    /// call publicly accessible endpoints.
1652    ///
1653    /// # Errors
1654    ///
1655    /// Returns an error if the retry manager cannot be created.
1656    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    /// Generates a timestamp for initialization.
1682    fn generate_ts_init(&self) -> UnixNanos {
1683        self.clock.get_time_ns()
1684    }
1685
1686    /// Creates a new authenticated [`OKXHttpClient`] using environment variables and
1687    /// the default OKX HTTP base url.
1688    ///
1689    /// # Errors
1690    ///
1691    /// Returns an error if the operation fails.
1692    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    /// Creates a new [`OKXHttpClient`] configured with credentials
1708    /// for authenticated requests, optionally using a custom base url.
1709    ///
1710    /// # Errors
1711    ///
1712    /// Returns an error if the operation fails.
1713    #[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    /// Retrieves an instrument from the cache.
1751    ///
1752    /// # Errors
1753    ///
1754    /// Returns an error if the instrument is not found in the cache.
1755    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    /// Cancel all pending HTTP requests.
1771    pub fn cancel_all_requests(&self) {
1772        self.inner.cancel_all_requests();
1773    }
1774
1775    /// Get the cancellation token for this client.
1776    pub fn cancellation_token(&self) -> &CancellationToken {
1777        self.inner.cancellation_token()
1778    }
1779
1780    /// Requests order list (pending orders).
1781    ///
1782    /// # Errors
1783    ///
1784    /// Returns an error if the operation fails.
1785    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    /// Requests pending algo orders.
1793    ///
1794    /// # Errors
1795    ///
1796    /// Returns an error if the operation fails.
1797    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    /// Requests information on current account positions.
1805    ///
1806    /// # Errors
1807    ///
1808    /// Returns an error if the operation fails.
1809    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    /// Returns the base url being used by the client.
1817    pub fn base_url(&self) -> &str {
1818        self.inner.base_url.as_str()
1819    }
1820
1821    /// Returns the public API key being used by the client.
1822    pub fn api_key(&self) -> Option<&str> {
1823        self.inner.credential.as_ref().map(|c| c.api_key())
1824    }
1825
1826    /// Returns a masked version of the API key for logging purposes.
1827    #[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    /// Returns whether the client is configured for demo trading.
1833    #[must_use]
1834    pub fn is_demo(&self) -> bool {
1835        self.inner.environment == OKXEnvironment::Demo
1836    }
1837
1838    /// Requests the current server time from OKX.
1839    ///
1840    /// Returns the OKX system time as a Unix timestamp in milliseconds.
1841    ///
1842    /// # Errors
1843    ///
1844    /// Returns an error if the HTTP request fails or if the response cannot be parsed.
1845    pub async fn get_server_time(&self) -> Result<u64, OKXHttpError> {
1846        self.inner.get_server_time().await
1847    }
1848
1849    /// Checks if the client is initialized.
1850    ///
1851    /// The client is considered initialized if any instruments have been cached from the venue.
1852    #[must_use]
1853    pub fn is_initialized(&self) -> bool {
1854        self.cache_initialized.load(Ordering::Acquire)
1855    }
1856
1857    /// Returns a snapshot of all instrument symbols currently held in the
1858    /// internal cache.
1859    #[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    /// Caches multiple instruments.
1869    ///
1870    /// Any existing instruments with the same symbols will be replaced.
1871    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    /// Caches a single instrument.
1881    ///
1882    /// Any existing instrument with the same symbol will be replaced.
1883    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    /// Gets an instrument from the cache by symbol.
1890    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1891        self.instruments_cache.get_cloned(symbol)
1892    }
1893
1894    /// Requests the account state for the `account_id` from OKX.
1895    ///
1896    /// # Errors
1897    ///
1898    /// Returns an error if the HTTP request fails or no account state is returned.
1899    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    /// Sets the position mode for the account.
1919    ///
1920    /// Defaults to NetMode if no position mode is provided.
1921    ///
1922    /// # Errors
1923    ///
1924    /// Returns an error if the HTTP request fails or the position mode cannot be set.
1925    ///
1926    /// # Note
1927    ///
1928    /// This endpoint only works for accounts with derivatives trading enabled.
1929    /// If the account only has spot trading, this will return an error.
1930    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(()); // Gracefully handle this case
1948                }
1949                anyhow::bail!(e)
1950            }
1951        }
1952    }
1953
1954    /// Requests all instruments for the `instrument_type` from OKX.
1955    ///
1956    /// # Errors
1957    ///
1958    /// Returns an error if the HTTP request fails or instrument parsing fails.
1959    ///
1960    /// # Returns
1961    ///
1962    /// A tuple containing:
1963    /// - `Vec<InstrumentAny>`: The parsed instruments
1964    /// - `Vec<(Ustr, u64)>`: Mappings of inst_id to inst_id_code for WebSocket order operations
1965    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            // Collect inst_id_code mappings for WebSocket order operations
2045            if let Some(code) = inst.inst_id_code {
2046                inst_id_codes.push((inst.inst_id, code));
2047            }
2048            // Skip pre-open instruments which have incomplete/empty field values
2049            // Keep suspended instruments as they have valid metadata and may return to live
2050            if inst.state == OKXInstrumentStatus::Preopen {
2051                continue;
2052            }
2053
2054            // Determine which fee fields to use based on contract type
2055            // OKX fee rate convention: positive = rebate, negative = commission
2056            // Nautilus convention: negative = rebate, positive = commission
2057            // Negate to convert between conventions
2058            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                    // Unsupported instrument type, skip silently
2088                }
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    /// Requests spread instruments from OKX.
2099    ///
2100    /// # Errors
2101    ///
2102    /// Returns an error if the HTTP request fails or spread parsing fails.
2103    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    /// Requests a single instrument by `instrument_id` from OKX.
2127    ///
2128    /// Fetches the instrument from the API, caches it, and returns it.
2129    ///
2130    /// # Errors
2131    ///
2132    /// This function will return an error if:
2133    /// - The API request fails.
2134    /// - The instrument is not found.
2135    /// - Failed to parse instrument data.
2136    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        // Skip pre-open instruments which have incomplete/empty field values
2195        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        // OKX fee rate convention: positive = rebate, negative = commission
2224        // Nautilus convention: negative = rebate, positive = commission
2225        // Negate to convert between conventions
2226        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    /// Requests event contract series metadata from OKX.
2287    ///
2288    /// # Errors
2289    ///
2290    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
2291    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    /// Requests event metadata for an event contract series from OKX.
2299    ///
2300    /// # Errors
2301    ///
2302    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
2303    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    /// Requests event contract market metadata from OKX.
2311    ///
2312    /// # Errors
2313    ///
2314    /// Returns an error if the HTTP request fails or the response cannot be deserialized.
2315    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    /// Requests forward prices for OKX options using the option summary endpoint.
2323    ///
2324    /// # Errors
2325    ///
2326    /// Returns an error if the HTTP request fails or no usable instrument family can be resolved.
2327    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    /// Requests the latest mark price for the `instrument_type` from OKX.
2401    ///
2402    /// # Errors
2403    ///
2404    /// Returns an error if the HTTP request fails or no mark price is returned.
2405    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    /// Requests the latest index price for the `instrument_id` from OKX.
2489    ///
2490    /// # Errors
2491    ///
2492    /// Returns an error if the HTTP request fails or no index price is returned.
2493    pub async fn request_index_price(
2494        &self,
2495        instrument_id: InstrumentId,
2496    ) -> anyhow::Result<IndexPriceUpdate> {
2497        // Index tickers endpoint requires base pair format (e.g., BTC-USDT)
2498        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    /// Requests an order book snapshot for the `instrument_id`.
2525    ///
2526    /// # Errors
2527    ///
2528    /// Returns an error if the HTTP request fails or book parsing fails.
2529    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    /// Requests an order book snapshot as `OrderBookDeltas` for the `instrument_id`.
2583    ///
2584    /// # Errors
2585    ///
2586    /// Returns an error if the HTTP request fails or parsing fails.
2587    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    /// Requests historical funding rates for the `instrument_id`.
2680    ///
2681    /// # Errors
2682    ///
2683    /// Returns an error if the HTTP request fails or parsing fails.
2684    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        // OKX uses "before" for newer-than and "after" for older-than
2697        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            // oldest funding update has no previous one to compute interval
2727            let rate = parse_funding_rate(last_raw, instrument_id, None)?;
2728            rates.push(rate);
2729        }
2730
2731        // OKX returns newest-first; reverse to chronological order so that
2732        // cache.add_funding_rates (which push_fronts) leaves the newest at front
2733        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    /// Requests trades for the `instrument_id` and `start` -> `end` time range.
2745    ///
2746    /// # Errors
2747    ///
2748    /// Returns an error if the HTTP request fails or trade parsing fails.
2749    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        // Historical pagination walks backwards using trade IDs, OKX does not honour timestamps for
2803        // standalone `before` requests (type=2)
2804        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            // Only apply default limit when there's no start boundary
2813            // (start provides a natural stopping point, end alone allows infinite backward pagination)
2814            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                // Use 'after' to get older trades (OKX API: after=cursor means < cursor)
2853                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                // Extract oldest unique trade ID for next page cursor
2934                let oldest_trade_id = if page_trades.is_empty() {
2935                    // Only apply consecutive empty guard if we've already collected some trades
2936                    // This allows historical backfills to paginate through empty prelude
2937                    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                    // No unique trades on page, use raw response for cursor
2947                    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                    // Use oldest deduplicated trade ID before reversing
2956                    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            // Deduplicate by (trade_id, ts_event) composite key
3001            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        // OKX returns newest-first, reverse to oldest-first
3058        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    /// Requests historical bars for the given bar type and time range.
3071    ///
3072    /// The aggregation source must be `EXTERNAL`. Time range validation ensures start < end.
3073    /// Returns bars sorted oldest to newest.
3074    ///
3075    /// # Errors
3076    ///
3077    /// Returns an error if the request fails.
3078    ///
3079    /// # Endpoint Selection
3080    ///
3081    /// The OKX API has different endpoints with different limits:
3082    /// - Regular endpoint (`/api/v5/market/candles`): ≤ 300 rows/call, ≤ 40 req/2s
3083    ///   - Used when: start is None OR age ≤ 100 days
3084    /// - History endpoint (`/api/v5/market/history-candles`): ≤ 100 rows/call, ≤ 20 req/2s
3085    ///   - Used when: start is Some AND age > 100 days
3086    ///
3087    /// Age is calculated as `Utc::now() - start` at the time of the first request.
3088    ///
3089    /// # Supported Aggregations
3090    ///
3091    /// Maps to OKX bar query parameter:
3092    /// - `Second` → `{n}s`
3093    /// - `Minute` → `{n}m`
3094    /// - `Hour` → `{n}H`
3095    /// - `Day` → `{n}D`
3096    /// - `Week` → `{n}W`
3097    /// - `Month` → `{n}M`
3098    ///
3099    /// # Pagination
3100    ///
3101    /// - Uses `before` parameter for backwards pagination
3102    /// - Pages backwards from end time (or now) to start time
3103    /// - Stops when: limit reached, time window covered, or API returns empty
3104    /// - Rate limit safety: ≥ 50ms between requests
3105    ///
3106    /// # References
3107    ///
3108    /// - <https://tr.okx.com/docs-v5/en/#order-book-trading-market-data-get-candlesticks>
3109    /// - <https://tr.okx.com/docs-v5/en/#order-book-trading-market-data-get-candlesticks-history>
3110    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, // Changed: when only start is provided, work backward from now
3178            (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        // Floor start and ceiling end to bar boundaries for cleaner API requests
3186        let start_ms = start.map(|s| {
3187            let ms = s.timestamp_millis();
3188
3189            if slot_ms > 0 {
3190                (ms / slot_ms) * slot_ms // Floor to nearest bar boundary
3191            } 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 // Ceiling to nearest bar boundary
3200            } 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        // IMPORTANT: OKX API has COUNTER-INTUITIVE semantics (same for bars and trades):
3214        // - after=X returns records with timestamp < X (upper bound, despite the name!)
3215        // - before=X returns records with timestamp > X (lower bound, despite the name!)
3216        // For Range [start, end], use: before=start (lower bound), after=end (upper bound)
3217        let mut after_ms: Option<i64> = match mode {
3218            Mode::Range => end_ms.or(Some(now_ms)), // Upper bound: bars < end
3219            _ => 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, // Lower bound: bars > start
3224            Mode::Latest => None,
3225        };
3226
3227        // For Range mode, we'll paginate backwards like Backward mode
3228        let mut forward_prepend_mode = matches!(mode, Mode::Range);
3229
3230        // Adjust before_ms to ensure we get data from the API
3231        // OKX API might not have bars for the very recent past
3232        // This handles both explicit end=now and the actor layer setting end=now when it's None
3233        if matches!(mode, Mode::Backward | Mode::Range)
3234            && let Some(b) = before_ms
3235        {
3236            // OKX endpoints have different data availability windows:
3237            // - Regular endpoint: has most recent data but limited depth
3238            // - History endpoint: has deep history but lags behind current time
3239            // Use a small buffer to avoid the "dead zone"
3240            let buffer_ms = slot_ms.max(60_000); // At least 1 minute or 1 bar
3241            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            // Choose endpoint based on how old the data is:
3269            // - Use regular endpoint for recent data (< 1 hour old)
3270            // - Use history endpoint for older data (> 1 hour old)
3271            // This avoids the "gap" where history endpoint has no recent data
3272            // and regular endpoint has limited depth
3273            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; // Use history if data is > 1 hour old
3276
3277            let page_ceiling = if using_history { 100 } else { 300 };
3278            let remaining = limit
3279                .filter(|&l| l > 0) // Treat limit=0 as no limit
3280                .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            // Track whether this planned request uses BEFORE or AFTER.
3289            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                    // Use 'after' to get older bars (OKX API: after=cursor means < cursor)
3300                    if let Some(b) = before_ms {
3301                        p.after_ms(b);
3302                    }
3303                }
3304                Mode::Range => {
3305                    // For Range mode, use both after and before to specify the full range
3306                    // This is much more efficient than pagination
3307                    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            // --- Fallbacks on empty page ---
3333            if raw.is_empty() {
3334                // LATEST: retry same cursor via history, then step back a page-interval before giving up
3335                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                        // Step back one page interval and retry loop
3354                        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                // Range mode doesn't need special bootstrap - it uses the normal flow with before_ms set
3367
3368                // If still empty: for Range after first page, try a single backstep window using BEFORE
3369                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                // First LATEST page empty: jump back >100d to force history, then continue loop
3398                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                // Still empty for any other case? Just break.
3410                if raw.is_empty() {
3411                    break;
3412                }
3413            }
3414            // --- end fallbacks ---
3415
3416            pages += 1;
3417
3418            // Parse, oldest → newest
3419            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            // Range filter (inclusive)
3437            // For Range mode, if we have no bars yet and this is an early page,
3438            // be more tolerant with the start boundary to handle gaps in data
3439            let mut filtered: Vec<Bar> = if matches!(mode, Mode::Range)
3440                && out.is_empty()
3441                && pages < 2
3442            {
3443                // On first pages of Range mode with no data yet, include the most recent bar
3444                // even if it's slightly before our start time (within 2 bar periods)
3445                // BUT we want ALL bars in the page that are within our range
3446                let tolerance_ns = slot_ns * 2; // Allow up to 2 bar periods before start
3447
3448                // Debug: log the page range
3449                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                        // Accept bars from (start - tolerance) to end
3466                        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                // Normal filtering
3476                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                // For Range mode, if all bars are before our start time, there's no point continuing
3489                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                    // Bars are too old (more than 2 bar periods before start), stop
3495                    break;
3496                }
3497            }
3498
3499            // Track contribution for progress guard
3500            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                            // We are backfilling older pages: prepend them.
3522                            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                            // Normal forward: append newer pages.
3534                            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            // Duplicate-window mitigation for Latest/Backward/Range
3545            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                // Advance cursors only when we made progress
3565                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                                // Move back by at least one bar period to avoid getting the same data
3578                                let jump_back = slot_ms.max(60_000); // At least 1 minute
3579                                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            // Stop conditions
3595            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                // For Range mode, check if we have all bars up to the end time
3615                if matches!(mode, Mode::Range) {
3616                    // Don't stop if we haven't reached the end time yet
3617                    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                            // We have bars before start but haven't reached end, need to continue forward
3623                            // Switch from backward to forward pagination
3624                            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        // Final rescue for FORWARD/RANGE when nothing gathered
3638        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        // Trim against end bound if needed (keep ≤ end)
3681        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        // Clamp first bar for Range when using forward pagination
3688        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        // Keep the most recent N bars when limit is specified
3699        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    /// Requests historical order status reports for the given parameters.
3711    ///
3712    /// # Errors
3713    ///
3714    /// Returns an error if the request fails.
3715    ///
3716    /// # References
3717    ///
3718    /// - <https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-order-history-last-7-days>.
3719    /// - <https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-order-history-last-3-months>.
3720    #[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        // Prepare time range filter
3787        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        // Use a seen filter in case pending orders are within the histories "2hr reserve window"
3794        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; // Reserved pending already reported
3817            }
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    /// Requests spread order status reports for the given parameters.
3861    ///
3862    /// # Errors
3863    ///
3864    /// Returns an error if the request fails.
3865    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    // Paginates through order history using `ord_id` as the cursor
4073    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    // Paginates through pending orders using `ord_id` as the cursor
4125    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    // Paginates through transaction details (fills) using `bill_id` as the cursor
4177    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    // Paginates through pending algo orders using `algo_id` as the cursor
4229    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    // Paginates through historical algo orders using `algo_id` as the cursor
4282    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    /// Requests fill reports (transaction details) for the given parameters.
4335    ///
4336    /// # Errors
4337    ///
4338    /// Returns an error if the request fails.
4339    ///
4340    /// # References
4341    ///
4342    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-get-transaction-details-last-3-days>.
4343    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(&params, limit).await?;
4386
4387        // Prepare time range filter
4388        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            // Skip fills with zero or negative quantity (cancelled orders, etc)
4396            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                // Skip unparsable quantities
4406                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    /// Requests spread fill reports for the given parameters.
4451    ///
4452    /// # Errors
4453    ///
4454    /// Returns an error if the request fails.
4455    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(&params, 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    /// Requests current position status reports for the given parameters.
4595    ///
4596    /// # Position Modes
4597    ///
4598    /// OKX supports two position modes, which affects how position data is returned:
4599    ///
4600    /// ## Net Mode (One-way)
4601    /// - `posSide` field will be `"net"`
4602    /// - `pos` field uses **signed quantities**:
4603    ///   - Positive value = Long position
4604    ///   - Negative value = Short position
4605    ///   - Zero = Flat/no position
4606    ///
4607    /// ## Long/Short Mode (Hedge/Dual-side)
4608    /// - `posSide` field will be `"long"` or `"short"`
4609    /// - `pos` field is **always positive** (use `posSide` to determine actual side)
4610    /// - Allows holding simultaneous long and short positions on the same instrument
4611    /// - Position IDs are suffixed with `-LONG` or `-SHORT` for uniqueness
4612    ///
4613    /// # Errors
4614    ///
4615    /// Returns an error if the request fails.
4616    ///
4617    /// # References
4618    ///
4619    /// <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-positions>
4620    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    /// Requests spot margin position status reports from account balance.
4682    ///
4683    /// Spot margin positions appear in `/api/v5/account/balance` as balance sheet items
4684    /// with non-zero `liab` (liability) or `spotInUseAmt` fields, rather than in the
4685    /// positions endpoint. This method fetches the balance and converts any margin
4686    /// positions into position status reports.
4687    ///
4688    /// # Errors
4689    ///
4690    /// Returns an error if the request fails or parsing fails.
4691    ///
4692    /// # References
4693    ///
4694    /// <https://www.okx.com/docs-v5/en/#trading-account-rest-api-get-balance>
4695    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        // Build a base-currency lookup over the cached spot pairs once per
4709        // call. Restricting to `CurrencyPair` (spot) ensures a derivative
4710        // sharing the same base (e.g. `BTC-USDT-SWAP`) is never reported as
4711        // a spot margin position with the wrong instrument id or size
4712        // precision.
4713        //
4714        // When multiple spot pairs share the same base currency, prefer the
4715        // dominant OKX quote (USDT, then USDC, then USD) so a live
4716        // `BTC-USDT` margin position stays reported under `BTC-USDT.OKX`
4717        // rather than being redirected to `BTC-USD.OKX` or any other
4718        // lexically-earlier pair. Unknown quotes fall back to a stable
4719        // lexical order by symbol, matching OKX's own listing precedence
4720        // and keeping the selection deterministic across runs.
4721        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) => {} // No margin position for this currency
4765                    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    /// Places a regular order via HTTP.
4778    ///
4779    /// # Errors
4780    ///
4781    /// Returns an error if the request fails.
4782    ///
4783    /// # References
4784    ///
4785    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-post-place-order>
4786    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    /// Places a spread order via HTTP.
4804    ///
4805    /// # Errors
4806    ///
4807    /// Returns an error if the request fails.
4808    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    /// Cancels a spread order via HTTP.
4832    ///
4833    /// # Errors
4834    ///
4835    /// Returns an error if the request fails.
4836    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    /// Cancels all orders for a spread via HTTP.
4860    ///
4861    /// # Errors
4862    ///
4863    /// Returns an error if the request fails.
4864    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    /// Cancels an order via HTTP, routing spread instruments to the spread endpoint.
4876    ///
4877    /// # Errors
4878    ///
4879    /// Returns an error if the request fails or if no order identifier is supplied.
4880    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    /// Cancels all open orders for an instrument via HTTP.
4930    ///
4931    /// # Errors
4932    ///
4933    /// Returns an error if the request fails.
4934    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    /// Cancels multiple regular orders via HTTP in a single request.
4981    ///
4982    /// Items with non-zero `sCode` are logged as warnings but do not
4983    /// fail the entire batch.
4984    ///
4985    /// # Errors
4986    ///
4987    /// Returns an error if the request fails.
4988    ///
4989    /// # References
4990    ///
4991    /// <https://www.okx.com/docs-v5/en/#order-book-trading-trade-post-cancel-multiple-orders>
4992    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    /// Places an algo order via HTTP.
5030    ///
5031    /// # Errors
5032    ///
5033    /// Returns an error if the request fails.
5034    ///
5035    /// # References
5036    ///
5037    /// <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-place-algo-order>
5038    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    /// Cancels an algo order via HTTP.
5075    ///
5076    /// # Errors
5077    ///
5078    /// Returns an error if the request fails.
5079    ///
5080    /// # References
5081    ///
5082    /// <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-cancel-algo-order>
5083    pub async fn cancel_algo_order(
5084        &self,
5085        request: OKXCancelAlgoOrderRequest,
5086    ) -> Result<OKXCancelAlgoOrderResponse, OKXHttpError> {
5087        // OKX expects an array for cancel-algos endpoint
5088        // Serialize once to bytes to keep signing and sending identical
5089        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    /// Cancels multiple algo orders via HTTP in a single request.
5122    ///
5123    /// Items with non-zero `sCode` are logged as warnings but do not
5124    /// fail the entire batch.
5125    ///
5126    /// # Errors
5127    ///
5128    /// Returns an error if the request fails.
5129    ///
5130    /// # References
5131    ///
5132    /// <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-cancel-algo-order>
5133    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    /// Cancels advance algo orders (trailing stop, iceberg, TWAP) via HTTP.
5171    ///
5172    /// These order types cannot use the standard `cancel-algos` endpoint.
5173    /// Items with non-zero `sCode` are logged as warnings.
5174    ///
5175    /// # Errors
5176    ///
5177    /// Returns an error if the request fails.
5178    ///
5179    /// # References
5180    ///
5181    /// <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-cancel-advance-algo-order>
5182    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    /// Amends an algo order via HTTP.
5220    ///
5221    /// # Errors
5222    ///
5223    /// Returns an error if the request fails.
5224    ///
5225    /// # References
5226    ///
5227    /// <https://www.okx.com/docs-v5/en/#order-book-trading-algo-trading-post-amend-algo-order>
5228    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    /// Amends an algo order using domain types.
5252    ///
5253    /// This is a convenience method that accepts Nautilus domain types
5254    /// and builds the appropriate OKX request structure internally.
5255    ///
5256    /// # Errors
5257    ///
5258    /// Returns an error if the request fails.
5259    #[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    /// Places an algo order using domain types.
5299    ///
5300    /// This is a convenience method that accepts Nautilus domain types
5301    /// and builds the appropriate OKX request structure internally.
5302    ///
5303    /// # Errors
5304    ///
5305    /// Returns an error if the request fails.
5306    #[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        // OKX options only support limit-style orders
5399        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                    // optimal_limit_ioc only works for SWAP/FUTURES
5444                    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                    // OKX uses op_fok for options FOK orders
5455                    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        // reduceOnly is not applicable to options per OKX docs
5485        let reduce_only = if instrument_type == OKXInstrumentType::Option {
5486            None
5487        } else {
5488            reduce_only
5489        };
5490
5491        // For options: pxUsd/pxVol are mutually exclusive with px
5492        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    /// Places a spread order using domain types.
5525    ///
5526    /// # Errors
5527    ///
5528    /// Returns an error if the order cannot be represented by the OKX spread endpoint
5529    /// or if the request fails.
5530    #[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    /// Places an algo order using domain types.
5590    ///
5591    /// This is a convenience method that accepts Nautilus domain types
5592    /// and builds the appropriate OKX request structure internally.
5593    ///
5594    /// # Errors
5595    ///
5596    /// Returns an error if the request fails.
5597    #[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        // Map trigger type to OKX format
5624        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    /// Cancels an algo order using domain types.
5773    ///
5774    /// This is a convenience method that accepts Nautilus domain types
5775    /// and builds the appropriate OKX request structure internally.
5776    ///
5777    /// # Errors
5778    ///
5779    /// Returns an error if the request fails.
5780    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    /// Requests algo order status reports.
5796    ///
5797    /// # Errors
5798    ///
5799    /// Returns an error if the request fails.
5800    #[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(&params, 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            // `/orders-algo-history` rejects anything but `state`/`algoId`
5883            // with 50015; `algoClOrdId` alone is not accepted.
5884            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(&params, 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    /// Requests an algo order status report by client order identifier.
5914    ///
5915    /// # Errors
5916    ///
5917    /// Returns an error if the request fails.
5918    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    /// Exposes raw HTTP client for testing purposes
5940    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}