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