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::{
19    cell::{Cell, RefCell},
20    sync::LazyLock,
21};
22
23use ahash::{AHashMap, AHashSet};
24use jiff::{
25    civil::{Date, Time},
26    tz::TimeZone,
27};
28use nautilus_core::{UnixNanos, datetime::get_timezone};
29use nautilus_model::{
30    data::Data,
31    enums::{AssetClass, PriceType},
32    identifiers::InstrumentId,
33    instruments::Instrument,
34    types::{Currency, Money},
35};
36use rust_decimal::prelude::ToPrimitive;
37use serde::Serialize;
38
39use super::{
40    AccountAdjustmentError, AccountAdjustmentOutcome, ExchangeContext, SimulationModule,
41    SimulationModuleResult,
42};
43#[cfg(feature = "python")]
44use crate::python::modules::PySimulationModule;
45
46const LOCATION_CURRENCY_MAP: &[(&str, &str)] = &[
47    ("AUS", "AUD"),
48    ("CAN", "CAD"),
49    ("CHE", "CHF"),
50    ("EA19", "EUR"),
51    ("USA", "USD"),
52    ("JPN", "JPY"),
53    ("NZL", "NZD"),
54    ("GBR", "GBP"),
55    ("RUS", "RUB"),
56    ("NOR", "NOK"),
57    ("CHN", "CNY"),
58    ("MEX", "MXN"),
59    ("ZAF", "ZAR"),
60];
61
62static EASTERN_TIMEZONE: LazyLock<TimeZone> =
63    LazyLock::new(|| get_timezone("America/New_York").expect("bundled America/New_York timezone"));
64
65fn eastern_timezone() -> &'static TimeZone {
66    &EASTERN_TIMEZONE
67}
68
69/// A single interest rate data entry.
70#[derive(Debug, Clone, Serialize)]
71#[cfg_attr(
72    feature = "python",
73    pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object)
74)]
75#[cfg_attr(
76    feature = "python",
77    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
78)]
79pub struct InterestRateRecord {
80    /// OECD location code using ISO 3166 alpha-3 (e.g., "AUS", "USA") or "EA19".
81    /// Records with unsupported codes are ignored.
82    pub location: String,
83    /// Time period key (e.g., "2024-01" for monthly, "2024-Q1" for quarterly).
84    pub time: String,
85    /// Interest rate value as a percentage (e.g., 5.25 means 5.25%). Must be finite.
86    pub value: f64,
87}
88
89impl InterestRateRecord {
90    pub(crate) fn validate(&self) -> anyhow::Result<()> {
91        anyhow::ensure!(
92            self.value.is_finite(),
93            "Interest rate for location '{}' at '{}' must be finite, was {}",
94            self.location,
95            self.time,
96            self.value
97        );
98        Ok(())
99    }
100}
101
102/// Calculates overnight rollover interest rates for FX currency pairs.
103///
104/// Uses short-term interest rate data (OECD format) to compute the daily
105/// differential between base and quote currency rates.
106#[derive(Debug, Clone)]
107pub struct RolloverInterestCalculator {
108    // currency code -> {time_key -> rate_percentage}
109    rates: AHashMap<String, AHashMap<String, f64>>,
110}
111
112impl RolloverInterestCalculator {
113    /// Creates a new calculator from interest rate records.
114    ///
115    /// Records with unsupported location codes are ignored. "CHN" supplies both CNY and CNH;
116    /// later records replace earlier records for the same currency and time.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if any interest rate is not finite.
121    pub fn new(records: Vec<InterestRateRecord>) -> anyhow::Result<Self> {
122        let location_to_currency: AHashMap<&str, &str> =
123            LOCATION_CURRENCY_MAP.iter().copied().collect();
124
125        let mut rates: AHashMap<String, AHashMap<String, f64>> = AHashMap::new();
126
127        for record in records {
128            record.validate()?;
129
130            // CHN maps to both CNY and CNH
131            if record.location == "CHN" {
132                rates
133                    .entry("CNH".to_string())
134                    .or_default()
135                    .insert(record.time.clone(), record.value);
136            }
137
138            if let Some(&currency) = location_to_currency.get(record.location.as_str()) {
139                rates
140                    .entry(currency.to_string())
141                    .or_default()
142                    .insert(record.time, record.value);
143            }
144        }
145
146        Ok(Self { rates })
147    }
148
149    /// Calculates the overnight interest rate differential for a currency pair.
150    ///
151    /// Returns `(base_rate - quote_rate) / 365 / 100` as a daily decimal rate.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if rate data is missing for either currency.
156    pub fn calc_overnight_rate(
157        &self,
158        instrument_id: InstrumentId,
159        date: Date,
160    ) -> anyhow::Result<f64> {
161        let symbol = instrument_id.symbol.as_str();
162        if symbol.len() < 6 {
163            anyhow::bail!("FX symbol must be at least 6 characters: {symbol}");
164        }
165
166        let base_currency = &symbol[..3];
167        let quote_currency = &symbol[symbol.len() - 3..];
168
169        let base_rate = self.lookup_rate(base_currency, date)?;
170        let quote_rate = self.lookup_rate(quote_currency, date)?;
171
172        Ok((base_rate - quote_rate) / 365.0 / 100.0)
173    }
174
175    fn lookup_rate(&self, currency: &str, date: Date) -> anyhow::Result<f64> {
176        let currency_rates = self
177            .rates
178            .get(currency)
179            .ok_or_else(|| anyhow::anyhow!("No rate data for currency {currency}"))?;
180
181        // Try monthly key first
182        let monthly_key = format!("{}-{:02}", date.year(), date.month());
183        if let Some(&rate) = currency_rates.get(&monthly_key) {
184            return Ok(rate);
185        }
186
187        // Fall back to quarterly key
188        let quarter = (date.month() - 1) / 3 + 1;
189        let quarterly_key = format!("{}-Q{quarter}", date.year());
190        if let Some(&rate) = currency_rates.get(&quarterly_key) {
191            return Ok(rate);
192        }
193
194        anyhow::bail!("No rate data for {currency} at {monthly_key} or {quarterly_key}")
195    }
196}
197
198/// Simulates FX rollover (swap) interest applied at 5 PM US/Eastern daily.
199///
200/// When holding FX positions overnight, the interest rate differential
201/// between the two currencies is credited or debited. Wednesday and Friday
202/// rollovers are tripled (Wednesday for T+2 settlement, Friday for the weekend).
203#[derive(Debug, Clone)]
204#[cfg_attr(
205    feature = "python",
206    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
207)]
208#[cfg_attr(
209    feature = "python",
210    pyo3::pyclass(
211        module = "nautilus_trader.backtest",
212        extends = PySimulationModule,
213        unsendable,
214        skip_from_py_object
215    )
216)]
217pub struct FXRolloverInterestModule {
218    calculator: RolloverInterestCalculator,
219    rollover_completed: Cell<bool>,
220    rollover_day: RefCell<Option<RolloverDayState>>,
221    rollover_totals: RefCell<AHashMap<Currency, f64>>,
222    unapplied_rollover_totals: RefCell<AHashMap<Currency, f64>>,
223}
224
225#[derive(Debug, Clone)]
226struct RolloverDayState {
227    date: Date,
228    warned_failures: AHashSet<(Date, InstrumentId, RolloverFailureKind)>,
229    warned_adjustment_failures: AHashSet<(Date, Currency, AccountAdjustmentFailureKind)>,
230    pending_adjustments: Option<Vec<RolloverAdjustment>>,
231    pending_end_date: Option<Date>,
232    attempt_time: Option<UnixNanos>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236struct RolloverAdjustment {
237    booking_date: Date,
238    amount: Money,
239}
240
241enum RolloverCalculationOutcome {
242    Completed(Vec<Money>),
243    Retry,
244}
245
246#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247enum RolloverFailureDisposition {
248    RetryDay,
249    SkipInstrument,
250}
251
252#[derive(Clone, Copy, Debug, PartialEq, Eq)]
253enum AccountAdjustmentFailureDisposition {
254    Retry,
255    RecordUnapplied,
256}
257
258#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
259enum RolloverFailureKind {
260    Engine,
261    Money,
262    Price,
263    Rate,
264    Xrate,
265}
266
267impl RolloverFailureKind {
268    const fn disposition(self) -> RolloverFailureDisposition {
269        match self {
270            Self::Engine | Self::Price | Self::Xrate => RolloverFailureDisposition::RetryDay,
271            Self::Money | Self::Rate => RolloverFailureDisposition::SkipInstrument,
272        }
273    }
274}
275
276#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
277enum AccountAdjustmentFailureKind {
278    TotalOverflow,
279    FreeBalanceOverflow,
280    MissingBalance,
281    MissingAccount,
282    AccountStateGeneration,
283}
284
285impl From<&AccountAdjustmentError> for AccountAdjustmentFailureKind {
286    fn from(error: &AccountAdjustmentError) -> Self {
287        match error {
288            AccountAdjustmentError::TotalOverflow(_) => Self::TotalOverflow,
289            AccountAdjustmentError::FreeBalanceOverflow(_) => Self::FreeBalanceOverflow,
290            AccountAdjustmentError::MissingBalance(_) => Self::MissingBalance,
291            AccountAdjustmentError::MissingAccount(_) => Self::MissingAccount,
292            AccountAdjustmentError::AccountStateGeneration(_) => Self::AccountStateGeneration,
293        }
294    }
295}
296
297impl AccountAdjustmentFailureKind {
298    const fn disposition(self) -> AccountAdjustmentFailureDisposition {
299        match self {
300            Self::TotalOverflow | Self::FreeBalanceOverflow | Self::AccountStateGeneration => {
301                AccountAdjustmentFailureDisposition::Retry
302            }
303            Self::MissingBalance | Self::MissingAccount => {
304                AccountAdjustmentFailureDisposition::RecordUnapplied
305            }
306        }
307    }
308}
309
310impl FXRolloverInterestModule {
311    /// Creates a new FX rollover interest module.
312    ///
313    /// Records with unsupported location codes are ignored.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if any interest rate is not finite.
318    pub fn new(records: Vec<InterestRateRecord>) -> anyhow::Result<Self> {
319        Ok(Self {
320            calculator: RolloverInterestCalculator::new(records)?,
321            rollover_completed: Cell::new(false),
322            rollover_day: RefCell::new(None),
323            rollover_totals: RefCell::new(AHashMap::new()),
324            unapplied_rollover_totals: RefCell::new(AHashMap::new()),
325        })
326    }
327
328    fn initialize_rollover_day(&self, date: Date) {
329        self.rollover_day.replace(Some(RolloverDayState {
330            date,
331            warned_failures: AHashSet::new(),
332            warned_adjustment_failures: AHashSet::new(),
333            pending_adjustments: None,
334            pending_end_date: None,
335            attempt_time: None,
336        }));
337        self.rollover_completed.set(false);
338    }
339
340    fn rollover_time_ns(date: Date) -> u64 {
341        let rollover_eastern = date.to_datetime(Time::constant(17, 0, 0, 0));
342        let timestamp = eastern_timezone()
343            .to_ambiguous_timestamp(rollover_eastern)
344            .unambiguous()
345            .expect("unambiguous rollover time")
346            .as_nanosecond();
347        u64::try_from(timestamp).expect("rollover timestamp in range")
348    }
349
350    fn weekday_on_or_before(mut date: Date) -> Date {
351        while date.weekday().to_monday_one_offset() > 5 {
352            date = date.yesterday().expect("previous rollover date in range");
353        }
354        date
355    }
356
357    fn next_weekday(mut date: Date) -> Date {
358        loop {
359            date = date.tomorrow().expect("next rollover date in range");
360            if date.weekday().to_monday_one_offset() <= 5 {
361                return date;
362            }
363        }
364    }
365
366    /// Logs a calculation failure at warn level once per (booking date, instrument,
367    /// kind), demoting repeats to debug: a `Retry` outcome re-runs the calculation
368    /// on every process call until it completes, and repeating the identical
369    /// warning per attempt would flood the log. The booking date is part of the key
370    /// because one catch-up batch spans many dates, and a permanent per-instrument
371    /// skip must stay visible for each date it drops rather than warning only for
372    /// the first. The set is cleared on a new day, on completion, and on reset.
373    fn log_calculation_failure(
374        &self,
375        booking_date: Date,
376        instrument_id: InstrumentId,
377        kind: RolloverFailureKind,
378        message: &str,
379    ) {
380        let first_failure = self
381            .rollover_day
382            .borrow_mut()
383            .as_mut()
384            .expect("rollover day initialized")
385            .warned_failures
386            .insert((booking_date, instrument_id, kind));
387
388        if first_failure {
389            log::warn!("{message}");
390        } else {
391            log::debug!("{message}");
392        }
393    }
394
395    fn calculate_rollover_interest(
396        &self,
397        date: Date,
398        iso_weekday: i8,
399        ctx: &ExchangeContext,
400    ) -> RolloverCalculationOutcome {
401        let mut instrument_ids = ctx.instruments.keys().copied().collect::<Vec<_>>();
402        instrument_ids.sort_unstable();
403        let mut adjustments = Vec::new();
404
405        for instrument_id in instrument_ids {
406            let instrument = &ctx.instruments[&instrument_id];
407
408            if instrument.asset_class() != AssetClass::FX {
409                continue;
410            }
411
412            let positions =
413                ctx.cache
414                    .positions_open(Some(&ctx.venue), Some(&instrument_id), None, None, None);
415
416            if positions.is_empty() {
417                continue;
418            }
419
420            // Look up the immutable rate data before any transient market
421            // inputs: a permanently missing rate must skip the instrument
422            // even when the engine or price would first retry the day.
423            let interest_rate = match self.calculator.calc_overnight_rate(instrument_id, date) {
424                Ok(rate) => rate,
425                Err(e) => {
426                    let kind = RolloverFailureKind::Rate;
427                    self.log_calculation_failure(
428                        date,
429                        instrument_id,
430                        kind,
431                        &format!("Skipping rollover for {instrument_id} on {date}: {e}"),
432                    );
433
434                    match kind.disposition() {
435                        RolloverFailureDisposition::RetryDay => {
436                            return RolloverCalculationOutcome::Retry;
437                        }
438                        RolloverFailureDisposition::SkipInstrument => continue,
439                    }
440                }
441            };
442
443            let Some(matching_engine) = ctx.matching_engines.get(&instrument_id) else {
444                self.log_calculation_failure(
445                    date,
446                    instrument_id,
447                    RolloverFailureKind::Engine,
448                    &format!("Cannot calculate rollover for {instrument_id}: no matching engine"),
449                );
450                return RolloverCalculationOutcome::Retry;
451            };
452            let book = matching_engine.get_book();
453            let mid = if let Some(mid) = book.midpoint() {
454                mid
455            } else if let Some(price) = book.best_bid_price() {
456                price.as_f64()
457            } else if let Some(price) = book.best_ask_price() {
458                price.as_f64()
459            } else {
460                self.log_calculation_failure(
461                    date,
462                    instrument_id,
463                    RolloverFailureKind::Price,
464                    &format!("Cannot calculate rollover for {instrument_id}: no market price"),
465                );
466                return RolloverCalculationOutcome::Retry;
467            };
468
469            let net_qty: f64 = positions.iter().map(|p| p.signed_qty).sum();
470
471            let mut rollover = net_qty * mid * interest_rate;
472
473            // Triple for Wednesday (T+2 settlement) and Friday (weekend)
474            if iso_weekday == 3 || iso_weekday == 5 {
475                rollover *= 3.0;
476            }
477
478            let currency = if let Some(base) = ctx.base_currency {
479                // Rollover math is still f64; convert the Decimal rate at the boundary
480                let xrate_result = ctx.cache.try_get_xrate(
481                    ctx.venue,
482                    instrument.quote_currency(),
483                    base,
484                    PriceType::Mid,
485                );
486                let xrate = match xrate_result {
487                    Ok(Some(rate)) => rate.to_f64(),
488                    Ok(None) => None,
489                    Err(e) => {
490                        self.log_calculation_failure(
491                            date,
492                            instrument_id,
493                            RolloverFailureKind::Xrate,
494                            &format!(
495                                "Cannot calculate rollover for {instrument_id}: exchange rate from {} to {base}: {e}",
496                                instrument.quote_currency()
497                            ),
498                        );
499                        return RolloverCalculationOutcome::Retry;
500                    }
501                };
502                let Some(xrate) = xrate else {
503                    self.log_calculation_failure(
504                        date,
505                        instrument_id,
506                        RolloverFailureKind::Xrate,
507                        &format!(
508                            "Cannot calculate rollover for {instrument_id}: no exchange rate from {} to {base}",
509                            instrument.quote_currency()
510                        ),
511                    );
512                    return RolloverCalculationOutcome::Retry;
513                };
514                rollover *= xrate;
515                base
516            } else {
517                instrument.quote_currency()
518            };
519
520            let adjustment = match Money::new_checked(rollover, currency) {
521                Ok(adjustment) => adjustment,
522                Err(e) => {
523                    let kind = RolloverFailureKind::Money;
524                    self.log_calculation_failure(
525                        date,
526                        instrument_id,
527                        kind,
528                        &format!(
529                            "Skipping rollover for {instrument_id} on {date}: invalid adjustment: {e}"
530                        ),
531                    );
532
533                    match kind.disposition() {
534                        RolloverFailureDisposition::RetryDay => {
535                            return RolloverCalculationOutcome::Retry;
536                        }
537                        RolloverFailureDisposition::SkipInstrument => continue,
538                    }
539                }
540            };
541
542            adjustments.push(adjustment);
543        }
544
545        RolloverCalculationOutcome::Completed(adjustments)
546    }
547}
548
549impl SimulationModule for FXRolloverInterestModule {
550    fn pre_process(&self, _data: &Data) -> anyhow::Result<()> {
551        Ok(())
552    }
553
554    fn process(
555        &self,
556        ts_now: UnixNanos,
557        ctx: &ExchangeContext,
558    ) -> anyhow::Result<SimulationModuleResult> {
559        let eastern_dt = ts_now
560            .to_datetime_utc()
561            .to_zoned(eastern_timezone().clone());
562        let observed_date = eastern_dt.date();
563
564        let initialize_date = {
565            let day = self.rollover_day.borrow();
566            match day.as_ref() {
567                None => Some(Self::weekday_on_or_before(observed_date)),
568                Some(day) if self.rollover_completed.get() && day.date < observed_date => {
569                    Some(Self::next_weekday(day.date))
570                }
571                Some(_) => None,
572            }
573        };
574
575        if let Some(date) = initialize_date {
576            self.initialize_rollover_day(date);
577        }
578
579        if self.rollover_completed.get() {
580            return Ok(SimulationModuleResult::NotReady);
581        }
582
583        {
584            let mut day = self.rollover_day.borrow_mut();
585            let day = day.as_mut().expect("rollover day initialized");
586            if let Some(adjustments) = &day.pending_adjustments {
587                let adjustments = adjustments
588                    .iter()
589                    .map(|adjustment| adjustment.amount)
590                    .collect();
591                day.attempt_time = Some(ts_now);
592                return Ok(SimulationModuleResult::Completed(adjustments));
593            }
594        }
595
596        let date = {
597            let day = self.rollover_day.borrow();
598            let day = day.as_ref().expect("rollover day initialized");
599            day.date
600        };
601
602        if ts_now.as_u64() < Self::rollover_time_ns(date) {
603            return Ok(SimulationModuleResult::NotReady);
604        }
605
606        // Drain every due weekday so sparse data cannot leave the booking cursor behind.
607        // This is complete by booked-day count, but uses the current positions, prices, and
608        // exchange rates for every date because historical cutoff snapshots are unavailable.
609        // Work is proportional to the gap length times the instrument count. This is a weekday
610        // calendar rather than a pair-specific business-day calendar. The existing Wednesday
611        // and Friday triple multipliers are retained for parity, even though standard spot FX
612        // usually applies the weekend triple on Wednesday only.
613        let mut booking_date = date;
614        let mut batch = Vec::new();
615        let batch_end_date = loop {
616            if booking_date > observed_date
617                || (booking_date == observed_date
618                    && ts_now.as_u64() < Self::rollover_time_ns(booking_date))
619            {
620                return Ok(SimulationModuleResult::NotReady);
621            }
622
623            let iso_weekday = booking_date.weekday().to_monday_one_offset();
624            match self.calculate_rollover_interest(booking_date, iso_weekday, ctx) {
625                RolloverCalculationOutcome::Completed(adjustments) => {
626                    batch.extend(adjustments.into_iter().map(|amount| RolloverAdjustment {
627                        booking_date,
628                        amount,
629                    }));
630                }
631                RolloverCalculationOutcome::Retry => {
632                    return Ok(SimulationModuleResult::NotReady);
633                }
634            }
635
636            let next = Self::next_weekday(booking_date);
637            if next > observed_date
638                || (next == observed_date && ts_now.as_u64() < Self::rollover_time_ns(next))
639            {
640                break booking_date;
641            }
642            booking_date = next;
643        };
644
645        let adjustments = batch.iter().map(|adjustment| adjustment.amount).collect();
646        let mut day = self.rollover_day.borrow_mut();
647        let day = day.as_mut().expect("rollover day initialized");
648        day.pending_adjustments = Some(batch);
649        day.pending_end_date = Some(batch_end_date);
650        day.attempt_time = Some(ts_now);
651        Ok(SimulationModuleResult::Completed(adjustments))
652    }
653
654    fn acknowledge(&self, outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
655        let (adjustments, attempt_time, batch_end_date) = {
656            let mut day = self.rollover_day.borrow_mut();
657            let day = day
658                .as_mut()
659                .ok_or_else(|| anyhow::anyhow!("FX rollover day is not initialized"))?;
660            let adjustment_count = day
661                .pending_adjustments
662                .as_ref()
663                .ok_or_else(|| anyhow::anyhow!("no completed FX rollover batch to acknowledge"))?
664                .len();
665            anyhow::ensure!(
666                outcomes.len() == adjustment_count,
667                "FX rollover acknowledgement count {}, expected {}",
668                outcomes.len(),
669                adjustment_count
670            );
671            let adjustments = day
672                .pending_adjustments
673                .take()
674                .ok_or_else(|| anyhow::anyhow!("no completed FX rollover batch to acknowledge"))?;
675            (
676                adjustments,
677                day.attempt_time
678                    .take()
679                    .ok_or_else(|| anyhow::anyhow!("FX rollover attempt time was not recorded"))?,
680                day.pending_end_date.ok_or_else(|| {
681                    anyhow::anyhow!("FX rollover batch end date was not recorded")
682                })?,
683            )
684        };
685
686        let mut failed = Vec::new();
687        {
688            let mut totals = self.rollover_totals.borrow_mut();
689            let mut unapplied_totals = self.unapplied_rollover_totals.borrow_mut();
690
691            for (adjustment, outcome) in adjustments.into_iter().zip(outcomes) {
692                match outcome {
693                    AccountAdjustmentOutcome::Applied => {
694                        let total = totals.entry(adjustment.amount.currency).or_insert(0.0);
695                        *total += adjustment.amount.as_f64();
696                    }
697                    AccountAdjustmentOutcome::Failed(error) => {
698                        let kind = AccountAdjustmentFailureKind::from(error);
699                        let first_failure = self
700                            .rollover_day
701                            .borrow_mut()
702                            .as_mut()
703                            .expect("rollover day initialized")
704                            .warned_adjustment_failures
705                            .insert((adjustment.booking_date, adjustment.amount.currency, kind));
706
707                        match kind.disposition() {
708                            AccountAdjustmentFailureDisposition::Retry => {
709                                if first_failure {
710                                    log::warn!(
711                                        "Cannot apply rollover adjustment for {} on {}: {error}",
712                                        adjustment.amount.currency,
713                                        adjustment.booking_date
714                                    );
715                                } else {
716                                    log::debug!(
717                                        "Cannot apply rollover adjustment for {} on {}: {error}",
718                                        adjustment.amount.currency,
719                                        adjustment.booking_date
720                                    );
721                                }
722                                failed.push(adjustment);
723                            }
724                            AccountAdjustmentFailureDisposition::RecordUnapplied => {
725                                if first_failure {
726                                    log::warn!(
727                                        "Rollover adjustment for {} on {} failed with {kind:?} and is recorded as unapplied: {error}",
728                                        adjustment.amount,
729                                        adjustment.booking_date
730                                    );
731                                } else {
732                                    log::debug!(
733                                        "Rollover adjustment for {} on {} failed with {kind:?} and is recorded as unapplied: {error}",
734                                        adjustment.amount,
735                                        adjustment.booking_date
736                                    );
737                                }
738                                let total = unapplied_totals
739                                    .entry(adjustment.amount.currency)
740                                    .or_insert(0.0);
741                                *total += adjustment.amount.as_f64();
742                            }
743                        }
744                    }
745                }
746            }
747        }
748
749        if failed.is_empty() {
750            self.rollover_completed.set(true);
751            let mut day = self.rollover_day.borrow_mut();
752            let day = day.as_mut().expect("rollover day initialized");
753            day.date = batch_end_date;
754            day.pending_end_date = None;
755            day.warned_failures.clear();
756
757            let attempt_eastern = attempt_time
758                .to_datetime_utc()
759                .to_zoned(eastern_timezone().clone());
760
761            if attempt_eastern.date() != batch_end_date {
762                log::warn!(
763                    "Rollover batch through {batch_end_date}, scheduled through {}, booked late at {attempt_time}",
764                    UnixNanos::from(Self::rollover_time_ns(batch_end_date))
765                );
766            }
767        } else {
768            self.rollover_day
769                .borrow_mut()
770                .as_mut()
771                .ok_or_else(|| anyhow::anyhow!("FX rollover day is not initialized"))?
772                .pending_adjustments = Some(failed);
773        }
774        Ok(())
775    }
776
777    fn log_diagnostics(&self) -> anyhow::Result<()> {
778        let totals = self.rollover_totals.borrow();
779        let parts: Vec<String> = totals
780            .iter()
781            .map(|(currency, total)| {
782                Money::new_checked(*total, *currency).map(|money| money.to_string())
783            })
784            .collect::<Result<_, _>>()?;
785        log::info!("Rollover interest (totals): {}", parts.join(", "));
786
787        let unapplied_totals = self.unapplied_rollover_totals.borrow();
788        let unapplied_parts: Vec<String> = unapplied_totals
789            .iter()
790            .map(|(currency, total)| {
791                Money::new_checked(*total, *currency).map(|money| money.to_string())
792            })
793            .collect::<Result<_, _>>()?;
794        log::info!(
795            "Rollover interest (unapplied totals): {}",
796            unapplied_parts.join(", ")
797        );
798        Ok(())
799    }
800
801    fn reset(&self) -> anyhow::Result<()> {
802        self.rollover_completed.set(false);
803        self.rollover_day.replace(None);
804        self.rollover_totals.borrow_mut().clear();
805        self.unapplied_rollover_totals.borrow_mut().clear();
806        Ok(())
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use indexmap::IndexMap;
813    use jiff::tz::Offset;
814    use nautilus_common::cache::Cache;
815    use nautilus_model::identifiers::{InstrumentId, Venue};
816    use rstest::rstest;
817    use serde_json::json;
818
819    use super::*;
820
821    fn sample_records() -> Vec<InterestRateRecord> {
822        vec![
823            InterestRateRecord {
824                location: "AUS".into(),
825                time: "2020-Q1".into(),
826                value: 0.75,
827            },
828            InterestRateRecord {
829                location: "USA".into(),
830                time: "2020-Q1".into(),
831                value: 1.50,
832            },
833            InterestRateRecord {
834                location: "JPN".into(),
835                time: "2020-Q1".into(),
836                value: -0.10,
837            },
838            InterestRateRecord {
839                location: "USA".into(),
840                time: "2020-01".into(),
841                value: 1.55,
842            },
843        ]
844    }
845
846    fn rollover_adjustment(booking_date: Date, amount: &str) -> RolloverAdjustment {
847        RolloverAdjustment {
848            booking_date,
849            amount: Money::from(amount),
850        }
851    }
852
853    fn utc_nanos(date: Date, hour: i8, minute: i8) -> UnixNanos {
854        let timestamp = Offset::UTC
855            .to_timestamp(date.at(hour, minute, 0, 0))
856            .unwrap();
857        UnixNanos::from(u64::try_from(timestamp.as_nanosecond()).unwrap())
858    }
859
860    #[rstest]
861    fn test_interest_rate_record_serializes_to_json() {
862        let record = InterestRateRecord {
863            location: "AUS".into(),
864            time: "2020-Q1".into(),
865            value: 0.75,
866        };
867
868        let value = serde_json::to_value(&record).unwrap();
869
870        assert_eq!(
871            value,
872            json!({
873                "location": "AUS",
874                "time": "2020-Q1",
875                "value": 0.75,
876            })
877        );
878    }
879
880    #[rstest]
881    fn test_calculator_quarterly_lookup() {
882        let calc = RolloverInterestCalculator::new(sample_records()).unwrap();
883        let date = Date::new(2020, 2, 15).unwrap();
884        let instrument_id = InstrumentId::from("AUDUSD.SIM");
885
886        let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
887
888        // (0.75 - 1.50) / 365 / 100 = -0.00002054...
889        let expected = (0.75 - 1.50) / 365.0 / 100.0;
890        assert!((rate - expected).abs() < 1e-12);
891    }
892
893    #[rstest]
894    fn test_calculator_monthly_preferred_over_quarterly() {
895        let calc = RolloverInterestCalculator::new(sample_records()).unwrap();
896        let date = Date::new(2020, 1, 15).unwrap();
897        let instrument_id = InstrumentId::from("USDJPY.SIM");
898
899        let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
900
901        // Monthly USD rate (1.55) preferred over quarterly (1.50)
902        let expected = (1.55 - (-0.10)) / 365.0 / 100.0;
903        assert!((rate - expected).abs() < 1e-12);
904    }
905
906    #[rstest]
907    fn test_calculator_missing_currency() {
908        let calc = RolloverInterestCalculator::new(sample_records()).unwrap();
909        let date = Date::new(2020, 1, 15).unwrap();
910        let instrument_id = InstrumentId::from("EURGBP.SIM");
911
912        let result = calc.calc_overnight_rate(instrument_id, date);
913        assert!(result.is_err());
914    }
915
916    #[rstest]
917    fn test_module_reset() {
918        let module = FXRolloverInterestModule::new(sample_records()).unwrap();
919        module.initialize_rollover_day(Date::new(2020, 1, 15).unwrap());
920        module.rollover_completed.set(true);
921        module
922            .rollover_totals
923            .borrow_mut()
924            .insert(Currency::USD(), 100.0);
925        module
926            .unapplied_rollover_totals
927            .borrow_mut()
928            .insert(Currency::AUD(), 20.0);
929
930        module.reset().unwrap();
931
932        assert!(module.rollover_day.borrow().is_none());
933        assert!(!module.rollover_completed.get());
934        assert!(module.rollover_totals.borrow().is_empty());
935        assert!(module.unapplied_rollover_totals.borrow().is_empty());
936    }
937
938    #[rstest]
939    fn test_calculation_failure_dedupe_is_keyed_per_booking_date() {
940        let module = FXRolloverInterestModule::new(sample_records()).unwrap();
941        let date = Date::new(2020, 1, 15).unwrap();
942        let next_date = Date::new(2020, 1, 16).unwrap();
943        let instrument_id = InstrumentId::from("AUDUSD.SIM");
944        module.initialize_rollover_day(date);
945
946        // A catch-up batch calculates many booking dates before the state is
947        // replaced, so a permanent per-instrument skip must stay visible for
948        // every date it drops rather than warning only for the first.
949        module.log_calculation_failure(date, instrument_id, RolloverFailureKind::Rate, "first");
950        module.log_calculation_failure(date, instrument_id, RolloverFailureKind::Rate, "repeat");
951        module.log_calculation_failure(next_date, instrument_id, RolloverFailureKind::Rate, "next");
952
953        assert_eq!(
954            module
955                .rollover_day
956                .borrow()
957                .as_ref()
958                .unwrap()
959                .warned_failures,
960            AHashSet::from([
961                (date, instrument_id, RolloverFailureKind::Rate),
962                (next_date, instrument_id, RolloverFailureKind::Rate),
963            ])
964        );
965    }
966
967    #[rstest]
968    #[case("CAN", "CADUSD.SIM")]
969    #[case("ZAF", "ZARUSD.SIM")]
970    fn test_calculator_maps_oecd_location_code(#[case] location: &str, #[case] symbol: &str) {
971        let records = vec![
972            InterestRateRecord {
973                location: location.to_string(),
974                time: "2020-Q1".to_string(),
975                value: 2.0,
976            },
977            InterestRateRecord {
978                location: "USA".to_string(),
979                time: "2020-Q1".to_string(),
980                value: 1.5,
981            },
982        ];
983        let calc = RolloverInterestCalculator::new(records).unwrap();
984        let date = Date::new(2020, 2, 15).unwrap();
985
986        let rate = calc
987            .calc_overnight_rate(InstrumentId::from(symbol), date)
988            .unwrap();
989        let expected = (2.0 - 1.5) / 365.0 / 100.0;
990
991        assert!((rate - expected).abs() < f64::EPSILON);
992    }
993
994    #[rstest]
995    #[case(f64::NAN)]
996    #[case(f64::INFINITY)]
997    #[case(f64::NEG_INFINITY)]
998    fn test_calculator_rejects_non_finite_rate(#[case] value: f64) {
999        let records = vec![InterestRateRecord {
1000            location: "USA".to_string(),
1001            time: "2020-Q1".to_string(),
1002            value,
1003        }];
1004
1005        let error = RolloverInterestCalculator::new(records).unwrap_err();
1006
1007        assert!(error.to_string().contains("must be finite"));
1008    }
1009
1010    #[rstest]
1011    fn test_transient_adjustment_failure_retries_only_failed_adjustments() {
1012        let module = FXRolloverInterestModule::new(sample_records()).unwrap();
1013        let date = Date::new(2020, 1, 15).unwrap();
1014        let attempt_time = utc_nanos(date, 22, 1);
1015        module.initialize_rollover_day(date);
1016        {
1017            let mut day = module.rollover_day.borrow_mut();
1018            let day = day.as_mut().unwrap();
1019            day.pending_adjustments = Some(vec![
1020                rollover_adjustment(date, "10.00 USD"),
1021                rollover_adjustment(date, "20.00 AUD"),
1022            ]);
1023            day.pending_end_date = Some(date);
1024            day.attempt_time = Some(attempt_time);
1025        }
1026
1027        module
1028            .acknowledge(&[
1029                AccountAdjustmentOutcome::Applied,
1030                AccountAdjustmentOutcome::Failed(AccountAdjustmentError::TotalOverflow(
1031                    Currency::AUD(),
1032                )),
1033            ])
1034            .unwrap();
1035
1036        assert!(!module.rollover_completed.get());
1037        assert_eq!(
1038            module
1039                .rollover_day
1040                .borrow()
1041                .as_ref()
1042                .unwrap()
1043                .pending_adjustments,
1044            Some(vec![rollover_adjustment(date, "20.00 AUD")])
1045        );
1046        assert_eq!(
1047            module.rollover_totals.borrow().get(&Currency::USD()),
1048            Some(&10.0)
1049        );
1050        assert!(
1051            !module
1052                .rollover_totals
1053                .borrow()
1054                .contains_key(&Currency::AUD())
1055        );
1056        assert_eq!(
1057            module
1058                .rollover_day
1059                .borrow()
1060                .as_ref()
1061                .unwrap()
1062                .warned_adjustment_failures
1063                .len(),
1064            1
1065        );
1066
1067        let instruments = AHashMap::new();
1068        let matching_engines = IndexMap::new();
1069        let cache = Cache::default();
1070        let ctx = ExchangeContext {
1071            venue: Venue::new("SIM"),
1072            base_currency: None,
1073            instruments: &instruments,
1074            matching_engines: &matching_engines,
1075            cache: &cache,
1076        };
1077        assert_eq!(
1078            module.process(attempt_time, &ctx).unwrap(),
1079            SimulationModuleResult::Completed(vec![Money::from("20.00 AUD")])
1080        );
1081        module
1082            .acknowledge(&[AccountAdjustmentOutcome::Failed(
1083                AccountAdjustmentError::TotalOverflow(Currency::AUD()),
1084            )])
1085            .unwrap();
1086        assert_eq!(
1087            module
1088                .rollover_day
1089                .borrow()
1090                .as_ref()
1091                .unwrap()
1092                .warned_adjustment_failures
1093                .len(),
1094            1
1095        );
1096        assert_eq!(
1097            module.process(attempt_time, &ctx).unwrap(),
1098            SimulationModuleResult::Completed(vec![Money::from("20.00 AUD")])
1099        );
1100        module
1101            .acknowledge(&[AccountAdjustmentOutcome::Applied])
1102            .unwrap();
1103
1104        assert!(module.rollover_completed.get());
1105        assert_eq!(
1106            module.rollover_totals.borrow().get(&Currency::USD()),
1107            Some(&10.0)
1108        );
1109        assert_eq!(
1110            module.rollover_totals.borrow().get(&Currency::AUD()),
1111            Some(&20.0)
1112        );
1113        assert_eq!(
1114            module
1115                .rollover_day
1116                .borrow()
1117                .as_ref()
1118                .unwrap()
1119                .warned_adjustment_failures
1120                .len(),
1121            1
1122        );
1123    }
1124
1125    #[rstest]
1126    fn test_permanent_adjustment_failure_completes_batch() {
1127        let module = FXRolloverInterestModule::new(sample_records()).unwrap();
1128        let date = Date::new(2020, 1, 15).unwrap();
1129        let attempt_time = utc_nanos(date, 22, 1);
1130        module.initialize_rollover_day(date);
1131        let second_date = date.tomorrow().unwrap();
1132        {
1133            let mut day = module.rollover_day.borrow_mut();
1134            let day = day.as_mut().unwrap();
1135            day.pending_adjustments = Some(vec![
1136                rollover_adjustment(date, "20.00 AUD"),
1137                rollover_adjustment(second_date, "30.00 AUD"),
1138            ]);
1139            day.pending_end_date = Some(second_date);
1140            day.attempt_time = Some(attempt_time);
1141        }
1142
1143        module
1144            .acknowledge(&[
1145                AccountAdjustmentOutcome::Failed(AccountAdjustmentError::MissingBalance(
1146                    Currency::AUD(),
1147                )),
1148                AccountAdjustmentOutcome::Failed(AccountAdjustmentError::MissingBalance(
1149                    Currency::AUD(),
1150                )),
1151            ])
1152            .unwrap();
1153
1154        assert!(module.rollover_completed.get());
1155        assert!(
1156            module
1157                .rollover_day
1158                .borrow()
1159                .as_ref()
1160                .unwrap()
1161                .pending_adjustments
1162                .is_none()
1163        );
1164        assert_eq!(
1165            module
1166                .unapplied_rollover_totals
1167                .borrow()
1168                .get(&Currency::AUD()),
1169            Some(&50.0)
1170        );
1171        assert!(
1172            !module
1173                .rollover_totals
1174                .borrow()
1175                .contains_key(&Currency::AUD())
1176        );
1177        assert_eq!(
1178            module
1179                .rollover_day
1180                .borrow()
1181                .as_ref()
1182                .unwrap()
1183                .warned_adjustment_failures,
1184            AHashSet::from([
1185                (
1186                    date,
1187                    Currency::AUD(),
1188                    AccountAdjustmentFailureKind::MissingBalance,
1189                ),
1190                (
1191                    second_date,
1192                    Currency::AUD(),
1193                    AccountAdjustmentFailureKind::MissingBalance,
1194                ),
1195            ])
1196        );
1197        let instruments = AHashMap::new();
1198        let matching_engines = IndexMap::new();
1199        let cache = Cache::default();
1200        let ctx = ExchangeContext {
1201            venue: Venue::new("SIM"),
1202            base_currency: None,
1203            instruments: &instruments,
1204            matching_engines: &matching_engines,
1205            cache: &cache,
1206        };
1207        let next_attempt = utc_nanos(second_date.tomorrow().unwrap(), 22, 1);
1208        assert_eq!(
1209            module.process(next_attempt, &ctx).unwrap(),
1210            SimulationModuleResult::Completed(Vec::new())
1211        );
1212    }
1213
1214    #[rstest]
1215    fn test_acknowledgement_count_error_preserves_pending_batch() {
1216        let module = FXRolloverInterestModule::new(sample_records()).unwrap();
1217        let date = Date::new(2020, 1, 15).unwrap();
1218        module.initialize_rollover_day(date);
1219        {
1220            let mut day = module.rollover_day.borrow_mut();
1221            let day = day.as_mut().unwrap();
1222            day.pending_adjustments = Some(vec![rollover_adjustment(date, "10.00 USD")]);
1223            day.attempt_time = Some(UnixNanos::from(1));
1224        }
1225
1226        let error = module.acknowledge(&[]).unwrap_err();
1227
1228        assert_eq!(
1229            error.to_string(),
1230            "FX rollover acknowledgement count 0, expected 1"
1231        );
1232        assert_eq!(
1233            module
1234                .rollover_day
1235                .borrow()
1236                .as_ref()
1237                .unwrap()
1238                .pending_adjustments,
1239            Some(vec![rollover_adjustment(date, "10.00 USD")])
1240        );
1241    }
1242}