Skip to main content

qs_core/
costs.rs

1//! Pure trading-cost arithmetic for commission and overnight swap.
2//!
3//! This module owns *how much* a cost is, never *when* it is charged. A replay or live consumer decides the timing, supplies the fill or rollover facts, and applies the returned signed charge to its own ledger.
4//!
5//! Every charge is signed so that a positive amount reduces the account balance and a negative amount increases it. Commission is always a cost. Swap follows broker convention, where a negative configured value is a nightly charge and a positive value is a nightly credit.
6//!
7//! Each charge also carries the currency basis it was computed in. Per-lot commission and per-lot swap are expressed directly in the account currency, while notional-rate commission and point-based swap are expressed in the instrument's native profit-and-loss currency and must be converted by the consumer.
8
9use chrono::{Datelike, NaiveDateTime, NaiveTime, Weekday};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::types::Side;
14
15/// Largest accepted notional commission rate per side.
16pub const MAX_NOTIONAL_COMMISSION_RATE: f64 = 0.1;
17
18/// Largest accepted absolute per-lot commission or swap magnitude.
19pub const MAX_PER_LOT_COST: f64 = 1.0e6;
20
21/// Largest accepted absolute swap magnitude expressed in price points.
22pub const MAX_SWAP_POINTS: f64 = 1.0e6;
23
24/// Which currency a computed charge is denominated in.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum CostBasis {
28    /// Already expressed in the account currency; the consumer applies it directly.
29    AccountCurrency,
30    /// Expressed in the instrument's native profit-and-loss currency; the consumer converts it.
31    InstrumentNative,
32}
33
34/// What produced one charge.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum CostKind {
38    /// Commission charged on the fill that opens or adds to a position.
39    EntryCommission,
40    /// Commission charged on the fill that reduces or closes a position.
41    ExitCommission,
42    /// Overnight financing charged or credited at a rollover instant.
43    Swap,
44}
45
46impl CostKind {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::EntryCommission => "entry_commission",
50            Self::ExitCommission => "exit_commission",
51            Self::Swap => "swap",
52        }
53    }
54}
55
56/// One computed signed charge before any account conversion.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct CostCharge {
59    /// Positive reduces the balance, negative increases it.
60    pub amount: f64,
61    pub basis: CostBasis,
62}
63
64impl CostCharge {
65    fn account(amount: f64) -> Self {
66        Self {
67            amount,
68            basis: CostBasis::AccountCurrency,
69        }
70    }
71
72    fn native(amount: f64) -> Self {
73        Self {
74            amount,
75            basis: CostBasis::InstrumentNative,
76        }
77    }
78}
79
80/// How commission is charged for one instrument.
81///
82/// The tag accepts both the snake_case spelling this type writes and the PascalCase spelling the service request and cost file use, so a specification read back out of a stored run can be fed straight into a new request.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
85pub enum CommissionModel {
86    /// Fixed amount per 1.0 lot per side, already expressed in the account currency. The declared currency is validated against the run's account currency when one is known.
87    #[serde(alias = "PerLotPerSide")]
88    PerLotPerSide { amount: f64, currency: String },
89    /// Fraction of traded notional per side, charged in the instrument's native profit-and-loss currency. Buy and sell rates are separate because some venues charge asymmetrically; use equal values for a symmetric venue.
90    #[serde(alias = "NotionalRatePerSide")]
91    NotionalRatePerSide { buy_rate: f64, sell_rate: f64 },
92}
93
94impl CommissionModel {
95    /// Account currency this model is denominated in, when it declares one.
96    pub fn declared_currency(&self) -> Option<&str> {
97        match self {
98            Self::PerLotPerSide { currency, .. } => Some(currency.as_str()),
99            Self::NotionalRatePerSide { .. } => None,
100        }
101    }
102
103    fn rate_for(&self, side: Side) -> Option<f64> {
104        match self {
105            Self::NotionalRatePerSide {
106                buy_rate,
107                sell_rate,
108            } => Some(match side {
109                Side::Buy => *buy_rate,
110                Side::Sell => *sell_rate,
111            }),
112            Self::PerLotPerSide { .. } => None,
113        }
114    }
115
116    fn validate(&self) -> Result<(), CostValidationError> {
117        match self {
118            Self::PerLotPerSide { amount, currency } => {
119                if !amount.is_finite() || *amount < 0.0 || *amount > MAX_PER_LOT_COST {
120                    return Err(CostValidationError::InvalidCommissionAmount { amount: *amount });
121                }
122                if currency.trim().is_empty() {
123                    return Err(CostValidationError::MissingCurrency {
124                        field: "commission",
125                    });
126                }
127                Ok(())
128            }
129            Self::NotionalRatePerSide {
130                buy_rate,
131                sell_rate,
132            } => {
133                for rate in [*buy_rate, *sell_rate] {
134                    if !rate.is_finite() || !(0.0..=MAX_NOTIONAL_COMMISSION_RATE).contains(&rate) {
135                        return Err(CostValidationError::InvalidCommissionRate { rate });
136                    }
137                }
138                Ok(())
139            }
140        }
141    }
142}
143
144/// Nightly swap magnitude for one instrument, in broker sign convention where negative charges and positive credits.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(tag = "unit", rename_all = "snake_case", deny_unknown_fields)]
147pub enum SwapAmount {
148    /// Price points per 1.0 lot per night, applied in the instrument's native profit-and-loss currency.
149    #[serde(alias = "Points")]
150    Points { long: f64, short: f64 },
151    /// Amount per 1.0 lot per night, already expressed in the account currency.
152    #[serde(alias = "Currency")]
153    Currency {
154        long: f64,
155        short: f64,
156        currency: String,
157    },
158}
159
160impl SwapAmount {
161    /// Account currency this amount is denominated in, when it declares one.
162    pub fn declared_currency(&self) -> Option<&str> {
163        match self {
164            Self::Currency { currency, .. } => Some(currency.as_str()),
165            Self::Points { .. } => None,
166        }
167    }
168
169    fn value_for(&self, side: Side) -> f64 {
170        let (long, short) = match self {
171            Self::Points { long, short } => (*long, *short),
172            Self::Currency { long, short, .. } => (*long, *short),
173        };
174        match side {
175            Side::Buy => long,
176            Side::Sell => short,
177        }
178    }
179
180    fn validate(&self) -> Result<(), CostValidationError> {
181        match self {
182            Self::Points { long, short } => {
183                for value in [*long, *short] {
184                    if !value.is_finite() || value.abs() > MAX_SWAP_POINTS {
185                        return Err(CostValidationError::InvalidSwapAmount { amount: value });
186                    }
187                }
188                Ok(())
189            }
190            Self::Currency {
191                long,
192                short,
193                currency,
194            } => {
195                for value in [*long, *short] {
196                    if !value.is_finite() || value.abs() > MAX_PER_LOT_COST {
197                        return Err(CostValidationError::InvalidSwapAmount { amount: value });
198                    }
199                }
200                if currency.trim().is_empty() {
201                    return Err(CostValidationError::MissingCurrency { field: "swap" });
202                }
203                Ok(())
204            }
205        }
206    }
207}
208
209/// Nightly swap magnitude plus the rollover calendar that charges it.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct SwapSchedule {
213    pub amount: SwapAmount,
214    /// Rollover instant expressed in the replay's timestamp zone.
215    pub rollover: NaiveTime,
216    /// Weekday whose rollover charges three nights at once.
217    pub triple_weekday: Weekday,
218    /// Weekdays whose rollover charges nothing, normally the weekend.
219    #[serde(default = "default_skipped_weekdays")]
220    pub skipped_weekdays: Vec<Weekday>,
221}
222
223fn default_skipped_weekdays() -> Vec<Weekday> {
224    vec![Weekday::Sat, Weekday::Sun]
225}
226
227impl SwapSchedule {
228    /// Number of nights charged by the rollover that occurs on `instant`.
229    pub fn nights_at(&self, instant: NaiveDateTime) -> u32 {
230        let weekday = instant.weekday();
231        if self.skipped_weekdays.contains(&weekday) {
232            0
233        } else if weekday == self.triple_weekday {
234            3
235        } else {
236            1
237        }
238    }
239
240    fn validate(&self) -> Result<(), CostValidationError> {
241        self.amount.validate()?;
242        if self.skipped_weekdays.contains(&self.triple_weekday) {
243            return Err(CostValidationError::TripleWeekdaySkipped);
244        }
245        if self.skipped_weekdays.len() > 7 {
246            return Err(CostValidationError::TooManySkippedWeekdays);
247        }
248        Ok(())
249    }
250}
251
252/// Complete cost specification for one instrument.
253#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct InstrumentCosts {
256    #[serde(default)]
257    pub commission: Option<CommissionModel>,
258    #[serde(default)]
259    pub swap: Option<SwapSchedule>,
260}
261
262impl InstrumentCosts {
263    /// True when this specification can never produce a charge.
264    pub fn is_empty(&self) -> bool {
265        self.commission.is_none() && self.swap.is_none()
266    }
267
268    /// Signed commission for one fill of `lots` at `fill_price`.
269    ///
270    /// `contract_size` is the number of quote-currency units moved by one full lot and one price unit, matching the multiplier the accounting path already applies to profit and loss.
271    pub fn commission_for_fill(
272        &self,
273        side: Side,
274        lots: f64,
275        fill_price: f64,
276        contract_size: f64,
277    ) -> Option<CostCharge> {
278        let model = self.commission.as_ref()?;
279        if !lots.is_finite() || lots <= 0.0 {
280            return None;
281        }
282        match model {
283            CommissionModel::PerLotPerSide { amount, .. } => {
284                let charge = amount * lots;
285                charge.is_finite().then(|| CostCharge::account(charge))
286            }
287            CommissionModel::NotionalRatePerSide { .. } => {
288                let rate = model.rate_for(side)?;
289                if !fill_price.is_finite() || !contract_size.is_finite() {
290                    return None;
291                }
292                let charge = fill_price.abs() * lots * contract_size * rate;
293                charge.is_finite().then(|| CostCharge::native(charge))
294            }
295        }
296    }
297
298    /// Signed swap for holding `lots` across `nights` rollovers.
299    ///
300    /// `point_size` is one price point for the instrument, normally `10^-digits`. It is ignored when the schedule is expressed in account currency.
301    pub fn swap_for_rollover(
302        &self,
303        side: Side,
304        lots: f64,
305        contract_size: f64,
306        point_size: f64,
307        nights: u32,
308    ) -> Option<CostCharge> {
309        let schedule = self.swap.as_ref()?;
310        if nights == 0 || !lots.is_finite() || lots <= 0.0 {
311            return None;
312        }
313        let nights = f64::from(nights);
314        let value = schedule.amount.value_for(side);
315        match &schedule.amount {
316            SwapAmount::Points { .. } => {
317                if !point_size.is_finite() || point_size <= 0.0 || !contract_size.is_finite() {
318                    return None;
319                }
320                let credit = value * point_size * lots * contract_size * nights;
321                credit.is_finite().then(|| CostCharge::native(-credit))
322            }
323            SwapAmount::Currency { .. } => {
324                let credit = value * lots * nights;
325                credit.is_finite().then(|| CostCharge::account(-credit))
326            }
327        }
328    }
329
330    /// Reject non-finite, negative, or out-of-range configuration before a run starts.
331    pub fn validate(&self) -> Result<(), CostValidationError> {
332        if let Some(commission) = self.commission.as_ref() {
333            commission.validate()?;
334        }
335        if let Some(swap) = self.swap.as_ref() {
336            swap.validate()?;
337        }
338        Ok(())
339    }
340
341    /// Reject account-currency costs that declare a different currency than the run.
342    pub fn validate_against_account_currency(
343        &self,
344        account_currency: &str,
345    ) -> Result<(), CostValidationError> {
346        let declared = [
347            self.commission
348                .as_ref()
349                .and_then(CommissionModel::declared_currency),
350            self.swap
351                .as_ref()
352                .and_then(|swap| swap.amount.declared_currency()),
353        ];
354        for currency in declared.into_iter().flatten() {
355            if !currency.eq_ignore_ascii_case(account_currency) {
356                return Err(CostValidationError::CurrencyMismatch {
357                    declared: currency.to_owned(),
358                    account: account_currency.to_owned(),
359                });
360            }
361        }
362        Ok(())
363    }
364
365    /// True when a point-denominated swap requires an instrument point size.
366    pub fn requires_point_size(&self) -> bool {
367        matches!(
368            self.swap.as_ref().map(|swap| &swap.amount),
369            Some(SwapAmount::Points { .. })
370        )
371    }
372}
373
374/// Rollover instants strictly after `previous` and at or before `current`.
375///
376/// `previous` is `None` before the first observed timestamp, where no history exists to charge.
377pub fn rollover_instants(
378    previous: Option<NaiveDateTime>,
379    current: NaiveDateTime,
380    rollover: NaiveTime,
381) -> Vec<NaiveDateTime> {
382    let Some(previous) = previous else {
383        return Vec::new();
384    };
385    if current <= previous {
386        return Vec::new();
387    }
388    let mut instants = Vec::new();
389    let mut date = previous.date();
390    let last = current.date();
391    while date <= last {
392        let instant = date.and_time(rollover);
393        if instant > previous && instant <= current {
394            instants.push(instant);
395        }
396        let Some(next) = date.succ_opt() else {
397            break;
398        };
399        date = next;
400    }
401    instants
402}
403
404/// Configuration failures detected before a run starts.
405#[derive(Debug, Clone, PartialEq, Error)]
406pub enum CostValidationError {
407    #[error(
408        "commission amount {amount} must be finite, non-negative, and within the accepted range"
409    )]
410    InvalidCommissionAmount { amount: f64 },
411    #[error("commission rate {rate} must be finite and within [0, {MAX_NOTIONAL_COMMISSION_RATE}]")]
412    InvalidCommissionRate { rate: f64 },
413    #[error("swap amount {amount} must be finite and within the accepted range")]
414    InvalidSwapAmount { amount: f64 },
415    #[error("{field} currency must not be empty")]
416    MissingCurrency { field: &'static str },
417    #[error("triple swap weekday must not also be skipped")]
418    TripleWeekdaySkipped,
419    #[error("skipped swap weekdays must not exceed seven entries")]
420    TooManySkippedWeekdays,
421    #[error("declared cost currency {declared} does not match account currency {account}")]
422    CurrencyMismatch { declared: String, account: String },
423}
424
425#[cfg(test)]
426mod tests {
427    #[test]
428    fn a_cost_specification_decodes_from_either_tag_spelling() {
429        let snake = r#"{"commission":{"type":"per_lot_per_side","amount":3.5,"currency":"USD"},
430                        "swap":{"amount":{"unit":"points","long":-6.1,"short":1.9},
431                                "rollover":"22:00:00","triple_weekday":"Wed","skipped_weekdays":["Sat","Sun"]}}"#;
432        let pascal = r#"{"commission":{"type":"PerLotPerSide","amount":3.5,"currency":"USD"},
433                         "swap":{"amount":{"unit":"Points","long":-6.1,"short":1.9},
434                                 "rollover":"22:00:00","triple_weekday":"Wed","skipped_weekdays":["Sat","Sun"]}}"#;
435        let from_snake: InstrumentCosts = serde_json::from_str(snake).unwrap();
436        let from_pascal: InstrumentCosts = serde_json::from_str(pascal).unwrap();
437        assert_eq!(from_snake, from_pascal);
438
439        // A specification written by this type decodes again, which is what a stored run's metadata round-trips through.
440        let written = serde_json::to_string(&from_snake).unwrap();
441        let reread: InstrumentCosts = serde_json::from_str(&written).unwrap();
442        assert_eq!(reread, from_snake);
443    }
444
445    use super::*;
446    use chrono::NaiveDate;
447
448    fn at(year: i32, month: u32, day: u32, hour: u32) -> NaiveDateTime {
449        NaiveDate::from_ymd_opt(year, month, day)
450            .unwrap()
451            .and_hms_opt(hour, 0, 0)
452            .unwrap()
453    }
454
455    fn per_lot() -> InstrumentCosts {
456        InstrumentCosts {
457            commission: Some(CommissionModel::PerLotPerSide {
458                amount: 3.5,
459                currency: "USD".into(),
460            }),
461            swap: None,
462        }
463    }
464
465    fn notional(buy_rate: f64, sell_rate: f64) -> InstrumentCosts {
466        InstrumentCosts {
467            commission: Some(CommissionModel::NotionalRatePerSide {
468                buy_rate,
469                sell_rate,
470            }),
471            swap: None,
472        }
473    }
474
475    fn points_swap(long: f64, short: f64) -> InstrumentCosts {
476        InstrumentCosts {
477            commission: None,
478            swap: Some(SwapSchedule {
479                amount: SwapAmount::Points { long, short },
480                rollover: NaiveTime::from_hms_opt(22, 0, 0).unwrap(),
481                triple_weekday: Weekday::Wed,
482                skipped_weekdays: default_skipped_weekdays(),
483            }),
484        }
485    }
486
487    #[test]
488    fn per_lot_commission_scales_with_lots_in_account_currency() {
489        let charge = per_lot()
490            .commission_for_fill(Side::Buy, 2.0, 1.1, 100_000.0)
491            .unwrap();
492        assert_eq!(charge.amount, 7.0);
493        assert_eq!(charge.basis, CostBasis::AccountCurrency);
494    }
495
496    #[test]
497    fn notional_commission_uses_side_specific_rate() {
498        let costs = notional(0.005, 0.002);
499        let buy = costs
500            .commission_for_fill(Side::Buy, 1.0, 100.0, 1.0)
501            .unwrap();
502        let sell = costs
503            .commission_for_fill(Side::Sell, 1.0, 100.0, 1.0)
504            .unwrap();
505        assert_eq!(buy.amount, 0.5);
506        assert_eq!(sell.amount, 0.2);
507        assert_eq!(buy.basis, CostBasis::InstrumentNative);
508    }
509
510    #[test]
511    fn point_swap_charges_native_amount_per_night() {
512        let charge = points_swap(-6.1, 1.9)
513            .swap_for_rollover(Side::Buy, 1.0, 100_000.0, 1.0e-5, 1)
514            .unwrap();
515        assert!((charge.amount - 6.1).abs() < 1.0e-9);
516        assert_eq!(charge.basis, CostBasis::InstrumentNative);
517    }
518
519    #[test]
520    fn positive_point_swap_is_a_credit() {
521        let charge = points_swap(-6.1, 1.9)
522            .swap_for_rollover(Side::Sell, 1.0, 100_000.0, 1.0e-5, 1)
523            .unwrap();
524        assert!((charge.amount + 1.9).abs() < 1.0e-9);
525    }
526
527    #[test]
528    fn triple_weekday_multiplies_nights() {
529        let costs = points_swap(-6.1, 1.9);
530        let schedule = costs.swap.as_ref().unwrap();
531        assert_eq!(schedule.nights_at(at(2026, 6, 3, 22)), 3);
532        assert_eq!(schedule.nights_at(at(2026, 6, 4, 22)), 1);
533        assert_eq!(schedule.nights_at(at(2026, 6, 6, 22)), 0);
534        assert_eq!(schedule.nights_at(at(2026, 6, 7, 22)), 0);
535    }
536
537    #[test]
538    fn zero_nights_produce_no_charge() {
539        assert!(
540            points_swap(-6.1, 1.9)
541                .swap_for_rollover(Side::Buy, 1.0, 100_000.0, 1.0e-5, 0)
542                .is_none()
543        );
544    }
545
546    #[test]
547    fn account_currency_swap_ignores_point_size() {
548        let costs = InstrumentCosts {
549            commission: None,
550            swap: Some(SwapSchedule {
551                amount: SwapAmount::Currency {
552                    long: -2.0,
553                    short: 0.5,
554                    currency: "USD".into(),
555                },
556                rollover: NaiveTime::from_hms_opt(22, 0, 0).unwrap(),
557                triple_weekday: Weekday::Wed,
558                skipped_weekdays: default_skipped_weekdays(),
559            }),
560        };
561        let charge = costs
562            .swap_for_rollover(Side::Buy, 2.0, 100_000.0, f64::NAN, 3)
563            .unwrap();
564        assert_eq!(charge.amount, 12.0);
565        assert_eq!(charge.basis, CostBasis::AccountCurrency);
566    }
567
568    #[test]
569    fn rollover_instants_are_half_open_on_the_left() {
570        let rollover = NaiveTime::from_hms_opt(22, 0, 0).unwrap();
571        let instants = rollover_instants(Some(at(2026, 6, 1, 22)), at(2026, 6, 3, 22), rollover);
572        assert_eq!(instants, vec![at(2026, 6, 2, 22), at(2026, 6, 3, 22)]);
573    }
574
575    #[test]
576    fn rollover_instants_cover_a_weekend_gap() {
577        let rollover = NaiveTime::from_hms_opt(22, 0, 0).unwrap();
578        let instants = rollover_instants(Some(at(2026, 6, 5, 20)), at(2026, 6, 7, 23), rollover);
579        assert_eq!(
580            instants,
581            vec![at(2026, 6, 5, 22), at(2026, 6, 6, 22), at(2026, 6, 7, 22)]
582        );
583    }
584
585    #[test]
586    fn first_observed_timestamp_charges_nothing() {
587        let rollover = NaiveTime::from_hms_opt(22, 0, 0).unwrap();
588        assert!(rollover_instants(None, at(2026, 6, 5, 23), rollover).is_empty());
589    }
590
591    #[test]
592    fn validation_rejects_out_of_range_values() {
593        assert!(notional(0.5, 0.0).validate().is_err());
594        assert!(notional(-0.1, 0.0).validate().is_err());
595        assert!(
596            InstrumentCosts {
597                commission: Some(CommissionModel::PerLotPerSide {
598                    amount: -1.0,
599                    currency: "USD".into()
600                }),
601                swap: None,
602            }
603            .validate()
604            .is_err()
605        );
606        assert!(per_lot().validate().is_ok());
607        assert!(points_swap(-6.1, 1.9).validate().is_ok());
608    }
609
610    #[test]
611    fn validation_rejects_mismatched_account_currency() {
612        assert!(per_lot().validate_against_account_currency("EUR").is_err());
613        assert!(per_lot().validate_against_account_currency("usd").is_ok());
614        assert!(
615            notional(0.005, 0.005)
616                .validate_against_account_currency("EUR")
617                .is_ok()
618        );
619    }
620}