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    position::Position,
30    types::{Currency, Money},
31};
32
33use super::{ExchangeContext, SimulationModule};
34
35const LOCATION_CURRENCY_MAP: &[(&str, &str)] = &[
36    ("AUS", "AUD"),
37    ("CAD", "CAD"),
38    ("CHE", "CHF"),
39    ("EA19", "EUR"),
40    ("USA", "USD"),
41    ("JPN", "JPY"),
42    ("NZL", "NZD"),
43    ("GBR", "GBP"),
44    ("RUS", "RUB"),
45    ("NOR", "NOK"),
46    ("CHN", "CNY"),
47    ("MEX", "MXN"),
48    ("ZAR", "ZAR"),
49];
50
51/// A single interest rate data entry.
52#[derive(Debug, Clone)]
53#[cfg_attr(
54    feature = "python",
55    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.backtest", from_py_object)
56)]
57#[cfg_attr(
58    feature = "python",
59    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
60)]
61pub struct InterestRateRecord {
62    /// OECD location code (e.g., "AUS", "USA").
63    pub location: String,
64    /// Time period key (e.g., "2024-01" for monthly, "2024-Q1" for quarterly).
65    pub time: String,
66    /// Interest rate value as a percentage (e.g., 5.25 means 5.25%).
67    pub value: f64,
68}
69
70/// Calculates overnight rollover interest rates for FX currency pairs.
71///
72/// Uses short-term interest rate data (OECD format) to compute the daily
73/// differential between base and quote currency rates.
74#[derive(Debug, Clone)]
75pub struct RolloverInterestCalculator {
76    // currency code -> {time_key -> rate_percentage}
77    rates: AHashMap<String, AHashMap<String, f64>>,
78}
79
80impl RolloverInterestCalculator {
81    /// Creates a new calculator from interest rate records.
82    pub fn new(records: Vec<InterestRateRecord>) -> Self {
83        let location_to_currency: AHashMap<&str, &str> =
84            LOCATION_CURRENCY_MAP.iter().copied().collect();
85
86        let mut rates: AHashMap<String, AHashMap<String, f64>> = AHashMap::new();
87
88        for record in records {
89            // CHN maps to both CNY and CNH
90            if record.location == "CHN" {
91                rates
92                    .entry("CNH".to_string())
93                    .or_default()
94                    .insert(record.time.clone(), record.value);
95            }
96
97            if let Some(&currency) = location_to_currency.get(record.location.as_str()) {
98                rates
99                    .entry(currency.to_string())
100                    .or_default()
101                    .insert(record.time, record.value);
102            }
103        }
104
105        Self { rates }
106    }
107
108    /// Calculates the overnight interest rate differential for a currency pair.
109    ///
110    /// Returns `(base_rate - quote_rate) / 365 / 100` as a daily decimal rate.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if rate data is missing for either currency.
115    pub fn calc_overnight_rate(
116        &self,
117        instrument_id: InstrumentId,
118        date: NaiveDate,
119    ) -> anyhow::Result<f64> {
120        let symbol = instrument_id.symbol.as_str();
121        if symbol.len() < 6 {
122            anyhow::bail!("FX symbol must be at least 6 characters: {symbol}");
123        }
124
125        let base_currency = &symbol[..3];
126        let quote_currency = &symbol[symbol.len() - 3..];
127
128        let base_rate = self.lookup_rate(base_currency, date)?;
129        let quote_rate = self.lookup_rate(quote_currency, date)?;
130
131        Ok((base_rate - quote_rate) / 365.0 / 100.0)
132    }
133
134    fn lookup_rate(&self, currency: &str, date: NaiveDate) -> anyhow::Result<f64> {
135        let currency_rates = self
136            .rates
137            .get(currency)
138            .ok_or_else(|| anyhow::anyhow!("No rate data for currency {currency}"))?;
139
140        // Try monthly key first
141        let monthly_key = format!("{}-{:02}", date.year(), date.month());
142        if let Some(&rate) = currency_rates.get(&monthly_key) {
143            return Ok(rate);
144        }
145
146        // Fall back to quarterly key
147        let quarter = (date.month() - 1) / 3 + 1;
148        let quarterly_key = format!("{}-Q{quarter}", date.year());
149        if let Some(&rate) = currency_rates.get(&quarterly_key) {
150            return Ok(rate);
151        }
152
153        anyhow::bail!("No rate data for {currency} at {monthly_key} or {quarterly_key}")
154    }
155}
156
157/// Simulates FX rollover (swap) interest applied at 5 PM US/Eastern daily.
158///
159/// When holding FX positions overnight, the interest rate differential
160/// between the two currencies is credited or debited. Wednesday and Friday
161/// rollovers are tripled (Wednesday for T+2 settlement, Friday for the weekend).
162#[derive(Debug, Clone)]
163#[cfg_attr(
164    feature = "python",
165    pyo3::pyclass(
166        module = "nautilus_trader.core.nautilus_pyo3.backtest",
167        unsendable,
168        skip_from_py_object
169    )
170)]
171#[cfg_attr(
172    feature = "python",
173    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
174)]
175pub struct FXRolloverInterestModule {
176    calculator: RolloverInterestCalculator,
177    rollover_time_ns: Cell<u64>,
178    rollover_applied: Cell<bool>,
179    day_number: Cell<u32>,
180    rollover_totals: RefCell<AHashMap<Currency, f64>>,
181}
182
183impl FXRolloverInterestModule {
184    /// Creates a new FX rollover interest module.
185    pub fn new(records: Vec<InterestRateRecord>) -> Self {
186        Self {
187            calculator: RolloverInterestCalculator::new(records),
188            rollover_time_ns: Cell::new(0),
189            rollover_applied: Cell::new(false),
190            day_number: Cell::new(0),
191            rollover_totals: RefCell::new(AHashMap::new()),
192        }
193    }
194
195    fn apply_rollover_interest(
196        &self,
197        date: NaiveDate,
198        iso_weekday: u32,
199        ctx: &ExchangeContext,
200    ) -> Vec<Money> {
201        let mut adjustments = Vec::new();
202
203        let mut mid_prices: AHashMap<InstrumentId, f64> = AHashMap::new();
204
205        for (instrument_id, instrument) in ctx.instruments {
206            if instrument.asset_class() != AssetClass::FX {
207                continue;
208            }
209
210            let matching_engine = match ctx.matching_engines.get(instrument_id) {
211                Some(engine) => engine,
212                None => continue,
213            };
214
215            let book = matching_engine.get_book();
216            let mid = if let Some(m) = book.midpoint() {
217                m
218            } else if let Some(p) = book.best_bid_price() {
219                p.as_f64()
220            } else if let Some(p) = book.best_ask_price() {
221                p.as_f64()
222            } else {
223                continue;
224            };
225            mid_prices.insert(*instrument_id, mid);
226        }
227
228        for (instrument_id, &mid) in &mid_prices {
229            let positions: Vec<&Position> =
230                ctx.cache
231                    .positions_open(Some(&ctx.venue), Some(instrument_id), None, None, None);
232
233            if positions.is_empty() {
234                continue;
235            }
236
237            let interest_rate = match self.calculator.calc_overnight_rate(*instrument_id, date) {
238                Ok(rate) => rate,
239                Err(e) => {
240                    log::warn!("Skipping rollover for {instrument_id}: {e}");
241                    continue;
242                }
243            };
244
245            let net_qty: f64 = positions.iter().map(|p| p.signed_qty).sum();
246
247            let mut rollover = net_qty * mid * interest_rate;
248
249            // Triple for Wednesday (T+2 settlement) and Friday (weekend)
250            if iso_weekday == 3 || iso_weekday == 5 {
251                rollover *= 3.0;
252            }
253
254            let instrument = &ctx.instruments[instrument_id];
255            let currency = if let Some(base) = ctx.base_currency {
256                let xrate = ctx
257                    .cache
258                    .get_xrate(ctx.venue, instrument.quote_currency(), base, PriceType::Mid)
259                    .unwrap_or(0.0);
260                rollover *= xrate;
261                base
262            } else {
263                instrument.quote_currency()
264            };
265
266            {
267                let mut totals = self.rollover_totals.borrow_mut();
268                let total = totals.entry(currency).or_insert(0.0);
269                *total += rollover;
270            }
271
272            adjustments.push(Money::new(rollover, currency));
273        }
274
275        adjustments
276    }
277}
278
279impl SimulationModule for FXRolloverInterestModule {
280    fn pre_process(&self, _data: &Data) {}
281
282    fn process(&self, ts_now: UnixNanos, ctx: &ExchangeContext) -> Vec<Money> {
283        let utc_dt = nanos_to_utc_datetime(ts_now);
284        let eastern_dt = Eastern.from_utc_datetime(&utc_dt);
285        let eastern_day = eastern_dt.ordinal();
286
287        if self.day_number.get() != eastern_day {
288            self.day_number.set(eastern_day);
289            self.rollover_applied.set(false);
290
291            let rollover_eastern = eastern_dt
292                .date_naive()
293                .and_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap());
294            let rollover_utc = Eastern
295                .from_local_datetime(&rollover_eastern)
296                .single()
297                .unwrap()
298                .naive_utc();
299            let rollover_ns = rollover_utc.and_utc().timestamp_nanos_opt().unwrap() as u64;
300            self.rollover_time_ns.set(rollover_ns);
301        }
302
303        if !self.rollover_applied.get() && ts_now.as_u64() >= self.rollover_time_ns.get() {
304            let iso_weekday = eastern_dt.weekday().number_from_monday();
305            self.rollover_applied.set(true);
306            return self.apply_rollover_interest(eastern_dt.date_naive(), iso_weekday, ctx);
307        }
308
309        Vec::new()
310    }
311
312    fn log_diagnostics(&self) {
313        let totals = self.rollover_totals.borrow();
314        let parts: Vec<String> = totals
315            .iter()
316            .map(|(currency, total)| {
317                let money = Money::new(*total, *currency);
318                money.to_string()
319            })
320            .collect();
321        log::info!("Rollover interest (totals): {}", parts.join(", "));
322    }
323
324    fn reset(&self) {
325        self.rollover_time_ns.set(0);
326        self.rollover_applied.set(false);
327        self.day_number.set(0);
328        self.rollover_totals.borrow_mut().clear();
329    }
330}
331
332fn nanos_to_utc_datetime(ts: UnixNanos) -> NaiveDateTime {
333    let secs = (ts.as_u64() / 1_000_000_000) as i64;
334    let nanos = (ts.as_u64() % 1_000_000_000) as u32;
335    DateTime::from_timestamp(secs, nanos)
336        .expect("valid timestamp")
337        .naive_utc()
338}
339
340#[cfg(test)]
341mod tests {
342    use nautilus_model::identifiers::InstrumentId;
343    use rstest::rstest;
344
345    use super::*;
346
347    fn sample_records() -> Vec<InterestRateRecord> {
348        vec![
349            InterestRateRecord {
350                location: "AUS".into(),
351                time: "2020-Q1".into(),
352                value: 0.75,
353            },
354            InterestRateRecord {
355                location: "USA".into(),
356                time: "2020-Q1".into(),
357                value: 1.50,
358            },
359            InterestRateRecord {
360                location: "JPN".into(),
361                time: "2020-Q1".into(),
362                value: -0.10,
363            },
364            InterestRateRecord {
365                location: "USA".into(),
366                time: "2020-01".into(),
367                value: 1.55,
368            },
369        ]
370    }
371
372    #[rstest]
373    fn test_calculator_quarterly_lookup() {
374        let calc = RolloverInterestCalculator::new(sample_records());
375        let date = NaiveDate::from_ymd_opt(2020, 2, 15).unwrap();
376        let instrument_id = InstrumentId::from("AUDUSD.SIM");
377
378        let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
379
380        // (0.75 - 1.50) / 365 / 100 = -0.00002054...
381        let expected = (0.75 - 1.50) / 365.0 / 100.0;
382        assert!((rate - expected).abs() < 1e-12);
383    }
384
385    #[rstest]
386    fn test_calculator_monthly_preferred_over_quarterly() {
387        let calc = RolloverInterestCalculator::new(sample_records());
388        let date = NaiveDate::from_ymd_opt(2020, 1, 15).unwrap();
389        let instrument_id = InstrumentId::from("USDJPY.SIM");
390
391        let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
392
393        // Monthly USD rate (1.55) preferred over quarterly (1.50)
394        let expected = (1.55 - (-0.10)) / 365.0 / 100.0;
395        assert!((rate - expected).abs() < 1e-12);
396    }
397
398    #[rstest]
399    fn test_calculator_missing_currency() {
400        let calc = RolloverInterestCalculator::new(sample_records());
401        let date = NaiveDate::from_ymd_opt(2020, 1, 15).unwrap();
402        let instrument_id = InstrumentId::from("EURGBP.SIM");
403
404        let result = calc.calc_overnight_rate(instrument_id, date);
405        assert!(result.is_err());
406    }
407
408    #[rstest]
409    fn test_module_reset() {
410        let module = FXRolloverInterestModule::new(sample_records());
411        module.day_number.set(15);
412        module.rollover_applied.set(true);
413        module
414            .rollover_totals
415            .borrow_mut()
416            .insert(Currency::USD(), 100.0);
417
418        module.reset();
419
420        assert_eq!(module.day_number.get(), 0);
421        assert!(!module.rollover_applied.get());
422        assert!(module.rollover_totals.borrow().is_empty());
423    }
424}