nautilus_backtest/modules/
fx_rollover.rs1use std::cell::{Cell, RefCell};
19
20use ahash::AHashMap;
21use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
22use chrono_tz::US::Eastern;
23use nautilus_core::UnixNanos;
24use nautilus_model::{
25 data::Data,
26 enums::{AssetClass, PriceType},
27 identifiers::InstrumentId,
28 instruments::Instrument,
29 types::{Currency, Money},
30};
31use rust_decimal::prelude::ToPrimitive;
32use serde::Serialize;
33
34use super::{ExchangeContext, SimulationModule};
35
36const LOCATION_CURRENCY_MAP: &[(&str, &str)] = &[
37 ("AUS", "AUD"),
38 ("CAD", "CAD"),
39 ("CHE", "CHF"),
40 ("EA19", "EUR"),
41 ("USA", "USD"),
42 ("JPN", "JPY"),
43 ("NZL", "NZD"),
44 ("GBR", "GBP"),
45 ("RUS", "RUB"),
46 ("NOR", "NOK"),
47 ("CHN", "CNY"),
48 ("MEX", "MXN"),
49 ("ZAR", "ZAR"),
50];
51
52#[derive(Debug, Clone, Serialize)]
54#[cfg_attr(
55 feature = "python",
56 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.backtest", from_py_object)
57)]
58#[cfg_attr(
59 feature = "python",
60 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
61)]
62pub struct InterestRateRecord {
63 pub location: String,
65 pub time: String,
67 pub value: f64,
69}
70
71#[derive(Debug, Clone)]
76pub struct RolloverInterestCalculator {
77 rates: AHashMap<String, AHashMap<String, f64>>,
79}
80
81impl RolloverInterestCalculator {
82 #[must_use]
84 pub fn new(records: Vec<InterestRateRecord>) -> Self {
85 let location_to_currency: AHashMap<&str, &str> =
86 LOCATION_CURRENCY_MAP.iter().copied().collect();
87
88 let mut rates: AHashMap<String, AHashMap<String, f64>> = AHashMap::new();
89
90 for record in records {
91 if record.location == "CHN" {
93 rates
94 .entry("CNH".to_string())
95 .or_default()
96 .insert(record.time.clone(), record.value);
97 }
98
99 if let Some(¤cy) = location_to_currency.get(record.location.as_str()) {
100 rates
101 .entry(currency.to_string())
102 .or_default()
103 .insert(record.time, record.value);
104 }
105 }
106
107 Self { rates }
108 }
109
110 pub fn calc_overnight_rate(
118 &self,
119 instrument_id: InstrumentId,
120 date: NaiveDate,
121 ) -> anyhow::Result<f64> {
122 let symbol = instrument_id.symbol.as_str();
123 if symbol.len() < 6 {
124 anyhow::bail!("FX symbol must be at least 6 characters: {symbol}");
125 }
126
127 let base_currency = &symbol[..3];
128 let quote_currency = &symbol[symbol.len() - 3..];
129
130 let base_rate = self.lookup_rate(base_currency, date)?;
131 let quote_rate = self.lookup_rate(quote_currency, date)?;
132
133 Ok((base_rate - quote_rate) / 365.0 / 100.0)
134 }
135
136 fn lookup_rate(&self, currency: &str, date: NaiveDate) -> anyhow::Result<f64> {
137 let currency_rates = self
138 .rates
139 .get(currency)
140 .ok_or_else(|| anyhow::anyhow!("No rate data for currency {currency}"))?;
141
142 let monthly_key = format!("{}-{:02}", date.year(), date.month());
144 if let Some(&rate) = currency_rates.get(&monthly_key) {
145 return Ok(rate);
146 }
147
148 let quarter = (date.month() - 1) / 3 + 1;
150 let quarterly_key = format!("{}-Q{quarter}", date.year());
151 if let Some(&rate) = currency_rates.get(&quarterly_key) {
152 return Ok(rate);
153 }
154
155 anyhow::bail!("No rate data for {currency} at {monthly_key} or {quarterly_key}")
156 }
157}
158
159#[derive(Debug, Clone)]
165#[cfg_attr(
166 feature = "python",
167 pyo3::pyclass(
168 module = "nautilus_trader.core.nautilus_pyo3.backtest",
169 unsendable,
170 skip_from_py_object
171 )
172)]
173#[cfg_attr(
174 feature = "python",
175 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
176)]
177pub struct FXRolloverInterestModule {
178 calculator: RolloverInterestCalculator,
179 rollover_time_ns: Cell<u64>,
180 rollover_applied: Cell<bool>,
181 day_number: Cell<u32>,
182 rollover_totals: RefCell<AHashMap<Currency, f64>>,
183}
184
185impl FXRolloverInterestModule {
186 #[must_use]
188 pub fn new(records: Vec<InterestRateRecord>) -> Self {
189 Self {
190 calculator: RolloverInterestCalculator::new(records),
191 rollover_time_ns: Cell::new(0),
192 rollover_applied: Cell::new(false),
193 day_number: Cell::new(0),
194 rollover_totals: RefCell::new(AHashMap::new()),
195 }
196 }
197
198 fn apply_rollover_interest(
199 &self,
200 date: NaiveDate,
201 iso_weekday: u32,
202 ctx: &ExchangeContext,
203 ) -> Vec<Money> {
204 let mut adjustments = Vec::new();
205
206 let mut mid_prices: AHashMap<InstrumentId, f64> = AHashMap::new();
207
208 for (instrument_id, instrument) in ctx.instruments {
209 if instrument.asset_class() != AssetClass::FX {
210 continue;
211 }
212
213 let Some(matching_engine) = ctx.matching_engines.get(instrument_id) else {
214 continue;
215 };
216
217 let book = matching_engine.get_book();
218 let mid = if let Some(m) = book.midpoint() {
219 m
220 } else if let Some(p) = book.best_bid_price() {
221 p.as_f64()
222 } else if let Some(p) = book.best_ask_price() {
223 p.as_f64()
224 } else {
225 continue;
226 };
227 mid_prices.insert(*instrument_id, mid);
228 }
229
230 for (instrument_id, &mid) in &mid_prices {
231 let positions =
232 ctx.cache
233 .positions_open(Some(&ctx.venue), Some(instrument_id), None, None, None);
234
235 if positions.is_empty() {
236 continue;
237 }
238
239 let interest_rate = match self.calculator.calc_overnight_rate(*instrument_id, date) {
240 Ok(rate) => rate,
241 Err(e) => {
242 log::warn!("Skipping rollover for {instrument_id}: {e}");
243 continue;
244 }
245 };
246
247 let net_qty: f64 = positions.iter().map(|p| p.signed_qty).sum();
248
249 let mut rollover = net_qty * mid * interest_rate;
250
251 if iso_weekday == 3 || iso_weekday == 5 {
253 rollover *= 3.0;
254 }
255
256 let instrument = &ctx.instruments[instrument_id];
257 let currency = if let Some(base) = ctx.base_currency {
258 let xrate = ctx
260 .cache
261 .get_xrate(ctx.venue, instrument.quote_currency(), base, PriceType::Mid)
262 .and_then(|rate| rate.to_f64())
263 .unwrap_or(0.0);
264 rollover *= xrate;
265 base
266 } else {
267 instrument.quote_currency()
268 };
269
270 {
271 let mut totals = self.rollover_totals.borrow_mut();
272 let total = totals.entry(currency).or_insert(0.0);
273 *total += rollover;
274 }
275
276 adjustments.push(Money::new(rollover, currency));
277 }
278
279 adjustments
280 }
281}
282
283impl SimulationModule for FXRolloverInterestModule {
284 fn pre_process(&self, _data: &Data) {}
285
286 fn process(&self, ts_now: UnixNanos, ctx: &ExchangeContext) -> Vec<Money> {
287 let utc_dt = nanos_to_utc_datetime(ts_now);
288 let eastern_dt = Eastern.from_utc_datetime(&utc_dt);
289 let eastern_day = eastern_dt.ordinal();
290
291 if self.day_number.get() != eastern_day {
292 self.day_number.set(eastern_day);
293 self.rollover_applied.set(false);
294
295 let rollover_eastern = eastern_dt
296 .date_naive()
297 .and_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap());
298 let rollover_utc = Eastern
299 .from_local_datetime(&rollover_eastern)
300 .single()
301 .unwrap()
302 .naive_utc();
303 let rollover_ns = rollover_utc
304 .and_utc()
305 .timestamp_nanos_opt()
306 .unwrap()
307 .cast_unsigned();
308 self.rollover_time_ns.set(rollover_ns);
309 }
310
311 if !self.rollover_applied.get() && ts_now.as_u64() >= self.rollover_time_ns.get() {
312 let iso_weekday = eastern_dt.weekday().number_from_monday();
313 self.rollover_applied.set(true);
314 return self.apply_rollover_interest(eastern_dt.date_naive(), iso_weekday, ctx);
315 }
316
317 Vec::new()
318 }
319
320 fn log_diagnostics(&self) {
321 let totals = self.rollover_totals.borrow();
322 let parts: Vec<String> = totals
323 .iter()
324 .map(|(currency, total)| {
325 let money = Money::new(*total, *currency);
326 money.to_string()
327 })
328 .collect();
329 log::info!("Rollover interest (totals): {}", parts.join(", "));
330 }
331
332 fn reset(&self) {
333 self.rollover_time_ns.set(0);
334 self.rollover_applied.set(false);
335 self.day_number.set(0);
336 self.rollover_totals.borrow_mut().clear();
337 }
338}
339
340fn nanos_to_utc_datetime(ts: UnixNanos) -> NaiveDateTime {
341 let secs = i64::try_from(ts.as_u64() / 1_000_000_000).expect("timestamp seconds fit in i64");
342 let nanos =
343 u32::try_from(ts.as_u64() % 1_000_000_000).expect("sub-second nanoseconds fit in u32");
344 DateTime::from_timestamp(secs, nanos)
345 .expect("valid timestamp")
346 .naive_utc()
347}
348
349#[cfg(test)]
350mod tests {
351 use nautilus_model::identifiers::InstrumentId;
352 use rstest::rstest;
353 use serde_json::json;
354
355 use super::*;
356
357 fn sample_records() -> Vec<InterestRateRecord> {
358 vec![
359 InterestRateRecord {
360 location: "AUS".into(),
361 time: "2020-Q1".into(),
362 value: 0.75,
363 },
364 InterestRateRecord {
365 location: "USA".into(),
366 time: "2020-Q1".into(),
367 value: 1.50,
368 },
369 InterestRateRecord {
370 location: "JPN".into(),
371 time: "2020-Q1".into(),
372 value: -0.10,
373 },
374 InterestRateRecord {
375 location: "USA".into(),
376 time: "2020-01".into(),
377 value: 1.55,
378 },
379 ]
380 }
381
382 #[rstest]
383 fn test_interest_rate_record_serializes_to_json() {
384 let record = InterestRateRecord {
385 location: "AUS".into(),
386 time: "2020-Q1".into(),
387 value: 0.75,
388 };
389
390 let value = serde_json::to_value(&record).unwrap();
391
392 assert_eq!(
393 value,
394 json!({
395 "location": "AUS",
396 "time": "2020-Q1",
397 "value": 0.75,
398 })
399 );
400 }
401
402 #[rstest]
403 fn test_calculator_quarterly_lookup() {
404 let calc = RolloverInterestCalculator::new(sample_records());
405 let date = NaiveDate::from_ymd_opt(2020, 2, 15).unwrap();
406 let instrument_id = InstrumentId::from("AUDUSD.SIM");
407
408 let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
409
410 let expected = (0.75 - 1.50) / 365.0 / 100.0;
412 assert!((rate - expected).abs() < 1e-12);
413 }
414
415 #[rstest]
416 fn test_calculator_monthly_preferred_over_quarterly() {
417 let calc = RolloverInterestCalculator::new(sample_records());
418 let date = NaiveDate::from_ymd_opt(2020, 1, 15).unwrap();
419 let instrument_id = InstrumentId::from("USDJPY.SIM");
420
421 let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
422
423 let expected = (1.55 - (-0.10)) / 365.0 / 100.0;
425 assert!((rate - expected).abs() < 1e-12);
426 }
427
428 #[rstest]
429 fn test_calculator_missing_currency() {
430 let calc = RolloverInterestCalculator::new(sample_records());
431 let date = NaiveDate::from_ymd_opt(2020, 1, 15).unwrap();
432 let instrument_id = InstrumentId::from("EURGBP.SIM");
433
434 let result = calc.calc_overnight_rate(instrument_id, date);
435 assert!(result.is_err());
436 }
437
438 #[rstest]
439 fn test_module_reset() {
440 let module = FXRolloverInterestModule::new(sample_records());
441 module.day_number.set(15);
442 module.rollover_applied.set(true);
443 module
444 .rollover_totals
445 .borrow_mut()
446 .insert(Currency::USD(), 100.0);
447
448 module.reset();
449
450 assert_eq!(module.day_number.get(), 0);
451 assert!(!module.rollover_applied.get());
452 assert!(module.rollover_totals.borrow().is_empty());
453 }
454}