1818use std:: str:: FromStr ;
1919
2020use anyhow:: Context ;
21- use nautilus_core:: {
22- UnixNanos ,
23- datetime:: { NANOSECONDS_IN_MILLISECOND , NANOSECONDS_IN_SECOND } ,
24- params:: Params ,
25- } ;
21+ use nautilus_core:: { UnixNanos , datetime:: NANOSECONDS_IN_SECOND , params:: Params } ;
2622use nautilus_model:: {
2723 enums:: { OptionKind , OrderSide , OrderStatus , OrderType , TimeInForce , TriggerType } ,
2824 identifiers:: { InstrumentId , Symbol } ,
@@ -533,6 +529,11 @@ pub fn derive_rejection_due_post_only(code: Option<i64>, reason: &str) -> bool {
533529
534530/// Parses a Derive instrument definition into a Nautilus instrument.
535531///
532+ /// Perpetuals are normalized to USDC quote and settlement: the wire quotes
533+ /// perps in `"USD"` index terms, while all Derive collateral, fees, and PnL
534+ /// settle in USDC, so Money currencies must match the account balances. The
535+ /// raw wire values remain in the instrument `info` payload.
536+ ///
536537/// # Errors
537538///
538539/// Returns an error when a Derive instrument is missing required details or
@@ -560,7 +561,8 @@ fn parse_perp_instrument(
560561 let instrument_id = format_instrument_id ( instrument. instrument_name . as_str ( ) ) ;
561562 let raw_symbol = Symbol :: new ( instrument. instrument_name . as_str ( ) ) ;
562563 let base_currency = Currency :: get_or_create_crypto ( instrument. base_currency . as_str ( ) ) ;
563- let quote_currency = Currency :: get_or_create_crypto ( instrument. quote_currency . as_str ( ) ) ;
564+ // Wire says "USD" but Derive settles everything in USDC
565+ let quote_currency = Currency :: USDC ( ) ;
564566 let settlement_currency = quote_currency;
565567 let price_increment = price_from_decimal ( instrument. tick_size , "tick_size" ) ?;
566568 let size_increment = quantity_from_decimal ( instrument. amount_step , "amount_step" ) ?;
@@ -618,7 +620,7 @@ fn parse_option_instrument(
618620 let option_kind = parse_option_kind ( details. option_type ) ;
619621 let strike_price = price_from_decimal ( details. strike , "option_details.strike" ) ?;
620622 let activation_ns =
621- timestamp_millis_to_nanos ( instrument. scheduled_activation , "scheduled_activation" ) ?;
623+ timestamp_seconds_to_nanos ( instrument. scheduled_activation , "scheduled_activation" ) ?;
622624 let expiration_ns = timestamp_seconds_to_nanos ( details. expiry , "option_details.expiry" ) ?;
623625 let price_increment = price_from_decimal ( instrument. tick_size , "tick_size" ) ?;
624626 let size_increment = quantity_from_decimal ( instrument. amount_step , "amount_step" ) ?;
@@ -741,10 +743,6 @@ fn timestamp_seconds_to_nanos(value: i64, field: &str) -> anyhow::Result<UnixNan
741743 timestamp_to_nanos ( value, NANOSECONDS_IN_SECOND , field)
742744}
743745
744- fn timestamp_millis_to_nanos ( value : i64 , field : & str ) -> anyhow:: Result < UnixNanos > {
745- timestamp_to_nanos ( value, NANOSECONDS_IN_MILLISECOND , field)
746- }
747-
748746fn timestamp_to_nanos ( value : i64 , multiplier : u64 , field : & str ) -> anyhow:: Result < UnixNanos > {
749747 let value = u64:: try_from ( value) . with_context ( || format ! ( "negative Derive {field}" ) ) ?;
750748 let nanos = value
@@ -968,14 +966,15 @@ mod tests {
968966 assert_eq ! ( perp. id( ) , InstrumentId :: from( "ETH-PERP.DERIVE" ) ) ;
969967 assert_eq ! ( perp. raw_symbol( ) . as_str( ) , "ETH-PERP" ) ;
970968 assert_eq ! ( perp. base_currency( ) , Some ( Currency :: ETH ( ) ) ) ;
969+ // Fixture carries the live wire quote "USD"; parser normalizes to USDC
971970 assert_eq ! ( perp. quote_currency( ) , Currency :: USDC ( ) ) ;
972971 assert_eq ! ( perp. settlement_currency( ) , Currency :: USDC ( ) ) ;
973972 assert_eq ! ( perp. price_increment( ) , Price :: from( "0.01" ) ) ;
974973 assert_eq ! ( perp. size_increment( ) , Quantity :: from( "0.001" ) ) ;
975- assert_eq ! ( perp. max_quantity( ) , Some ( Quantity :: from( "1000 " ) ) ) ;
976- assert_eq ! ( perp. min_quantity( ) , Some ( Quantity :: from( "0.001 " ) ) ) ;
974+ assert_eq ! ( perp. max_quantity( ) , Some ( Quantity :: from( "10000 " ) ) ) ;
975+ assert_eq ! ( perp. min_quantity( ) , Some ( Quantity :: from( "0.1 " ) ) ) ;
977976 assert_eq ! ( perp. maker_fee( ) , dec!( 0.0001 ) ) ;
978- assert_eq ! ( perp. taker_fee( ) , dec!( 0.0005 ) ) ;
977+ assert_eq ! ( perp. taker_fee( ) , dec!( 0.0003 ) ) ;
979978 assert ! ( !perp. is_inverse( ) ) ;
980979
981980 // `info` mirrors the raw venue payload so downstream consumers can read
@@ -985,9 +984,49 @@ mod tests {
985984 assert_eq ! ( info. get_str( "instrument_name" ) , Some ( "ETH-PERP" ) ) ;
986985 assert_eq ! ( info. get_str( "instrument_type" ) , Some ( "perp" ) ) ;
987986 assert_eq ! ( info. get_str( "base_asset_sub_id" ) , Some ( "0" ) ) ;
987+ // Normalization must not rewrite the raw venue payload.
988+ assert_eq ! ( info. get_str( "quote_currency" ) , Some ( "USD" ) ) ;
988989 assert ! ( info. get( "perp_details" ) . is_some_and( |v| v. is_object( ) ) ) ;
989990 }
990991
992+ #[ rstest]
993+ fn test_parse_perp_instrument_money_flows_settle_in_usdc ( ) {
994+ // Linear notional and PnL come out in cost_currency (= quote), which
995+ // must match the USDC-only account
996+ let instrument = parse_derive_instrument_any ( & perp_fixture ( ) , UnixNanos :: from ( 123 ) )
997+ . unwrap ( )
998+ . unwrap ( ) ;
999+
1000+ let InstrumentAny :: CryptoPerpetual ( perp) = instrument else {
1001+ panic ! ( "expected CryptoPerpetual" ) ;
1002+ } ;
1003+
1004+ let notional =
1005+ perp. calculate_notional_value ( Quantity :: from ( "2" ) , Price :: from ( "3000.00" ) , None ) ;
1006+
1007+ assert ! ( !perp. is_quanto( ) ) ;
1008+ assert_eq ! ( perp. cost_currency( ) , Currency :: USDC ( ) ) ;
1009+ assert_eq ! ( notional. currency, Currency :: USDC ( ) ) ;
1010+ assert_eq ! ( notional. as_decimal( ) , dec!( 6000 ) ) ;
1011+ }
1012+
1013+ #[ rstest]
1014+ fn test_parse_perp_instrument_pins_usdc_for_any_wire_quote ( ) {
1015+ // The USDC pin is unconditional, not gated on the wire saying "USD".
1016+ let mut instrument = perp_fixture ( ) ;
1017+ instrument. quote_currency = "XUSD" . into ( ) ;
1018+
1019+ let parsed = parse_derive_instrument_any ( & instrument, UnixNanos :: from ( 123 ) )
1020+ . unwrap ( )
1021+ . unwrap ( ) ;
1022+ let InstrumentAny :: CryptoPerpetual ( perp) = parsed else {
1023+ panic ! ( "expected CryptoPerpetual" ) ;
1024+ } ;
1025+
1026+ assert_eq ! ( perp. quote_currency( ) , Currency :: USDC ( ) ) ;
1027+ assert_eq ! ( perp. settlement_currency( ) , Currency :: USDC ( ) ) ;
1028+ }
1029+
9911030 #[ rstest]
9921031 fn test_parse_option_instrument ( ) {
9931032 let instrument = parse_derive_instrument_any ( & option_fixture ( ) , UnixNanos :: from ( 456 ) )
@@ -1000,30 +1039,30 @@ mod tests {
10001039
10011040 assert_eq ! (
10021041 option. id( ) ,
1003- InstrumentId :: from( "ETH-20260627 -3500-C.DERIVE" )
1042+ InstrumentId :: from( "ETH-20261225 -3500-C.DERIVE" )
10041043 ) ;
1005- assert_eq ! ( option. raw_symbol( ) . as_str( ) , "ETH-20260627 -3500-C" ) ;
1044+ assert_eq ! ( option. raw_symbol( ) . as_str( ) , "ETH-20261225 -3500-C" ) ;
10061045 assert_eq ! ( option. base_currency( ) , Some ( Currency :: ETH ( ) ) ) ;
10071046 assert_eq ! ( option. quote_currency( ) , Currency :: USDC ( ) ) ;
10081047 assert_eq ! ( option. settlement_currency( ) , Currency :: USDC ( ) ) ;
10091048 assert_eq ! ( option. option_kind( ) , Some ( OptionKind :: Call ) ) ;
10101049 assert_eq ! ( option. strike_price( ) , Some ( Price :: from( "3500" ) ) ) ;
10111050 assert_eq ! (
10121051 option. activation_ns( ) ,
1013- Some ( UnixNanos :: from( 1_700_000_000_000_000_000 ) ) ,
1052+ Some ( UnixNanos :: from( 1_774_598_400_000_000_000 ) ) ,
10141053 ) ;
10151054 assert_eq ! (
10161055 option. expiration_ns( ) ,
1017- Some ( UnixNanos :: from( 1_782_000_000_000_000_000 ) ) ,
1056+ Some ( UnixNanos :: from( 1_798_185_600_000_000_000 ) ) ,
10181057 ) ;
1019- assert_eq ! ( option. price_increment( ) , Price :: from( "1" ) ) ;
1058+ assert_eq ! ( option. price_increment( ) , Price :: from( "0. 1" ) ) ;
10201059 assert_eq ! ( option. size_increment( ) , Quantity :: from( "0.01" ) ) ;
1021- assert_eq ! ( option. max_quantity( ) , Some ( Quantity :: from( "100 " ) ) ) ;
1022- assert_eq ! ( option. min_quantity( ) , Some ( Quantity :: from( "0.01 " ) ) ) ;
1023- assert_eq ! ( option. taker_fee( ) , dec!( 0.001 ) ) ;
1060+ assert_eq ! ( option. max_quantity( ) , Some ( Quantity :: from( "10000 " ) ) ) ;
1061+ assert_eq ! ( option. min_quantity( ) , Some ( Quantity :: from( "0.1 " ) ) ) ;
1062+ assert_eq ! ( option. taker_fee( ) , dec!( 0.0003 ) ) ;
10241063
10251064 let info = option. info . as_ref ( ) . expect ( "info populated" ) ;
1026- assert_eq ! ( info. get_str( "instrument_name" ) , Some ( "ETH-20260627 -3500-C" ) ) ;
1065+ assert_eq ! ( info. get_str( "instrument_name" ) , Some ( "ETH-20261225 -3500-C" ) ) ;
10271066 assert_eq ! ( info. get_str( "instrument_type" ) , Some ( "option" ) ) ;
10281067 let option_details = info. get ( "option_details" ) . expect ( "option_details present" ) ;
10291068 assert_eq ! (
0 commit comments