Skip to content

Commit d7b509a

Browse files
authored
Fix Deribit chart bar volume for inverse perpetuals (#4245)
1 parent 33bcefb commit d7b509a

5 files changed

Lines changed: 138 additions & 15 deletions

File tree

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

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ use nautilus_core::{
2525
};
2626
use nautilus_model::{
2727
data::{Bar, BarType, BookOrder, TradeTick},
28-
enums::{AccountType, AggressorSide, BookType, OptionKind, OrderSide},
28+
enums::{AccountType, AggressorSide, BookType, InstrumentClass, OptionKind, OrderSide},
2929
events::AccountState,
3030
identifiers::{AccountId, InstrumentId, Symbol, TradeId},
3131
instruments::{
3232
CryptoFuture, CryptoFuturesSpread, CryptoOption, CryptoOptionSpread, CryptoPerpetual,
33-
CurrencyPair, any::InstrumentAny,
33+
CurrencyPair, Instrument, any::InstrumentAny,
3434
},
3535
orderbook::OrderBook,
3636
types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
@@ -860,11 +860,35 @@ pub fn parse_trade_tick(
860860
))
861861
}
862862

863+
/// Returns true when `Bar.volume` should be populated from the chart `cost` field (USD) instead
864+
/// of the `volume` field (base currency).
865+
///
866+
/// Deribit's `trades.{instrument}` channel reports each trade's `amount` in USD for inverse
867+
/// perpetuals and inverse futures, and in the underlying base currency for options and linear
868+
/// futures. To keep `Bar.volume` and `TradeTick.size` on a single unit per instrument, route
869+
/// inverse non-option products through `cost`. Options and option spreads stay on `volume` even
870+
/// when flagged `is_inverse`, because their trade `amount` is reported in base currency.
871+
///
872+
/// Reference: <https://docs.deribit.com/api-reference/market-data/public-get_last_trades_by_currency>
873+
#[must_use]
874+
pub fn use_cost_for_bar_volume(instrument: &InstrumentAny) -> bool {
875+
if !instrument.is_inverse() {
876+
return false;
877+
}
878+
!matches!(
879+
instrument.instrument_class(),
880+
InstrumentClass::Option | InstrumentClass::OptionSpread
881+
)
882+
}
883+
863884
/// Parses Deribit TradingView chart data into Nautilus [`Bar`]s.
864885
///
865886
/// Converts OHLCV arrays from the `public/get_tradingview_chart_data` endpoint
866887
/// into a vector of [`Bar`] objects.
867888
///
889+
/// When `use_cost_for_volume` is true, `Bar.volume` is populated from `chart_data.cost` (USD)
890+
/// instead of `chart_data.volume` (base currency) — see [`use_cost_for_bar_volume`].
891+
///
868892
/// # Errors
869893
///
870894
/// Returns an error if:
@@ -876,6 +900,7 @@ pub fn parse_bars(
876900
bar_type: BarType,
877901
price_precision: u8,
878902
size_precision: u8,
903+
use_cost_for_volume: bool,
879904
ts_init: UnixNanos,
880905
) -> anyhow::Result<Vec<Bar>> {
881906
// Check status
@@ -894,7 +919,8 @@ pub fn parse_bars(
894919
&& chart_data.high.len() == num_bars
895920
&& chart_data.low.len() == num_bars
896921
&& chart_data.close.len() == num_bars
897-
&& chart_data.volume.len() == num_bars,
922+
&& chart_data.volume.len() == num_bars
923+
&& chart_data.cost.len() == num_bars,
898924
"Inconsistent array lengths in chart data"
899925
);
900926

@@ -913,7 +939,12 @@ pub fn parse_bars(
913939
.with_context(|| format!("Invalid low price at index {i}"))?;
914940
let close = Price::new_checked(chart_data.close[i], price_precision)
915941
.with_context(|| format!("Invalid close price at index {i}"))?;
916-
let volume = Quantity::new_checked(chart_data.volume[i], size_precision)
942+
let raw_volume = if use_cost_for_volume {
943+
chart_data.cost[i]
944+
} else {
945+
chart_data.volume[i]
946+
};
947+
let volume = Quantity::new_checked(raw_volume, size_precision)
917948
.with_context(|| format!("Invalid volume at index {i}"))?;
918949

919950
// Convert timestamp from milliseconds to nanoseconds
@@ -1402,7 +1433,56 @@ mod tests {
14021433
}
14031434

14041435
#[rstest]
1405-
fn test_parse_bars() {
1436+
fn test_use_cost_for_bar_volume() {
1437+
// Inverse perpetual: BTC-PERPETUAL → cost (USD)
1438+
let perp_json = load_test_json("http_get_instrument.json");
1439+
let perp_response: DeribitJsonRpcResponse<DeribitInstrument> =
1440+
serde_json::from_str(&perp_json).unwrap();
1441+
let perp_inst = perp_response.result.expect("Test data must have result");
1442+
let perp =
1443+
parse_deribit_instrument_any(&perp_inst, UnixNanos::default(), UnixNanos::default())
1444+
.unwrap()
1445+
.expect("Should parse perpetual");
1446+
assert!(perp.is_inverse());
1447+
assert!(use_cost_for_bar_volume(&perp));
1448+
1449+
// BTC inverse option: is_inverse, but trade amount is in BTC, so stay on volume
1450+
let instruments_json = load_test_json("http_get_instruments.json");
1451+
let instruments_response: DeribitJsonRpcResponse<Vec<DeribitInstrument>> =
1452+
serde_json::from_str(&instruments_json).unwrap();
1453+
let instruments = instruments_response
1454+
.result
1455+
.expect("Test data must have result");
1456+
1457+
let option_inst = instruments
1458+
.iter()
1459+
.find(|i| i.instrument_name.as_str() == "BTC-27DEC24-100000-C")
1460+
.expect("Test data must contain BTC-27DEC24-100000-C");
1461+
let option =
1462+
parse_deribit_instrument_any(option_inst, UnixNanos::default(), UnixNanos::default())
1463+
.unwrap()
1464+
.expect("Should parse option");
1465+
assert!(option.is_inverse());
1466+
assert!(
1467+
!use_cost_for_bar_volume(&option),
1468+
"options report trade amount in base currency, must keep using volume",
1469+
);
1470+
1471+
// Inverse future: same convention as perp — cost (USD)
1472+
let future_inst = instruments
1473+
.iter()
1474+
.find(|i| i.instrument_name.as_str() == "BTC-27DEC24")
1475+
.expect("Test data must contain BTC-27DEC24");
1476+
let future =
1477+
parse_deribit_instrument_any(future_inst, UnixNanos::default(), UnixNanos::default())
1478+
.unwrap()
1479+
.expect("Should parse future");
1480+
assert!(future.is_inverse());
1481+
assert!(use_cost_for_bar_volume(&future));
1482+
}
1483+
1484+
#[rstest]
1485+
fn test_parse_bars_uses_volume_field() {
14061486
let json_data = load_test_json("http_get_tradingview_chart_data.json");
14071487
let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
14081488
serde_json::from_str(&json_data).unwrap();
@@ -1411,7 +1491,8 @@ mod tests {
14111491
let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
14121492
let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
14131493

1414-
let bars = parse_bars(&chart_data, bar_type, 1, 8, ts_init).expect("Should parse bars");
1494+
let bars =
1495+
parse_bars(&chart_data, bar_type, 1, 8, false, ts_init).expect("Should parse bars");
14151496

14161497
assert_eq!(bars.len(), 5, "Should parse 5 bars");
14171498

@@ -1442,6 +1523,24 @@ mod tests {
14421523
);
14431524
}
14441525

1526+
#[rstest]
1527+
fn test_parse_bars_cost_path() {
1528+
let json_data = load_test_json("http_get_tradingview_chart_data.json");
1529+
let response: DeribitJsonRpcResponse<DeribitTradingViewChartData> =
1530+
serde_json::from_str(&json_data).unwrap();
1531+
let chart_data = response.result.expect("Test data must have result");
1532+
1533+
let bar_type = BarType::from("BTC-PERPETUAL.DERIBIT-1-MINUTE-LAST-EXTERNAL");
1534+
let ts_init = UnixNanos::from(1766487086146245_u64 * NANOSECONDS_IN_MICROSECOND);
1535+
1536+
// Cost path picks `cost` (USD), matching trade `amount` on inverse perps/futures.
1537+
let bars =
1538+
parse_bars(&chart_data, bar_type, 1, 0, true, ts_init).expect("Should parse bars");
1539+
assert_eq!(bars.len(), 5);
1540+
assert_eq!(bars[0].volume, Quantity::from("257490"));
1541+
assert_eq!(bars[4].volume, Quantity::from("8910"));
1542+
}
1543+
14451544
#[rstest]
14461545
fn test_parse_order_book() {
14471546
let json_data = load_test_json("http_get_order_book.json");

crates/adapters/deribit/src/http/client.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ use crate::{
7979
parse::{
8080
extract_server_timestamp, parse_account_state, parse_bars,
8181
parse_deribit_instrument_any, parse_order_book, parse_trade_tick,
82+
use_cost_for_bar_volume,
8283
},
8384
urls::get_http_base_url,
8485
},
@@ -1433,11 +1434,15 @@ impl DeribitHttpClient {
14331434
return Ok(Vec::new());
14341435
}
14351436

1436-
// Get instrument from cache to determine precisions
1437+
// Get instrument from cache to determine precisions and volume-field selection.
14371438
let instrument_id = bar_type.instrument_id();
1438-
let (price_precision, size_precision) =
1439+
let (price_precision, size_precision, use_cost_for_volume) =
14391440
if let Some(instrument) = self.get_instrument(&instrument_id.symbol.inner()) {
1440-
(instrument.price_precision(), instrument.size_precision())
1441+
(
1442+
instrument.price_precision(),
1443+
instrument.size_precision(),
1444+
use_cost_for_bar_volume(&instrument),
1445+
)
14411446
} else {
14421447
log::warn!("Instrument {instrument_id} not in cache, skipping bars request");
14431448
anyhow::bail!("Instrument {instrument_id} not in cache");
@@ -1449,6 +1454,7 @@ impl DeribitHttpClient {
14491454
bar_type,
14501455
price_precision,
14511456
size_precision,
1457+
use_cost_for_volume,
14521458
ts_init,
14531459
)?;
14541460

crates/adapters/deribit/src/http/models.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,6 @@ pub struct DeribitTradingViewChartData {
515515
/// List of prices at close (one per candle)
516516
pub close: Vec<f64>,
517517
/// List of cost bars (volume in quote currency, one per candle)
518-
#[serde(default)]
519518
pub cost: Vec<f64>,
520519
/// List of highest price levels (one per candle)
521520
pub high: Vec<f64>,

crates/adapters/deribit/src/websocket/handler.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ use crate::{
7070
common::{
7171
consts::{DERIBIT_POST_ONLY_ERROR_CODE, DERIBIT_RATE_LIMIT_KEY_ORDER, DERIBIT_VENUE},
7272
enums::DeribitInstrumentState,
73-
parse::parse_portfolio_to_account_state,
73+
parse::{parse_portfolio_to_account_state, use_cost_for_bar_volume},
7474
},
7575
data_types::DeribitVolatilityIndex,
7676
};
@@ -1935,12 +1935,15 @@ impl DeribitWsFeedHandler {
19351935
Ok(bar_type) => {
19361936
let price_precision = instrument.price_precision();
19371937
let size_precision = instrument.size_precision();
1938+
let use_cost_for_volume =
1939+
use_cost_for_bar_volume(instrument);
19381940

19391941
match parse_chart_msg(
19401942
&chart_msg,
19411943
bar_type,
19421944
price_precision,
19431945
size_precision,
1946+
use_cost_for_volume,
19441947
self.bars_timestamp_on_close,
19451948
ts_init,
19461949
) {

crates/adapters/deribit/src/websocket/parse.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,11 @@ pub fn resolution_to_bar_type(
659659
/// Converts a single OHLCV data point from the `chart.trades.{instrument}.{resolution}` channel
660660
/// into a Nautilus Bar object.
661661
///
662+
/// When `use_cost_for_volume` is true, `Bar.volume` is populated from `chart_msg.cost` (USD) to
663+
/// match instruments whose trade `amount` is in USD (inverse perpetuals / inverse futures).
664+
/// Otherwise `chart_msg.volume` (base currency) is used. Callers derive this from the instrument
665+
/// via [`crate::common::parse::use_cost_for_bar_volume`].
666+
///
662667
/// # Errors
663668
///
664669
/// Returns an error if:
@@ -669,6 +674,7 @@ pub fn parse_chart_msg(
669674
bar_type: BarType,
670675
price_precision: u8,
671676
size_precision: u8,
677+
use_cost_for_volume: bool,
672678
timestamp_on_close: bool,
673679
ts_init: UnixNanos,
674680
) -> anyhow::Result<Bar> {
@@ -677,8 +683,12 @@ pub fn parse_chart_msg(
677683
let low = Price::new_checked(chart_msg.low, price_precision).context("Invalid low price")?;
678684
let close =
679685
Price::new_checked(chart_msg.close, price_precision).context("Invalid close price")?;
680-
let volume =
681-
Quantity::new_checked(chart_msg.volume, size_precision).context("Invalid volume")?;
686+
let raw_volume = if use_cost_for_volume {
687+
chart_msg.cost
688+
} else {
689+
chart_msg.volume
690+
};
691+
let volume = Quantity::new_checked(raw_volume, size_precision).context("Invalid volume")?;
682692

683693
// Convert timestamp from milliseconds to nanoseconds
684694
let mut ts_event = UnixNanos::from(chart_msg.tick * NANOSECONDS_IN_MILLISECOND);
@@ -1872,8 +1882,13 @@ mod tests {
18721882
}
18731883

18741884
#[rstest]
1875-
fn test_parse_chart_msg() {
1885+
fn test_parse_chart_msg_uses_cost() {
18761886
let instrument = test_perpetual_instrument();
1887+
assert!(
1888+
instrument.is_inverse(),
1889+
"test fixture is expected to be an inverse perp"
1890+
);
1891+
18771892
let json = load_test_json("ws_chart.json");
18781893
let response: serde_json::Value = serde_json::from_str(&json).unwrap();
18791894
let chart_msg: DeribitChartMsg =
@@ -1896,6 +1911,7 @@ mod tests {
18961911
bar_type,
18971912
instrument.price_precision(),
18981913
instrument.size_precision(),
1914+
true, // use_cost_for_volume
18991915
true,
19001916
UnixNanos::default(),
19011917
)
@@ -1906,7 +1922,7 @@ mod tests {
19061922
assert_eq!(bar.high, instrument.make_price(87500.0));
19071923
assert_eq!(bar.low, instrument.make_price(87465.0));
19081924
assert_eq!(bar.close, instrument.make_price(87474.0));
1909-
assert_eq!(bar.volume, instrument.make_qty(1.0, None)); // Rounded to 1.0 with size_precision=0
1925+
assert_eq!(bar.volume, instrument.make_qty(83970.0, None));
19101926

19111927
// ts_event should be close time (open + 1 minute)
19121928
assert_eq!(bar.ts_event, UnixNanos::new(1_767_200_100_000_000_000));

0 commit comments

Comments
 (0)