@@ -25,12 +25,12 @@ use nautilus_core::{
2525} ;
2626use 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" ) ;
0 commit comments