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