Skip to content

Commit aaf5a29

Browse files
committed
Refine OKX VIP level handling and order book subscriptions
- Add VIP-aware order book channel selection with depth support - Add VIP level query endpoint and automatic detection on connect - Fix account state parsing for some account types - BREAKING: Removed `vip_level` config option (now redundant)
1 parent 9d40438 commit aaf5a29

File tree

17 files changed

+507
-71
lines changed

17 files changed

+507
-71
lines changed

crates/adapters/okx/src/common/enums.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,27 @@ pub enum OKXVipLevel {
660660
Vip9 = 9,
661661
}
662662

663+
impl From<u8> for OKXVipLevel {
664+
fn from(value: u8) -> Self {
665+
match value {
666+
0 => Self::Vip0,
667+
1 => Self::Vip1,
668+
2 => Self::Vip2,
669+
3 => Self::Vip3,
670+
4 => Self::Vip4,
671+
5 => Self::Vip5,
672+
6 => Self::Vip6,
673+
7 => Self::Vip7,
674+
8 => Self::Vip8,
675+
9 => Self::Vip9,
676+
_ => {
677+
tracing::warn!("Invalid VIP level {value}, defaulting to Vip0");
678+
Self::Vip0
679+
}
680+
}
681+
}
682+
}
683+
663684
impl From<OKXSide> for OrderSide {
664685
fn from(side: OKXSide) -> Self {
665686
match side {

crates/adapters/okx/src/common/parse.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ use crate::{
5757
consts::OKX_VENUE,
5858
enums::{
5959
OKXExecType, OKXInstrumentType, OKXOrderStatus, OKXOrderType, OKXPositionSide, OKXSide,
60+
OKXVipLevel,
6061
},
6162
models::OKXInstrument,
6263
},
@@ -139,6 +140,35 @@ where
139140
}
140141
}
141142

143+
/// Deserializes an OKX VIP level string (e.g., "Lv4") into [`OKXVipLevel`].
144+
///
145+
/// OKX returns VIP levels as strings like "Lv0", "Lv1", ..., "Lv9".
146+
/// This function strips the "Lv" prefix and parses the numeric value.
147+
///
148+
/// # Errors
149+
///
150+
/// Returns an error if the string cannot be parsed into a valid VIP level.
151+
pub fn deserialize_vip_level<'de, D>(deserializer: D) -> Result<OKXVipLevel, D::Error>
152+
where
153+
D: Deserializer<'de>,
154+
{
155+
let s = String::deserialize(deserializer)?;
156+
157+
// Strip "Lv" prefix if present
158+
let level_str = s
159+
.strip_prefix("Lv")
160+
.or_else(|| s.strip_prefix("lv"))
161+
.unwrap_or(&s);
162+
163+
// Parse the numeric value
164+
let level_num = level_str
165+
.parse::<u8>()
166+
.map_err(|e| serde::de::Error::custom(format!("Invalid VIP level '{s}': {e}")))?;
167+
168+
// Convert to enum
169+
Ok(OKXVipLevel::from(level_num))
170+
}
171+
142172
/// Returns the currency either from the internal currency map or creates a default crypto.
143173
fn get_currency(code: &str) -> Currency {
144174
CURRENCY_MAP
@@ -1290,6 +1320,12 @@ pub fn parse_account_state(
12901320
) -> anyhow::Result<AccountState> {
12911321
let mut balances = Vec::new();
12921322
for b in &okx_account.details {
1323+
// Skip balances with empty currency codes
1324+
if b.ccy.is_empty() {
1325+
tracing::warn!("Skipping balance detail with empty currency code");
1326+
continue;
1327+
}
1328+
12931329
let currency = Currency::from(b.ccy);
12941330
let total = Money::new(b.cash_bal.parse::<f64>()?, currency);
12951331
let free = Money::new(b.avail_bal.parse::<f64>()?, currency);
@@ -1298,6 +1334,15 @@ pub fn parse_account_state(
12981334
balances.push(balance);
12991335
}
13001336

1337+
// Ensure at least one balance exists (Nautilus requires non-empty balances)
1338+
// OKX may return empty details for certain account configurations
1339+
if balances.is_empty() {
1340+
let zero_currency = Currency::USD();
1341+
let zero_money = Money::new(0.0, zero_currency);
1342+
let zero_balance = AccountBalance::new(zero_money, zero_money, zero_money);
1343+
balances.push(zero_balance);
1344+
}
1345+
13011346
let mut margins = Vec::new();
13021347

13031348
// OKX provides account-level margin requirements (not per instrument)
@@ -2401,4 +2446,44 @@ mod tests {
24012446
"BarType must be preserved exactly through parsing"
24022447
);
24032448
}
2449+
2450+
#[rstest]
2451+
fn test_deserialize_vip_level_with_lv_prefix() {
2452+
use serde::Deserialize;
2453+
use serde_json;
2454+
2455+
#[derive(Deserialize)]
2456+
struct TestFeeRate {
2457+
#[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
2458+
level: OKXVipLevel,
2459+
}
2460+
2461+
let json = r#"{"level":"Lv4"}"#;
2462+
let result: TestFeeRate = serde_json::from_str(json).unwrap();
2463+
assert_eq!(result.level, OKXVipLevel::Vip4);
2464+
2465+
let json = r#"{"level":"Lv0"}"#;
2466+
let result: TestFeeRate = serde_json::from_str(json).unwrap();
2467+
assert_eq!(result.level, OKXVipLevel::Vip0);
2468+
2469+
let json = r#"{"level":"Lv9"}"#;
2470+
let result: TestFeeRate = serde_json::from_str(json).unwrap();
2471+
assert_eq!(result.level, OKXVipLevel::Vip9);
2472+
}
2473+
2474+
#[rstest]
2475+
fn test_deserialize_vip_level_without_prefix() {
2476+
use serde::Deserialize;
2477+
use serde_json;
2478+
2479+
#[derive(Deserialize)]
2480+
struct TestFeeRate {
2481+
#[serde(deserialize_with = "crate::common::parse::deserialize_vip_level")]
2482+
level: OKXVipLevel,
2483+
}
2484+
2485+
let json = r#"{"level":"5"}"#;
2486+
let result: TestFeeRate = serde_json::from_str(json).unwrap();
2487+
assert_eq!(result.level, OKXVipLevel::Vip5);
2488+
}
24042489
}

crates/adapters/okx/src/config.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
//! Configuration structures for the OKX adapter.
1717
1818
use crate::common::{
19-
enums::{OKXContractType, OKXInstrumentType, OKXVipLevel},
19+
enums::{OKXContractType, OKXInstrumentType},
2020
urls::{
2121
get_http_base_url, get_ws_base_url_business, get_ws_base_url_private,
2222
get_ws_base_url_public,
@@ -51,8 +51,6 @@ pub struct OKXDataClientConfig {
5151
pub http_timeout_secs: Option<u64>,
5252
/// Optional interval for refreshing instruments.
5353
pub update_instruments_interval_mins: Option<u64>,
54-
/// Optional VIP level that unlocks additional subscriptions.
55-
pub vip_level: Option<OKXVipLevel>,
5654
}
5755

5856
impl Default for OKXDataClientConfig {
@@ -70,7 +68,6 @@ impl Default for OKXDataClientConfig {
7068
is_demo: false,
7169
http_timeout_secs: Some(60),
7270
update_instruments_interval_mins: Some(60),
73-
vip_level: None,
7471
}
7572
}
7673
}

crates/adapters/okx/src/data/mod.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ use tokio_util::sync::CancellationToken;
5959
use crate::{
6060
common::{
6161
consts::OKX_VENUE,
62-
enums::{OKXBookChannel, OKXContractType, OKXInstrumentType},
62+
enums::{OKXBookChannel, OKXContractType, OKXInstrumentType, OKXVipLevel},
6363
},
6464
config::OKXDataClientConfig,
6565
http::client::OKXHttpClient,
@@ -157,8 +157,8 @@ impl OKXDataClient {
157157
*OKX_VENUE
158158
}
159159

160-
fn vip_level(&self) -> Option<u8> {
161-
self.config.vip_level.map(|vip| vip as u8)
160+
fn vip_level(&self) -> Option<OKXVipLevel> {
161+
self.ws_public.as_ref().map(|ws| ws.vip_level())
162162
}
163163

164164
fn public_ws(&self) -> anyhow::Result<&OKXWebSocketClient> {
@@ -479,7 +479,13 @@ impl DataClient for OKXDataClient {
479479
}
480480

481481
fn start(&mut self) -> anyhow::Result<()> {
482-
tracing::info!("Starting OKX data client {id}", id = self.client_id);
482+
tracing::info!(
483+
client_id = %self.client_id,
484+
vip_level = ?self.vip_level(),
485+
instrument_types = ?self.config.instrument_types,
486+
is_demo = self.config.is_demo,
487+
"Starting OKX data client"
488+
);
483489
Ok(())
484490
}
485491

@@ -516,6 +522,18 @@ impl DataClient for OKXDataClient {
516522

517523
self.bootstrap_instruments().await?;
518524

525+
// Query VIP level and update websocket clients
526+
if self.config.has_api_credentials()
527+
&& let Ok(Some(vip_level)) = self.http_client.request_vip_level().await
528+
{
529+
if let Some(ws) = self.ws_public.as_mut() {
530+
ws.set_vip_level(vip_level);
531+
}
532+
if let Some(ws) = self.ws_business.as_mut() {
533+
ws.set_vip_level(vip_level);
534+
}
535+
}
536+
519537
{
520538
let ws_public = self.public_ws_mut()?;
521539
ws_public
@@ -623,18 +641,18 @@ impl DataClient for OKXDataClient {
623641
anyhow::bail!("invalid depth {depth}; valid values are 50 or 400");
624642
}
625643

626-
let vip = self.vip_level().unwrap_or(0);
644+
let vip = self.vip_level().unwrap_or(OKXVipLevel::Vip0);
627645
let channel = match depth {
628646
50 => {
629-
if vip < 4 {
647+
if vip < OKXVipLevel::Vip4 {
630648
anyhow::bail!(
631649
"VIP level {vip} insufficient for 50 depth subscription (requires VIP4)"
632650
);
633651
}
634652
OKXBookChannel::Books50L2Tbt
635653
}
636654
0 | 400 => {
637-
if vip >= 5 {
655+
if vip >= OKXVipLevel::Vip5 {
638656
OKXBookChannel::BookL2Tbt
639657
} else {
640658
OKXBookChannel::Book
@@ -650,15 +668,15 @@ impl DataClient for OKXDataClient {
650668
async move {
651669
match channel {
652670
OKXBookChannel::Books50L2Tbt => ws
653-
.subscribe_books50_l2_tbt(instrument_id)
671+
.subscribe_book50_l2_tbt(instrument_id)
654672
.await
655673
.context("books50-l2-tbt subscription")?,
656674
OKXBookChannel::BookL2Tbt => ws
657675
.subscribe_book_l2_tbt(instrument_id)
658676
.await
659677
.context("books-l2-tbt subscription")?,
660678
OKXBookChannel::Book => ws
661-
.subscribe_book(instrument_id)
679+
.subscribe_books_channel(instrument_id)
662680
.await
663681
.context("books subscription")?,
664682
}

crates/adapters/okx/src/execution/mod.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,16 @@ impl ExecutionClient for OKXExecutionClient {
366366

367367
self.ensure_instruments_initialized()?;
368368
self.started = true;
369-
tracing::info!("OKX execution client {} started", self.core.client_id);
369+
tracing::info!(
370+
client_id = %self.core.client_id,
371+
account_id = %self.core.account_id,
372+
account_type = ?self.core.account_type,
373+
trade_mode = ?self.trade_mode,
374+
instrument_types = ?self.config.instrument_types,
375+
use_fills_channel = self.config.use_fills_channel,
376+
is_demo = self.config.is_demo,
377+
"OKX execution client started"
378+
);
370379
Ok(())
371380
}
372381

@@ -526,6 +535,11 @@ impl LiveExecutionClient for OKXExecutionClient {
526535

527536
self.ensure_instruments_initialized_async().await?;
528537

538+
// Query VIP level and update websocket client
539+
if let Ok(Some(vip_level)) = self.http_client.request_vip_level().await {
540+
self.ws_client.set_vip_level(vip_level);
541+
}
542+
529543
self.ws_client.connect().await?;
530544
self.ws_client.wait_until_active(10.0).await?;
531545

0 commit comments

Comments
 (0)