1use chrono::{Datelike, NaiveDateTime, NaiveTime, Weekday};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::types::Side;
14
15pub const MAX_NOTIONAL_COMMISSION_RATE: f64 = 0.1;
17
18pub const MAX_PER_LOT_COST: f64 = 1.0e6;
20
21pub const MAX_SWAP_POINTS: f64 = 1.0e6;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum CostBasis {
28 AccountCurrency,
30 InstrumentNative,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum CostKind {
38 EntryCommission,
40 ExitCommission,
42 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#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct CostCharge {
59 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
85pub enum CommissionModel {
86 #[serde(alias = "PerLotPerSide")]
88 PerLotPerSide { amount: f64, currency: String },
89 #[serde(alias = "NotionalRatePerSide")]
91 NotionalRatePerSide { buy_rate: f64, sell_rate: f64 },
92}
93
94impl CommissionModel {
95 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(tag = "unit", rename_all = "snake_case", deny_unknown_fields)]
147pub enum SwapAmount {
148 #[serde(alias = "Points")]
150 Points { long: f64, short: f64 },
151 #[serde(alias = "Currency")]
153 Currency {
154 long: f64,
155 short: f64,
156 currency: String,
157 },
158}
159
160impl SwapAmount {
161 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct SwapSchedule {
213 pub amount: SwapAmount,
214 pub rollover: NaiveTime,
216 pub triple_weekday: Weekday,
218 #[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 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#[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 pub fn is_empty(&self) -> bool {
265 self.commission.is_none() && self.swap.is_none()
266 }
267
268 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 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 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 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 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
374pub 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#[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 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}