1use std::cell::{Cell, RefCell};
19
20use ahash::{AHashMap, AHashSet};
21use jiff::{
22 civil::{Date, Time, Weekday},
23 tz::TimeZone,
24};
25use nautilus_core::UnixNanos;
26use nautilus_model::{
27 data::Data,
28 enums::{InstrumentClass, PriceType},
29 identifiers::InstrumentId,
30 instruments::Instrument,
31 types::{Currency, Money, Price},
32};
33use rust_decimal::Decimal;
34
35use super::{AccountAdjustmentOutcome, ExchangeContext, SimulationModule, SimulationModuleResult};
36#[cfg(feature = "python")]
37use crate::python::modules::PySimulationModule;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(module = "nautilus_trader.backtest", from_py_object)
44)]
45#[cfg_attr(
46 feature = "python",
47 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
48)]
49pub struct CfdSwapRate {
50 pub instrument_id: InstrumentId,
52 pub long_rate: Decimal,
54 pub short_rate: Decimal,
56}
57
58impl CfdSwapRate {
59 #[must_use]
61 pub const fn new(instrument_id: InstrumentId, long_rate: Decimal, short_rate: Decimal) -> Self {
62 Self {
63 instrument_id,
64 long_rate,
65 short_rate,
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
72#[cfg_attr(
73 feature = "python",
74 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")
75)]
76#[cfg_attr(
77 feature = "python",
78 pyo3::pyclass(
79 module = "nautilus_trader.backtest",
80 extends = PySimulationModule,
81 unsendable,
82 skip_from_py_object
83 )
84)]
85pub struct CfdSwapModule {
86 rates: AHashMap<InstrumentId, CfdSwapRate>,
87 rollover_time: Time,
88 triple_roll_weekday: Weekday,
89 rollover_completed: Cell<bool>,
90 rollover_day: RefCell<Option<RolloverDayState>>,
91 swap_totals: RefCell<AHashMap<Currency, Decimal>>,
92 unapplied_swap_totals: RefCell<AHashMap<Currency, Decimal>>,
93}
94
95#[derive(Debug, Clone)]
96struct RolloverDayState {
97 date: Date,
98 warned_failures: AHashSet<(Date, InstrumentId, CfdSwapFailureKind)>,
99 pending_adjustments: Option<Vec<SwapAdjustment>>,
100 pending_end_date: Option<Date>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104enum CfdSwapFailureKind {
105 Engine,
106 Price,
107 Xrate,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111struct SwapAdjustment {
112 booking_date: Date,
113 amount: Money,
114}
115
116impl CfdSwapModule {
117 #[must_use]
122 pub fn new(rates: Vec<CfdSwapRate>, rollover_time: Time, triple_roll_weekday: Weekday) -> Self {
123 Self {
124 rates: rates
125 .into_iter()
126 .map(|rate| (rate.instrument_id, rate))
127 .collect(),
128 rollover_time,
129 triple_roll_weekday,
130 rollover_completed: Cell::new(false),
131 rollover_day: RefCell::new(None),
132 swap_totals: RefCell::new(AHashMap::new()),
133 unapplied_swap_totals: RefCell::new(AHashMap::new()),
134 }
135 }
136
137 fn initialize_rollover_day(&self, date: Date) {
138 self.rollover_day.replace(Some(RolloverDayState {
139 date,
140 warned_failures: AHashSet::new(),
141 pending_adjustments: None,
142 pending_end_date: None,
143 }));
144 self.rollover_completed.set(false);
145 }
146
147 fn rollover_time_ns(&self, date: Date) -> anyhow::Result<u64> {
148 let timestamp = date
149 .to_datetime(self.rollover_time)
150 .to_zoned(TimeZone::UTC)?
151 .timestamp()
152 .as_nanosecond();
153 Ok(u64::try_from(timestamp)?)
154 }
155
156 fn weekday_on_or_before(mut date: Date) -> anyhow::Result<Date> {
157 while date.weekday().to_monday_one_offset() > 5 {
158 date = date.yesterday()?;
159 }
160 Ok(date)
161 }
162
163 fn next_weekday(mut date: Date) -> anyhow::Result<Date> {
164 loop {
165 date = date.tomorrow()?;
166 if date.weekday().to_monday_one_offset() <= 5 {
167 return Ok(date);
168 }
169 }
170 }
171
172 fn settlement_price(
173 ctx: &ExchangeContext,
174 instrument_id: InstrumentId,
175 ) -> anyhow::Result<Option<Price>> {
176 let Some(matching_engine) = ctx.matching_engines.get(&instrument_id) else {
177 return Ok(None);
178 };
179 let book = matching_engine.get_book();
180
181 match (book.best_bid_price(), book.best_ask_price()) {
182 (Some(bid), Some(ask)) => {
183 let midpoint = bid
184 .as_decimal()
185 .checked_add(ask.as_decimal())
186 .and_then(|sum| sum.checked_div(Decimal::TWO))
187 .ok_or_else(|| {
188 anyhow::anyhow!(
189 "cannot calculate CFD swap for {instrument_id}: midpoint overflow"
190 )
191 })?;
192 Ok(Some(Price::from_decimal(midpoint)?))
193 }
194 (Some(price), None) | (None, Some(price)) => Ok(Some(price)),
195 (None, None) => Ok(None),
196 }
197 }
198
199 fn log_calculation_failure(
200 &self,
201 booking_date: Date,
202 instrument_id: InstrumentId,
203 kind: CfdSwapFailureKind,
204 message: &str,
205 ) {
206 let first_failure = self
207 .rollover_day
208 .borrow_mut()
209 .as_mut()
210 .expect("rollover day initialized")
211 .warned_failures
212 .insert((booking_date, instrument_id, kind));
213
214 if first_failure {
215 log::warn!("{message}");
216 } else {
217 log::debug!("{message}");
218 }
219 }
220
221 fn calculate_adjustments(
222 &self,
223 booking_date: Date,
224 ctx: &ExchangeContext,
225 ) -> anyhow::Result<Option<Vec<Money>>> {
226 let mut instrument_ids = self.rates.keys().copied().collect::<Vec<_>>();
227 instrument_ids.sort_unstable();
228 let mut adjustments = Vec::new();
229
230 for instrument_id in instrument_ids {
231 let Some(instrument) = ctx.instruments.get(&instrument_id) else {
232 continue;
233 };
234
235 if instrument.instrument_class() != InstrumentClass::Cfd {
236 continue;
237 }
238
239 let mut positions =
240 ctx.cache
241 .positions_open(Some(&ctx.venue), Some(&instrument_id), None, None, None);
242 positions.sort_unstable_by_key(|position| position.id);
243 if positions.is_empty() {
244 continue;
245 }
246
247 let Some(settlement_price) = Self::settlement_price(ctx, instrument_id)? else {
248 let (kind, message) = if ctx.matching_engines.contains_key(&instrument_id) {
249 (
250 CfdSwapFailureKind::Price,
251 format!(
252 "Cannot calculate CFD swap for {instrument_id}: no settlement price"
253 ),
254 )
255 } else {
256 (
257 CfdSwapFailureKind::Engine,
258 format!(
259 "Cannot calculate CFD swap for {instrument_id}: no matching engine"
260 ),
261 )
262 };
263 self.log_calculation_failure(booking_date, instrument_id, kind, &message);
264 return Ok(None);
265 };
266 let rate = self.rates[&instrument_id];
267 let multiplier = if booking_date.weekday() == self.triple_roll_weekday {
268 Decimal::from(3)
269 } else {
270 Decimal::ONE
271 };
272
273 for position in positions {
274 let daily_rate = if position.is_long() {
275 rate.long_rate
276 } else if position.is_short() {
277 rate.short_rate
278 } else {
279 continue;
280 };
281 let notional = position.try_notional_value(settlement_price)?;
282 let amount = notional
283 .as_decimal()
284 .checked_mul(daily_rate)
285 .and_then(|value| value.checked_mul(multiplier))
286 .ok_or_else(|| {
287 anyhow::anyhow!(
288 "cannot calculate CFD swap for position {}: adjustment overflow",
289 position.id
290 )
291 })?;
292 let (amount, currency) = if let Some(base_currency) = ctx.base_currency {
293 let xrate = match ctx.cache.try_get_xrate(
294 ctx.venue,
295 notional.currency,
296 base_currency,
297 PriceType::Mid,
298 ) {
299 Ok(Some(xrate)) => xrate,
300 Ok(None) => {
301 self.log_calculation_failure(
302 booking_date,
303 instrument_id,
304 CfdSwapFailureKind::Xrate,
305 &format!(
306 "Cannot calculate CFD swap for {instrument_id}: no exchange rate from {} to {base_currency}",
307 notional.currency
308 ),
309 );
310 return Ok(None);
311 }
312 Err(e) => {
313 self.log_calculation_failure(
314 booking_date,
315 instrument_id,
316 CfdSwapFailureKind::Xrate,
317 &format!(
318 "Cannot calculate CFD swap for {instrument_id}: exchange rate from {} to {base_currency}: {e}",
319 notional.currency
320 ),
321 );
322 return Ok(None);
323 }
324 };
325 let amount = amount.checked_mul(xrate).ok_or_else(|| {
326 anyhow::anyhow!(
327 "cannot calculate CFD swap for position {}: currency conversion overflow",
328 position.id
329 )
330 })?;
331 (amount, base_currency)
332 } else {
333 (amount, notional.currency)
334 };
335 adjustments.push(Money::from_decimal(amount, currency)?);
336 }
337 }
338
339 Ok(Some(adjustments))
340 }
341
342 fn log_totals(label: &str, totals: &AHashMap<Currency, Decimal>) -> anyhow::Result<()> {
343 let mut currencies = totals.keys().copied().collect::<Vec<_>>();
344 currencies.sort_unstable_by_key(|currency| currency.code);
345 let parts = currencies
346 .into_iter()
347 .map(|currency| Money::from_decimal(totals[¤cy], currency))
348 .collect::<Result<Vec<_>, _>>()?
349 .into_iter()
350 .map(|money| money.to_string())
351 .collect::<Vec<_>>();
352 log::info!("CFD swap ({label}): {}", parts.join(", "));
353 Ok(())
354 }
355}
356
357impl SimulationModule for CfdSwapModule {
358 fn pre_process(&self, _data: &Data) -> anyhow::Result<()> {
359 Ok(())
360 }
361
362 fn process(
363 &self,
364 ts_now: UnixNanos,
365 ctx: &ExchangeContext,
366 ) -> anyhow::Result<SimulationModuleResult> {
367 let observed_date = ts_now.to_datetime_utc().to_zoned(TimeZone::UTC).date();
368 let initialize_date = {
369 let day = self.rollover_day.borrow();
370 match day.as_ref() {
371 None => Some(Self::weekday_on_or_before(observed_date)?),
372 Some(day) if self.rollover_completed.get() && day.date < observed_date => {
373 Some(Self::next_weekday(day.date)?)
374 }
375 Some(_) => None,
376 }
377 };
378
379 if let Some(date) = initialize_date {
380 self.initialize_rollover_day(date);
381 }
382
383 if self.rollover_completed.get() {
384 return Ok(SimulationModuleResult::NotReady);
385 }
386
387 {
388 let day = self.rollover_day.borrow();
389 let day = day
390 .as_ref()
391 .ok_or_else(|| anyhow::anyhow!("CFD swap rollover day is not initialized"))?;
392 if let Some(adjustments) = &day.pending_adjustments {
393 return Ok(SimulationModuleResult::Completed(
394 adjustments
395 .iter()
396 .map(|adjustment| adjustment.amount)
397 .collect(),
398 ));
399 }
400 }
401
402 let date = self
403 .rollover_day
404 .borrow()
405 .as_ref()
406 .ok_or_else(|| anyhow::anyhow!("CFD swap rollover day is not initialized"))?
407 .date;
408 if ts_now.as_u64() < self.rollover_time_ns(date)? {
409 return Ok(SimulationModuleResult::NotReady);
410 }
411
412 let mut booking_date = date;
413 let mut batch = Vec::new();
414 let batch_end_date = loop {
415 if booking_date > observed_date
416 || (booking_date == observed_date
417 && ts_now.as_u64() < self.rollover_time_ns(booking_date)?)
418 {
419 return Ok(SimulationModuleResult::NotReady);
420 }
421
422 let Some(adjustments) = self.calculate_adjustments(booking_date, ctx)? else {
423 return Ok(SimulationModuleResult::NotReady);
424 };
425 batch.extend(adjustments.into_iter().map(|amount| SwapAdjustment {
426 booking_date,
427 amount,
428 }));
429
430 let next = Self::next_weekday(booking_date)?;
431 if next > observed_date
432 || (next == observed_date && ts_now.as_u64() < self.rollover_time_ns(next)?)
433 {
434 break booking_date;
435 }
436 booking_date = next;
437 };
438
439 let adjustments = batch.iter().map(|adjustment| adjustment.amount).collect();
440 let mut day = self.rollover_day.borrow_mut();
441 let day = day
442 .as_mut()
443 .ok_or_else(|| anyhow::anyhow!("CFD swap rollover day is not initialized"))?;
444 day.pending_adjustments = Some(batch);
445 day.pending_end_date = Some(batch_end_date);
446 Ok(SimulationModuleResult::Completed(adjustments))
447 }
448
449 fn acknowledge(&self, outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
450 let (adjustments, batch_end_date) = {
451 let mut day = self.rollover_day.borrow_mut();
452 let day = day
453 .as_mut()
454 .ok_or_else(|| anyhow::anyhow!("CFD swap rollover day is not initialized"))?;
455 let adjustment_count = day
456 .pending_adjustments
457 .as_ref()
458 .ok_or_else(|| anyhow::anyhow!("no completed CFD swap batch to acknowledge"))?
459 .len();
460 anyhow::ensure!(
461 outcomes.len() == adjustment_count,
462 "CFD swap acknowledgement count {}, expected {}",
463 outcomes.len(),
464 adjustment_count
465 );
466 let adjustments = day
467 .pending_adjustments
468 .take()
469 .ok_or_else(|| anyhow::anyhow!("no completed CFD swap batch to acknowledge"))?;
470 let end_date = day
471 .pending_end_date
472 .ok_or_else(|| anyhow::anyhow!("CFD swap batch end date was not recorded"))?;
473 (adjustments, end_date)
474 };
475
476 let mut failed = Vec::new();
477
478 for (adjustment, outcome) in adjustments.into_iter().zip(outcomes) {
479 match outcome {
480 AccountAdjustmentOutcome::Applied => {
481 let mut totals = self.swap_totals.borrow_mut();
482 let total = totals.entry(adjustment.amount.currency).or_default();
483 *total = total
484 .checked_add(adjustment.amount.as_decimal())
485 .ok_or_else(|| anyhow::anyhow!("CFD swap diagnostic total overflow"))?;
486 }
487 AccountAdjustmentOutcome::Failed(error) if error.is_retryable() => {
488 log::warn!(
489 "Cannot apply CFD swap adjustment for {} on {}: {error}",
490 adjustment.amount.currency,
491 adjustment.booking_date
492 );
493 failed.push(adjustment);
494 }
495 AccountAdjustmentOutcome::Failed(error) => {
496 log::warn!(
497 "CFD swap adjustment {} on {} is recorded as unapplied: {error}",
498 adjustment.amount,
499 adjustment.booking_date
500 );
501 let mut totals = self.unapplied_swap_totals.borrow_mut();
502 let total = totals.entry(adjustment.amount.currency).or_default();
503 *total = total
504 .checked_add(adjustment.amount.as_decimal())
505 .ok_or_else(|| anyhow::anyhow!("unapplied CFD swap total overflow"))?;
506 }
507 }
508 }
509
510 let mut day = self.rollover_day.borrow_mut();
511 let day = day
512 .as_mut()
513 .ok_or_else(|| anyhow::anyhow!("CFD swap rollover day is not initialized"))?;
514 if failed.is_empty() {
515 day.date = batch_end_date;
516 day.pending_end_date = None;
517 day.warned_failures.clear();
518 self.rollover_completed.set(true);
519 } else {
520 day.pending_adjustments = Some(failed);
521 }
522 Ok(())
523 }
524
525 fn log_diagnostics(&self) -> anyhow::Result<()> {
526 Self::log_totals("totals", &self.swap_totals.borrow())?;
527 Self::log_totals("unapplied totals", &self.unapplied_swap_totals.borrow())
528 }
529
530 fn reset(&self) -> anyhow::Result<()> {
531 self.rollover_completed.set(false);
532 self.rollover_day.replace(None);
533 self.swap_totals.borrow_mut().clear();
534 self.unapplied_swap_totals.borrow_mut().clear();
535 Ok(())
536 }
537}