Skip to content

Commit 8d94bed

Browse files
committed
Fix Derive perp settlement currency and option activation
- Normalize perp quote and settlement currency to USDC (wire is USD) - Convert option scheduled_activation from UNIX seconds, not millis - Correct scheduled_* doc comments and refresh live-shaped fixtures
1 parent e762253 commit 8d94bed

11 files changed

Lines changed: 126 additions & 72 deletions

File tree

RELEASES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ releases as feedback arrives, before the final `2.0.0` release.
4747
- Fixed Redis message bus startup with Python v2 configs (#4356), thanks for reporting @davidgreyme
4848
- Fixed Binance Futures order reports omitting external limit order prices (#4346), thanks for reporting @linimin
4949
- Fixed Binance Futures external algo order materialization (#4348), thanks for reporting @linimin
50+
- Fixed Derive perpetual quote and settlement currency to USDC (venue reports quote as `USD`)
51+
- Fixed Derive option `scheduled_activation` parsing as UNIX seconds (was parsed as milliseconds)
5052
- Fixed Polymarket RTDS retained-subscription recovery after reconnects (#4353), thanks @graceyangfan
5153
- Fixed Tardis replay trades directory to `trades/` for catalog compatibility (#4373), thanks @AdvancedUno
5254
- Fixed Hyperliquid `l2Book` resubscribe options and shared stream teardown (#4298)

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

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@
1818
use std::str::FromStr;
1919

2020
use 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};
2622
use 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-
748746
fn 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!(

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,9 @@ pub struct DeriveInstrument {
202202
pub perp_details: Option<DerivePerpPublicDetails>,
203203
/// Quote currency (e.g. `"USDC"`).
204204
pub quote_currency: Ustr,
205-
/// Scheduled activation timestamp (UNIX ms; 0 if already active).
205+
/// Scheduled activation timestamp (UNIX seconds).
206206
pub scheduled_activation: i64,
207-
/// Scheduled deactivation timestamp (UNIX ms; `i64::MAX` if none).
207+
/// Scheduled deactivation timestamp (UNIX seconds; `i64::MAX` if none).
208208
pub scheduled_deactivation: i64,
209209
/// Taker fee rate (fraction).
210210
#[serde(deserialize_with = "deserialize_decimal")]
@@ -432,9 +432,9 @@ pub struct DeriveTicker {
432432
pub perp_details: Option<DerivePerpPublicDetails>,
433433
/// Quote currency.
434434
pub quote_currency: Ustr,
435-
/// Scheduled activation timestamp (UNIX ms).
435+
/// Scheduled activation timestamp (UNIX seconds).
436436
pub scheduled_activation: i64,
437-
/// Scheduled deactivation timestamp (UNIX ms).
437+
/// Scheduled deactivation timestamp (UNIX seconds; `i64::MAX` if none).
438438
pub scheduled_deactivation: i64,
439439
/// 24-hour rolling statistics. Populated by the WebSocket ticker channel.
440440
#[serde(default, skip_serializing_if = "Option::is_none")]

crates/adapters/derive/test_data/common/http_get_instruments_eth_all.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
"min_rate_per_hour": "-0.01",
2424
"static_interest_rate": "0.0"
2525
},
26-
"quote_currency": "USDC",
27-
"scheduled_activation": 0,
28-
"scheduled_deactivation": 0,
26+
"quote_currency": "USD",
27+
"scheduled_activation": 1701840228,
28+
"scheduled_deactivation": 9223372036854775807,
2929
"taker_fee_rate": "0.0005",
3030
"tick_size": "0.01"
3131
},
@@ -51,8 +51,8 @@
5151
},
5252
"perp_details": null,
5353
"quote_currency": "USDC",
54-
"scheduled_activation": 1700000000000,
55-
"scheduled_deactivation": 0,
54+
"scheduled_activation": 1700000000,
55+
"scheduled_deactivation": 1781999940,
5656
"taker_fee_rate": "0.001",
5757
"tick_size": "1"
5858
}
Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
{
22
"amount_step": "0.01",
3-
"base_asset_address": "0x0000000000000000000000000000000000000001",
4-
"base_asset_sub_id": "42",
3+
"base_asset_address": "0x4BB4C3CDc7562f08e9910A0C7D8bB7e108861eB4",
4+
"base_asset_sub_id": "39614082760370722398570160768",
55
"base_currency": "ETH",
6-
"base_fee": "1",
7-
"instrument_name": "ETH-20260627-3500-C",
6+
"base_fee": "0.5",
7+
"instrument_name": "ETH-20261225-3500-C",
88
"instrument_type": "option",
99
"is_active": true,
10-
"maker_fee_rate": "0",
11-
"mark_price_fee_rate_cap": null,
12-
"maximum_amount": "100",
13-
"minimum_amount": "0.01",
10+
"maker_fee_rate": "0.0001",
11+
"mark_price_fee_rate_cap": "0.125",
12+
"maximum_amount": "10000",
13+
"minimum_amount": "0.1",
1414
"option_details": {
15-
"expiry": 1782000000,
15+
"expiry": 1798185600,
1616
"index": "ETH-USD",
1717
"option_type": "C",
1818
"settlement_price": null,
1919
"strike": "3500"
2020
},
2121
"perp_details": null,
2222
"quote_currency": "USDC",
23-
"scheduled_activation": 1700000000000,
24-
"scheduled_deactivation": 0,
25-
"taker_fee_rate": "0.001",
26-
"tick_size": "1"
23+
"scheduled_activation": 1774598400,
24+
"scheduled_deactivation": 1798185540,
25+
"taker_fee_rate": "0.0003",
26+
"tick_size": "0.1"
2727
}

crates/adapters/derive/test_data/perps/http_get_instrument_eth.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
"min_rate_per_hour": "-0.01",
2323
"static_interest_rate": "0.0"
2424
},
25-
"quote_currency": "USDC",
26-
"scheduled_activation": 0,
27-
"scheduled_deactivation": 0,
25+
"quote_currency": "USD",
26+
"scheduled_activation": 1701840228,
27+
"scheduled_deactivation": 9223372036854775807,
2828
"taker_fee_rate": "0.0005",
2929
"tick_size": "0.01"
3030
}

crates/adapters/derive/test_data/perps/http_get_instruments_eth.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
"min_rate_per_hour": "-0.01",
2424
"static_interest_rate": "0.0"
2525
},
26-
"quote_currency": "USDC",
27-
"scheduled_activation": 0,
28-
"scheduled_deactivation": 0,
26+
"quote_currency": "USD",
27+
"scheduled_activation": 1701840228,
28+
"scheduled_deactivation": 9223372036854775807,
2929
"taker_fee_rate": "0.0005",
3030
"tick_size": "0.01"
3131
}

