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
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#[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 pub location: String,
81 pub time: String,
83 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#[derive(Debug, Clone)]
105pub struct RolloverInterestCalculator {
106 rates: AHashMap<String, AHashMap<String, f64>>,
108}
109
110impl RolloverInterestCalculator {
111 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 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(¤cy) = 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 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 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 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#[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 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 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 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 if iso_weekday == 3 || iso_weekday == 5 {
468 rollover *= 3.0;
469 }
470
471 let currency = if let Some(base) = ctx.base_currency {
472 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 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 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 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 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}