Skip to main content

kestrel_chartkit/
contract.rs

1//! Provider-neutral instrument contract specifications, currency definitions, and valuation models.
2//!
3//! Provides explicit contract units (multiplier, lot size, currencies) and FX conversion
4//! so position sizing, notionals, and P&L can be computed consistently in an account currency.
5
6use std::fmt;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11/// An ISO currency code or currency symbol identifier (e.g. "EUR", "USD", "GBP", "JPY").
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub struct Currency(String);
15
16impl Currency {
17    /// Creates a new normalized currency identifier (trimmed, uppercase).
18    pub fn new(code: impl AsRef<str>) -> Self {
19        Self(code.as_ref().trim().to_uppercase())
20    }
21
22    pub fn eur() -> Self {
23        Self("EUR".to_string())
24    }
25
26    pub fn usd() -> Self {
27        Self("USD".to_string())
28    }
29
30    pub fn gbp() -> Self {
31        Self("GBP".to_string())
32    }
33
34    pub fn chf() -> Self {
35        Self("CHF".to_string())
36    }
37
38    pub fn jpy() -> Self {
39        Self("JPY".to_string())
40    }
41
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl fmt::Display for Currency {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(&self.0)
50    }
51}
52
53impl From<&str> for Currency {
54    fn from(s: &str) -> Self {
55        Self::new(s)
56    }
57}
58
59impl From<String> for Currency {
60    fn from(s: String) -> Self {
61        Self::new(s)
62    }
63}
64
65/// Category of traded financial contract.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
68pub enum InstrumentType {
69    /// Standard stock / equity share. Multiplier is typically 1.0.
70    Equity,
71    /// Linear commodity, index, or rate future with a fixed point multiplier.
72    LinearFuture,
73    /// Spot or forward Foreign Exchange pair.
74    Forex,
75    /// Linear Contract For Difference.
76    Cfd,
77    /// Spot Cryptocurrency.
78    CryptoSpot,
79    /// Derivative financial option contract.
80    Option,
81}
82
83impl InstrumentType {
84    /// Returns true if this instrument uses standard linear valuation (`price * quantity * multiplier`).
85    ///
86    /// Inverse, quanto, or non-linear option contracts are not linear.
87    pub fn is_linear(&self) -> bool {
88        match self {
89            Self::Equity | Self::LinearFuture | Self::Forex | Self::Cfd | Self::CryptoSpot => true,
90            Self::Option => false,
91        }
92    }
93}
94
95/// Detailed operative contract specifications complementing [`crate::model::InstrumentMeta`].
96#[derive(Debug, Clone, PartialEq)]
97#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
98pub struct ContractSpec {
99    /// Currency in which the instrument's market price is quoted (e.g. USD for AAPL, EUR for DAX).
100    pub price_currency: Currency,
101    /// Currency in which margin and cash settlements occur.
102    pub settlement_currency: Currency,
103    /// Contract point multiplier (e.g. 1.0 for equities, 25.0 for DAX future, 100.0 for SPX).
104    pub multiplier: f64,
105    /// Minimum increment of order quantity (lot step size, e.g. 1.0 for futures, 0.0001 for crypto).
106    pub quantity_step: f64,
107    /// Minimum allowed order quantity. Must be >= `quantity_step`.
108    pub min_quantity: f64,
109    /// The structural category of the instrument.
110    pub instrument_type: InstrumentType,
111}
112
113impl Default for ContractSpec {
114    fn default() -> Self {
115        Self {
116            price_currency: Currency::usd(),
117            settlement_currency: Currency::usd(),
118            multiplier: 1.0,
119            quantity_step: 1.0,
120            min_quantity: 1.0,
121            instrument_type: InstrumentType::Equity,
122        }
123    }
124}
125
126/// Errors when validating a [`ContractSpec`].
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum ContractSpecError {
129    NonPositiveMultiplier,
130    NonFiniteMultiplier,
131    NonPositiveQuantityStep,
132    NonFiniteQuantityStep,
133    InvalidMinQuantity,
134    EmptyPriceCurrency,
135    EmptySettlementCurrency,
136}
137
138impl fmt::Display for ContractSpecError {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::NonPositiveMultiplier => f.write_str("multiplier must be > 0"),
142            Self::NonFiniteMultiplier => f.write_str("multiplier must be finite"),
143            Self::NonPositiveQuantityStep => f.write_str("quantity_step must be > 0"),
144            Self::NonFiniteQuantityStep => f.write_str("quantity_step must be finite"),
145            Self::InvalidMinQuantity => {
146                f.write_str("min_quantity must be finite and >= quantity_step")
147            }
148            Self::EmptyPriceCurrency => f.write_str("price_currency must not be empty"),
149            Self::EmptySettlementCurrency => f.write_str("settlement_currency must not be empty"),
150        }
151    }
152}
153
154impl std::error::Error for ContractSpecError {}
155
156impl ContractSpec {
157    /// Validates the contractual consistency of the specification.
158    pub fn validate(&self) -> Result<(), ContractSpecError> {
159        if !self.multiplier.is_finite() {
160            return Err(ContractSpecError::NonFiniteMultiplier);
161        }
162        if self.multiplier <= 0.0 {
163            return Err(ContractSpecError::NonPositiveMultiplier);
164        }
165        if !self.quantity_step.is_finite() {
166            return Err(ContractSpecError::NonFiniteQuantityStep);
167        }
168        if self.quantity_step <= 0.0 {
169            return Err(ContractSpecError::NonPositiveQuantityStep);
170        }
171        if !self.min_quantity.is_finite() || self.min_quantity < self.quantity_step {
172            return Err(ContractSpecError::InvalidMinQuantity);
173        }
174        if self.price_currency.as_str().is_empty() {
175            return Err(ContractSpecError::EmptyPriceCurrency);
176        }
177        if self.settlement_currency.as_str().is_empty() {
178            return Err(ContractSpecError::EmptySettlementCurrency);
179        }
180        Ok(())
181    }
182
183    /// Rounds `quantity` strictly downward to the nearest integer multiple of `quantity_step`.
184    ///
185    /// If the resulting quantity is strictly less than `min_quantity`, returns `0.0`
186    /// (no trade generated below minimum threshold).
187    pub fn round_quantity_down(&self, quantity: f64) -> f64 {
188        if !quantity.is_finite() || quantity <= 0.0 || self.quantity_step <= 0.0 {
189            return 0.0;
190        }
191        let steps = (quantity / self.quantity_step + 1e-12).floor();
192        let rounded = steps * self.quantity_step;
193        // Use a small epsilon to avoid floating point precision edge cases (e.g. 0.99999999999 < 1.0)
194        if rounded + 1e-12 < self.min_quantity {
195            0.0
196        } else {
197            rounded
198        }
199    }
200}
201
202/// An FX quote between two currencies: `base_currency / quote_currency = rate`.
203///
204/// Example: `EUR / USD = 1.08` means 1 EUR = 1.08 USD.
205#[derive(Debug, Clone, PartialEq)]
206#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
207pub struct FxRate {
208    pub base: Currency,
209    pub quote: Currency,
210    pub rate: f64,
211    /// Epoch timestamp of the quote in seconds or milliseconds (caller-defined convention).
212    pub timestamp: i64,
213}
214
215impl FxRate {
216    pub fn new(base: impl Into<Currency>, quote: impl Into<Currency>, rate: f64) -> Self {
217        Self {
218            base: base.into(),
219            quote: quote.into(),
220            rate,
221            timestamp: 0,
222        }
223    }
224
225    /// Converts an `amount` from `from` currency to `to` currency using this FX rate.
226    ///
227    /// If `from == to`, returns `Ok(amount)` without needing a valid FX quote.
228    /// If missing or inverted, computes the direct or reciprocal rate.
229    pub fn convert(
230        &self,
231        amount: f64,
232        from: &Currency,
233        to: &Currency,
234    ) -> Result<f64, FxConversionError> {
235        if from == to {
236            return Ok(amount);
237        }
238        if !self.rate.is_finite() || self.rate <= 0.0 {
239            return Err(FxConversionError::InvalidRate(self.rate));
240        }
241
242        if from == &self.base && to == &self.quote {
243            // e.g. amount in EUR -> USD: amount * rate
244            Ok(amount * self.rate)
245        } else if from == &self.quote && to == &self.base {
246            // e.g. amount in USD -> EUR: amount / rate
247            Ok(amount / self.rate)
248        } else {
249            Err(FxConversionError::MissingPair {
250                from: from.clone(),
251                to: to.clone(),
252            })
253        }
254    }
255}
256
257/// Errors during foreign exchange currency conversion.
258#[derive(Debug, Clone, PartialEq)]
259pub enum FxConversionError {
260    InvalidRate(f64),
261    MissingPair { from: Currency, to: Currency },
262    StaleRate { age_seconds: i64, max_age: i64 },
263}
264
265impl fmt::Display for FxConversionError {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::InvalidRate(r) => write!(f, "invalid non-positive FX rate: {r}"),
269            Self::MissingPair { from, to } => {
270                write!(f, "no FX rate available to convert from {from} to {to}")
271            }
272            Self::StaleRate {
273                age_seconds,
274                max_age,
275            } => write!(
276                f,
277                "FX rate is stale: age {age_seconds}s exceeds max {max_age}s"
278            ),
279        }
280    }
281}
282
283impl std::error::Error for FxConversionError {}
284
285/// Errors during contract valuation and sizing calculations.
286#[derive(Debug, Clone, PartialEq)]
287pub enum ValuationError {
288    InvalidContract(ContractSpecError),
289    FxUnavailable(FxConversionError),
290    NonPositivePrice(f64),
291    NonFiniteInput(&'static str),
292}
293
294impl fmt::Display for ValuationError {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        match self {
297            Self::InvalidContract(e) => write!(f, "invalid contract specification: {e}"),
298            Self::FxUnavailable(e) => write!(f, "currency conversion failed: {e}"),
299            Self::NonPositivePrice(p) => write!(f, "price must be positive: {p}"),
300            Self::NonFiniteInput(field) => write!(f, "input {field} must be finite"),
301        }
302    }
303}
304
305impl std::error::Error for ValuationError {}
306
307impl From<ContractSpecError> for ValuationError {
308    fn from(e: ContractSpecError) -> Self {
309        Self::InvalidContract(e)
310    }
311}
312
313impl From<FxConversionError> for ValuationError {
314    fn from(e: FxConversionError) -> Self {
315        Self::FxUnavailable(e)
316    }
317}
318
319/// Computes the total notional value of a position in account currency:
320/// `quantity * price * multiplier * fx_rate`.
321///
322/// `fx_rate` converts from `spec.price_currency` to the account currency.
323/// If `spec.price_currency == account_currency`, pass `Some(1.0)` or use [`FxRate::convert`].
324/// If FX is required but unavailable, passing `None` returns [`ValuationError::FxUnavailable`].
325pub fn notional_value(
326    price: f64,
327    quantity: f64,
328    spec: &ContractSpec,
329    fx_to_account: Option<f64>,
330) -> Result<f64, ValuationError> {
331    spec.validate()?;
332    if !price.is_finite() || price <= 0.0 {
333        return Err(ValuationError::NonPositivePrice(price));
334    }
335    if !quantity.is_finite() || quantity < 0.0 {
336        return Err(ValuationError::NonFiniteInput("quantity"));
337    }
338    let fx = match fx_to_account {
339        Some(rate) if rate.is_finite() && rate > 0.0 => rate,
340        Some(invalid) => {
341            return Err(ValuationError::FxUnavailable(
342                FxConversionError::InvalidRate(invalid),
343            ))
344        }
345        None => {
346            return Err(ValuationError::FxUnavailable(
347                FxConversionError::MissingPair {
348                    from: spec.price_currency.clone(),
349                    to: spec.settlement_currency.clone(),
350                },
351            ))
352        }
353    };
354    Ok(quantity * price * spec.multiplier * fx)
355}
356
357/// Computes the stop risk amount in account currency:
358/// `quantity * |entry - stop| * multiplier * fx_rate`.
359pub fn stop_risk_amount(
360    entry: f64,
361    stop: f64,
362    quantity: f64,
363    spec: &ContractSpec,
364    fx_to_account: Option<f64>,
365) -> Result<f64, ValuationError> {
366    spec.validate()?;
367    if !entry.is_finite() || entry <= 0.0 {
368        return Err(ValuationError::NonPositivePrice(entry));
369    }
370    if !stop.is_finite() || stop <= 0.0 {
371        return Err(ValuationError::NonPositivePrice(stop));
372    }
373    if !quantity.is_finite() || quantity < 0.0 {
374        return Err(ValuationError::NonFiniteInput("quantity"));
375    }
376    let fx = match fx_to_account {
377        Some(rate) if rate.is_finite() && rate > 0.0 => rate,
378        Some(invalid) => {
379            return Err(ValuationError::FxUnavailable(
380                FxConversionError::InvalidRate(invalid),
381            ))
382        }
383        None => {
384            return Err(ValuationError::FxUnavailable(
385                FxConversionError::MissingPair {
386                    from: spec.price_currency.clone(),
387                    to: spec.settlement_currency.clone(),
388                },
389            ))
390        }
391    };
392    let price_diff = (entry - stop).abs();
393    Ok(quantity * price_diff * spec.multiplier * fx)
394}
395
396/// Computes the realized or unrealized P&L in account currency:
397/// `(exit - entry) * direction * quantity * multiplier * fx_rate`.
398pub fn contract_pnl(
399    entry: f64,
400    exit: f64,
401    quantity: f64,
402    is_long: bool,
403    spec: &ContractSpec,
404    fx_to_account: Option<f64>,
405) -> Result<f64, ValuationError> {
406    spec.validate()?;
407    if !entry.is_finite() || entry <= 0.0 {
408        return Err(ValuationError::NonPositivePrice(entry));
409    }
410    if !exit.is_finite() || exit <= 0.0 {
411        return Err(ValuationError::NonPositivePrice(exit));
412    }
413    if !quantity.is_finite() || quantity < 0.0 {
414        return Err(ValuationError::NonFiniteInput("quantity"));
415    }
416    let fx = match fx_to_account {
417        Some(rate) if rate.is_finite() && rate > 0.0 => rate,
418        Some(invalid) => {
419            return Err(ValuationError::FxUnavailable(
420                FxConversionError::InvalidRate(invalid),
421            ))
422        }
423        None => {
424            return Err(ValuationError::FxUnavailable(
425                FxConversionError::MissingPair {
426                    from: spec.price_currency.clone(),
427                    to: spec.settlement_currency.clone(),
428                },
429            ))
430        }
431    };
432    let diff = if is_long { exit - entry } else { entry - exit };
433    Ok(diff * quantity * spec.multiplier * fx)
434}
435
436/// Computes the single-tick value in account currency:
437/// `tick_size * multiplier * fx_rate`.
438pub fn contract_tick_value(
439    tick_size: f64,
440    spec: &ContractSpec,
441    fx_to_account: Option<f64>,
442) -> Result<f64, ValuationError> {
443    spec.validate()?;
444    if !tick_size.is_finite() || tick_size <= 0.0 {
445        return Err(ValuationError::NonPositivePrice(tick_size));
446    }
447    let fx = match fx_to_account {
448        Some(rate) if rate.is_finite() && rate > 0.0 => rate,
449        Some(invalid) => {
450            return Err(ValuationError::FxUnavailable(
451                FxConversionError::InvalidRate(invalid),
452            ))
453        }
454        None => {
455            return Err(ValuationError::FxUnavailable(
456                FxConversionError::MissingPair {
457                    from: spec.price_currency.clone(),
458                    to: spec.settlement_currency.clone(),
459                },
460            ))
461        }
462    };
463    Ok(tick_size * spec.multiplier * fx)
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_contract_spec_validation() {
472        let default_spec = ContractSpec::default();
473        assert_eq!(default_spec.validate(), Ok(()));
474
475        let bad_multiplier = ContractSpec {
476            multiplier: 0.0,
477            ..ContractSpec::default()
478        };
479        assert_eq!(
480            bad_multiplier.validate(),
481            Err(ContractSpecError::NonPositiveMultiplier)
482        );
483
484        let bad_step = ContractSpec {
485            quantity_step: -1.0,
486            ..ContractSpec::default()
487        };
488        assert_eq!(
489            bad_step.validate(),
490            Err(ContractSpecError::NonPositiveQuantityStep)
491        );
492
493        let bad_min = ContractSpec {
494            quantity_step: 10.0,
495            min_quantity: 5.0,
496            ..ContractSpec::default()
497        };
498        assert_eq!(
499            bad_min.validate(),
500            Err(ContractSpecError::InvalidMinQuantity)
501        );
502    }
503
504    #[test]
505    fn test_quantity_down_rounding() {
506        let spec = ContractSpec {
507            quantity_step: 0.5,
508            min_quantity: 1.0,
509            ..ContractSpec::default()
510        };
511        assert_eq!(spec.round_quantity_down(2.8), 2.5);
512        assert_eq!(spec.round_quantity_down(1.0), 1.0);
513        // Strictly below min_quantity -> 0.0 (no trade)
514        assert_eq!(spec.round_quantity_down(0.9), 0.0);
515        assert_eq!(spec.round_quantity_down(0.5), 0.0);
516    }
517
518    #[test]
519    fn test_fx_conversion() {
520        let fx = FxRate::new("EUR", "USD", 1.08);
521
522        // Same currency -> identity without rate check
523        assert_eq!(
524            fx.convert(100.0, &Currency::eur(), &Currency::eur())
525                .unwrap(),
526            100.0
527        );
528
529        // Direct EUR -> USD
530        let usd = fx
531            .convert(100.0, &Currency::eur(), &Currency::usd())
532            .unwrap();
533        assert!((usd - 108.0).abs() < 1e-9);
534
535        // Reciprocal USD -> EUR
536        let eur = fx
537            .convert(108.0, &Currency::usd(), &Currency::eur())
538            .unwrap();
539        assert!((eur - 100.0).abs() < 1e-9);
540
541        // Missing pair
542        let err = fx.convert(100.0, &Currency::gbp(), &Currency::usd());
543        assert!(matches!(err, Err(FxConversionError::MissingPair { .. })));
544    }
545}