crates/adapters/derive/test_data/perps/http_ticker_eth_snapshot.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
"min_rate_per_hour": "-0.01",
2929
"static_interest_rate": "0"
3030
},
31-
"quote_currency": "USDC",
32-
"scheduled_activation": 0,
33-
"scheduled_deactivation": 0,
31+
"quote_currency": "USD",
32+
"scheduled_activation": 1701840228,
33+
"scheduled_deactivation": 9223372036854775807,
3434
"stats": {
3535
"contract_volume": "12345.6",
3636
"high": "3600",
Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
{
22
"amount_step": "0.001",
3-
"base_asset_address": "0x0000000000000000000000000000000000000001",
3+
"base_asset_address": "0xAf65752C4643E25C02F693f9D4FE19cF23a095E3",
44
"base_asset_sub_id": "0",
55
"base_currency": "ETH",
6-
"base_fee": "0.5",
6+
"base_fee": "0.01",
77
"instrument_name": "ETH-PERP",
88
"instrument_type": "perp",
99
"is_active": true,
1010
"maker_fee_rate": "0.0001",
11-
"mark_price_fee_rate_cap": null,
12-
"maximum_amount": "1000",
13-
"minimum_amount": "0.001",
11+
"mark_price_fee_rate_cap": "0",
12+
"maximum_amount": "10000",
13+
"minimum_amount": "0.1",
1414
"option_details": null,
1515
"perp_details": {
16-
"aggregate_funding": "1.5",
17-
"funding_rate": "0.0001",
16+
"aggregate_funding": "1020.924990439907504194",
17+
"funding_rate": "0.0000125",
1818
"index": "ETH-USD",
19-
"max_rate_per_hour": "0.01",
20-
"min_rate_per_hour": "-0.01",
21-
"static_interest_rate": "0.0"
19+
"max_rate_per_hour": "0.004",
20+
"min_rate_per_hour": "-0.004",
21+
"static_interest_rate": "0.0000125"
2222
},
23-
"quote_currency": "USDC",
24-
"scheduled_activation": 0,
25-
"scheduled_deactivation": 0,
26-
"taker_fee_rate": "0.0005",
23+
"quote_currency": "USD",
24+
"scheduled_activation": 1701840228,
25+
"scheduled_deactivation": 9223372036854775807,
26+
"taker_fee_rate": "0.0003",
2727
"tick_size": "0.01"
2828
}

crates/adapters/derive/test_data/perps/ws_ticker_eth.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@
2929
"min_rate_per_hour": "-0.01",
3030
"static_interest_rate": "0"
3131
},
32-
"quote_currency": "USDC",
33-
"scheduled_activation": 0,
34-
"scheduled_deactivation": 0,
32+
"quote_currency": "USD",
33+
"scheduled_activation": 1701840228,
34+
"scheduled_deactivation": 9223372036854775807,
3535
"stats": {
3636
"contract_volume": "12345.6",
3737
"high": "3600",

0 commit comments

Comments
 (0)