Skip to main content

nautilus_backtest/modules/
fx_rollover.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! FX rollover interest simulation module.
17
18use 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/// A single interest rate data entry.
53#[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    /// OECD location code (e.g., "AUS", "USA").
64    pub location: String,
65    /// Time period key (e.g., "2024-01" for monthly, "2024-Q1" for quarterly).
66    pub time: String,
67    /// Interest rate value as a percentage (e.g., 5.25 means 5.25%).
68    pub value: f64,
69}
70
71/// Calculates overnight rollover interest rates for FX currency pairs.
72///
73/// Uses short-term interest rate data (OECD format) to compute the daily
74/// differential between base and quote currency rates.
75#[derive(Debug, Clone)]
76pub struct RolloverInterestCalculator {
77    // currency code -> {time_key -> rate_percentage}
78    rates: AHashMap<String, AHashMap<String, f64>>,
79}
80
81impl RolloverInterestCalculator {
82    /// Creates a new calculator from interest rate records.
83    #[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            // CHN maps to both CNY and CNH
92            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(&currency) = 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    /// Calculates the overnight interest rate differential for a currency pair.
111    ///
112    /// Returns `(base_rate - quote_rate) / 365 / 100` as a daily decimal rate.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if rate data is missing for either currency.
117    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        // Try monthly key first
143        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        // Fall back to quarterly key
149        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/// Simulates FX rollover (swap) interest applied at 5 PM US/Eastern daily.
160///
161/// When holding FX positions overnight, the interest rate differential
162/// between the two currencies is credited or debited. Wednesday and Friday
163/// rollovers are tripled (Wednesday for T+2 settlement, Friday for the weekend).
164#[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    /// Creates a new FX rollover interest module.
187    #[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            // Triple for Wednesday (T+2 settlement) and Friday (weekend)
252            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                // Rollover math is still f64; convert the Decimal rate at the boundary
259                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        // (0.75 - 1.50) / 365 / 100 = -0.00002054...
411        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        // Monthly USD rate (1.55) preferred over quarterly (1.50)
424        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}