1use 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#[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 pub location: String,
83 pub time: String,
85 pub value: Decimal,
87}
88
89#[derive(Debug, Clone)]
94pub struct RolloverInterestCalculator {
95 rates: AHashMap<String, AHashMap<String, Decimal>>,
97}
98
99impl RolloverInterestCalculator {
100 #[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 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(¤cy) = 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 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 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 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#[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 #[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 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 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 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 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 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 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}