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