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        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    /// Creates a new CFD swap module.
118    ///
119    /// Rates are signed daily fractions of settlement notional. Later entries replace earlier
120    /// entries for the same instrument.
121    #[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[&currency], 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}