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