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