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