Skip to main content

nautilus_backtest/modules/
cfd_swap.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! CFD overnight swap simulation module.
17
18use 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/// Daily long and short swap rates for a CFD instrument.
40#[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    /// The instrument ID the rates apply to.
51    pub instrument_id: InstrumentId,
52    /// The signed daily rate applied to long positions.
53    pub long_rate: Decimal,
54    /// The signed daily rate applied to short positions.
55    pub short_rate: Decimal,
56}
57
58impl CfdSwapRate {
59    /// Creates a new [`CfdSwapRate`] instance.
60    #[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/// Simulates daily CFD swap adjustments at a configurable UTC rollover time.
71#[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    /// Creates a new CFD swap module.
117    ///
118    /// Rates are signed daily fractions of settlement notional. Later entries replace earlier
119    /// entries for the same instrument.
120    #[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[&currency], 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}