nautilus_backtest/modules/
fx_rollover.rs1use std::cell::{Cell, RefCell};
19
20use ahash::AHashMap;
21use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
22use chrono_tz::US::Eastern;
23use nautilus_core::UnixNanos;
24use nautilus_model::{
25 data::Data,
26 enums::{AssetClass, PriceType},
27 identifiers::InstrumentId,
28 instruments::Instrument,
29 position::Position,
30 types::{Currency, Money},
31};
32
33use super::{ExchangeContext, SimulationModule};
34
35const LOCATION_CURRENCY_MAP: &[(&str, &str)] = &[
36 ("AUS", "AUD"),
37 ("CAD", "CAD"),
38 ("CHE", "CHF"),
39 ("EA19", "EUR"),
40 ("USA", "USD"),
41 ("JPN", "JPY"),
42 ("NZL", "NZD"),
43 ("GBR", "GBP"),
44 ("RUS", "RUB"),
45 ("NOR", "NOK"),
46 ("CHN", "CNY"),
47 ("MEX", "MXN"),
48 ("ZAR", "ZAR"),
49];
50
51#[derive(Debug, Clone)]
53#[cfg_attr(
54 feature = "python",
55 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.backtest", from_py_object)
56)]
57#[cfg_attr(
58 feature = "python",
59 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
60)]
61pub struct InterestRateRecord {
62 pub location: String,
64 pub time: String,
66 pub value: f64,
68}
69
70#[derive(Debug, Clone)]
75pub struct RolloverInterestCalculator {
76 rates: AHashMap<String, AHashMap<String, f64>>,
78}
79
80impl RolloverInterestCalculator {
81 pub fn new(records: Vec<InterestRateRecord>) -> Self {
83 let location_to_currency: AHashMap<&str, &str> =
84 LOCATION_CURRENCY_MAP.iter().copied().collect();
85
86 let mut rates: AHashMap<String, AHashMap<String, f64>> = AHashMap::new();
87
88 for record in records {
89 if record.location == "CHN" {
91 rates
92 .entry("CNH".to_string())
93 .or_default()
94 .insert(record.time.clone(), record.value);
95 }
96
97 if let Some(¤cy) = location_to_currency.get(record.location.as_str()) {
98 rates
99 .entry(currency.to_string())
100 .or_default()
101 .insert(record.time, record.value);
102 }
103 }
104
105 Self { rates }
106 }
107
108 pub fn calc_overnight_rate(
116 &self,
117 instrument_id: InstrumentId,
118 date: NaiveDate,
119 ) -> anyhow::Result<f64> {
120 let symbol = instrument_id.symbol.as_str();
121 if symbol.len() < 6 {
122 anyhow::bail!("FX symbol must be at least 6 characters: {symbol}");
123 }
124
125 let base_currency = &symbol[..3];
126 let quote_currency = &symbol[symbol.len() - 3..];
127
128 let base_rate = self.lookup_rate(base_currency, date)?;
129 let quote_rate = self.lookup_rate(quote_currency, date)?;
130
131 Ok((base_rate - quote_rate) / 365.0 / 100.0)
132 }
133
134 fn lookup_rate(&self, currency: &str, date: NaiveDate) -> anyhow::Result<f64> {
135 let currency_rates = self
136 .rates
137 .get(currency)
138 .ok_or_else(|| anyhow::anyhow!("No rate data for currency {currency}"))?;
139
140 let monthly_key = format!("{}-{:02}", date.year(), date.month());
142 if let Some(&rate) = currency_rates.get(&monthly_key) {
143 return Ok(rate);
144 }
145
146 let quarter = (date.month() - 1) / 3 + 1;
148 let quarterly_key = format!("{}-Q{quarter}", date.year());
149 if let Some(&rate) = currency_rates.get(&quarterly_key) {
150 return Ok(rate);
151 }
152
153 anyhow::bail!("No rate data for {currency} at {monthly_key} or {quarterly_key}")
154 }
155}
156
157#[derive(Debug, Clone)]
163#[cfg_attr(
164 feature = "python",
165 pyo3::pyclass(
166 module = "nautilus_trader.core.nautilus_pyo3.backtest",
167 unsendable,
168 skip_from_py_object
169 )
170)]
171#[cfg_attr(
172 feature = "python",
173 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
174)]
175pub struct FXRolloverInterestModule {
176 calculator: RolloverInterestCalculator,
177 rollover_time_ns: Cell<u64>,
178 rollover_applied: Cell<bool>,
179 day_number: Cell<u32>,
180 rollover_totals: RefCell<AHashMap<Currency, f64>>,
181}
182
183impl FXRolloverInterestModule {
184 pub fn new(records: Vec<InterestRateRecord>) -> Self {
186 Self {
187 calculator: RolloverInterestCalculator::new(records),
188 rollover_time_ns: Cell::new(0),
189 rollover_applied: Cell::new(false),
190 day_number: Cell::new(0),
191 rollover_totals: RefCell::new(AHashMap::new()),
192 }
193 }
194
195 fn apply_rollover_interest(
196 &self,
197 date: NaiveDate,
198 iso_weekday: u32,
199 ctx: &ExchangeContext,
200 ) -> Vec<Money> {
201 let mut adjustments = Vec::new();
202
203 let mut mid_prices: AHashMap<InstrumentId, f64> = AHashMap::new();
204
205 for (instrument_id, instrument) in ctx.instruments {
206 if instrument.asset_class() != AssetClass::FX {
207 continue;
208 }
209
210 let matching_engine = match ctx.matching_engines.get(instrument_id) {
211 Some(engine) => engine,
212 None => continue,
213 };
214
215 let book = matching_engine.get_book();
216 let mid = if let Some(m) = book.midpoint() {
217 m
218 } else if let Some(p) = book.best_bid_price() {
219 p.as_f64()
220 } else if let Some(p) = book.best_ask_price() {
221 p.as_f64()
222 } else {
223 continue;
224 };
225 mid_prices.insert(*instrument_id, mid);
226 }
227
228 for (instrument_id, &mid) in &mid_prices {
229 let positions: Vec<&Position> =
230 ctx.cache
231 .positions_open(Some(&ctx.venue), Some(instrument_id), None, None, None);
232
233 if positions.is_empty() {
234 continue;
235 }
236
237 let interest_rate = match self.calculator.calc_overnight_rate(*instrument_id, date) {
238 Ok(rate) => rate,
239 Err(e) => {
240 log::warn!("Skipping rollover for {instrument_id}: {e}");
241 continue;
242 }
243 };
244
245 let net_qty: f64 = positions.iter().map(|p| p.signed_qty).sum();
246
247 let mut rollover = net_qty * mid * interest_rate;
248
249 if iso_weekday == 3 || iso_weekday == 5 {
251 rollover *= 3.0;
252 }
253
254 let instrument = &ctx.instruments[instrument_id];
255 let currency = if let Some(base) = ctx.base_currency {
256 let xrate = ctx
257 .cache
258 .get_xrate(ctx.venue, instrument.quote_currency(), base, PriceType::Mid)
259 .unwrap_or(0.0);
260 rollover *= xrate;
261 base
262 } else {
263 instrument.quote_currency()
264 };
265
266 {
267 let mut totals = self.rollover_totals.borrow_mut();
268 let total = totals.entry(currency).or_insert(0.0);
269 *total += rollover;
270 }
271
272 adjustments.push(Money::new(rollover, currency));
273 }
274
275 adjustments
276 }
277}
278
279impl SimulationModule for FXRolloverInterestModule {
280 fn pre_process(&self, _data: &Data) {}
281
282 fn process(&self, ts_now: UnixNanos, ctx: &ExchangeContext) -> Vec<Money> {
283 let utc_dt = nanos_to_utc_datetime(ts_now);
284 let eastern_dt = Eastern.from_utc_datetime(&utc_dt);
285 let eastern_day = eastern_dt.ordinal();
286
287 if self.day_number.get() != eastern_day {
288 self.day_number.set(eastern_day);
289 self.rollover_applied.set(false);
290
291 let rollover_eastern = eastern_dt
292 .date_naive()
293 .and_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap());
294 let rollover_utc = Eastern
295 .from_local_datetime(&rollover_eastern)
296 .single()
297 .unwrap()
298 .naive_utc();
299 let rollover_ns = rollover_utc.and_utc().timestamp_nanos_opt().unwrap() as u64;
300 self.rollover_time_ns.set(rollover_ns);
301 }
302
303 if !self.rollover_applied.get() && ts_now.as_u64() >= self.rollover_time_ns.get() {
304 let iso_weekday = eastern_dt.weekday().number_from_monday();
305 self.rollover_applied.set(true);
306 return self.apply_rollover_interest(eastern_dt.date_naive(), iso_weekday, ctx);
307 }
308
309 Vec::new()
310 }
311
312 fn log_diagnostics(&self) {
313 let totals = self.rollover_totals.borrow();
314 let parts: Vec<String> = totals
315 .iter()
316 .map(|(currency, total)| {
317 let money = Money::new(*total, *currency);
318 money.to_string()
319 })
320 .collect();
321 log::info!("Rollover interest (totals): {}", parts.join(", "));
322 }
323
324 fn reset(&self) {
325 self.rollover_time_ns.set(0);
326 self.rollover_applied.set(false);
327 self.day_number.set(0);
328 self.rollover_totals.borrow_mut().clear();
329 }
330}
331
332fn nanos_to_utc_datetime(ts: UnixNanos) -> NaiveDateTime {
333 let secs = (ts.as_u64() / 1_000_000_000) as i64;
334 let nanos = (ts.as_u64() % 1_000_000_000) as u32;
335 DateTime::from_timestamp(secs, nanos)
336 .expect("valid timestamp")
337 .naive_utc()
338}
339
340#[cfg(test)]
341mod tests {
342 use nautilus_model::identifiers::InstrumentId;
343 use rstest::rstest;
344
345 use super::*;
346
347 fn sample_records() -> Vec<InterestRateRecord> {
348 vec![
349 InterestRateRecord {
350 location: "AUS".into(),
351 time: "2020-Q1".into(),
352 value: 0.75,
353 },
354 InterestRateRecord {
355 location: "USA".into(),
356 time: "2020-Q1".into(),
357 value: 1.50,
358 },
359 InterestRateRecord {
360 location: "JPN".into(),
361 time: "2020-Q1".into(),
362 value: -0.10,
363 },
364 InterestRateRecord {
365 location: "USA".into(),
366 time: "2020-01".into(),
367 value: 1.55,
368 },
369 ]
370 }
371
372 #[rstest]
373 fn test_calculator_quarterly_lookup() {
374 let calc = RolloverInterestCalculator::new(sample_records());
375 let date = NaiveDate::from_ymd_opt(2020, 2, 15).unwrap();
376 let instrument_id = InstrumentId::from("AUDUSD.SIM");
377
378 let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
379
380 let expected = (0.75 - 1.50) / 365.0 / 100.0;
382 assert!((rate - expected).abs() < 1e-12);
383 }
384
385 #[rstest]
386 fn test_calculator_monthly_preferred_over_quarterly() {
387 let calc = RolloverInterestCalculator::new(sample_records());
388 let date = NaiveDate::from_ymd_opt(2020, 1, 15).unwrap();
389 let instrument_id = InstrumentId::from("USDJPY.SIM");
390
391 let rate = calc.calc_overnight_rate(instrument_id, date).unwrap();
392
393 let expected = (1.55 - (-0.10)) / 365.0 / 100.0;
395 assert!((rate - expected).abs() < 1e-12);
396 }
397
398 #[rstest]
399 fn test_calculator_missing_currency() {
400 let calc = RolloverInterestCalculator::new(sample_records());
401 let date = NaiveDate::from_ymd_opt(2020, 1, 15).unwrap();
402 let instrument_id = InstrumentId::from("EURGBP.SIM");
403
404 let result = calc.calc_overnight_rate(instrument_id, date);
405 assert!(result.is_err());
406 }
407
408 #[rstest]
409 fn test_module_reset() {
410 let module = FXRolloverInterestModule::new(sample_records());
411 module.day_number.set(15);
412 module.rollover_applied.set(true);
413 module
414 .rollover_totals
415 .borrow_mut()
416 .insert(Currency::USD(), 100.0);
417
418 module.reset();
419
420 assert_eq!(module.day_number.get(), 0);
421 assert!(!module.rollover_applied.get());
422 assert!(module.rollover_totals.borrow().is_empty());
423 }
424}