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