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