Skip to main content

quant_metrics/
trading.rs

1//! Trading performance metrics.
2//!
3//! Metrics computed from individual trade P&L values.
4
5use rust_decimal::Decimal;
6
7use crate::MetricsError;
8
9/// Calculate win rate as a percentage.
10///
11/// Formula: winning_trades / total_trades * 100
12///
13/// # Arguments
14/// * `trade_pnls` - P&L of each trade (positive = win, negative = loss)
15///
16/// # Returns
17/// Win rate as percentage (0-100).
18///
19/// # Example
20/// ```
21/// use quant_metrics::win_rate;
22/// use rust_decimal_macros::dec;
23///
24/// let trades = vec![dec!(100), dec!(-50), dec!(75), dec!(-25), dec!(80)];
25/// assert_eq!(win_rate(&trades).unwrap(), dec!(60)); // 3 wins / 5 trades
26/// ```
27pub fn win_rate(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
28    if trade_pnls.is_empty() {
29        return Err(MetricsError::InsufficientData {
30            required: 1,
31            actual: 0,
32        });
33    }
34
35    let wins = trade_pnls
36        .iter()
37        .filter(|&&pnl| pnl > Decimal::ZERO)
38        .count();
39    let total = trade_pnls.len();
40
41    Ok(Decimal::from(wins as u64) / Decimal::from(total as u64) * Decimal::from(100))
42}
43
44/// Calculate profit factor.
45///
46/// Formula: gross_profit / gross_loss
47///
48/// A profit factor > 1 indicates profitable trading.
49///
50/// # Arguments
51/// * `trade_pnls` - P&L of each trade
52///
53/// # Returns
54/// Profit factor (gross profit / gross loss).
55pub fn profit_factor(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
56    if trade_pnls.is_empty() {
57        return Err(MetricsError::InsufficientData {
58            required: 1,
59            actual: 0,
60        });
61    }
62
63    let gross_profit: Decimal = trade_pnls.iter().filter(|&&pnl| pnl > Decimal::ZERO).sum();
64
65    let gross_loss: Decimal = trade_pnls
66        .iter()
67        .filter(|&&pnl| pnl < Decimal::ZERO)
68        .map(|pnl| pnl.abs())
69        .sum();
70
71    if gross_loss == Decimal::ZERO {
72        if gross_profit == Decimal::ZERO {
73            return Ok(Decimal::ONE); // No trades = neutral
74        }
75        // All wins, no losses = infinite profit factor
76        return Err(MetricsError::DivisionByZero {
77            context: "no losing trades",
78        });
79    }
80
81    Ok(gross_profit / gross_loss)
82}
83
84/// Calculate average winning trade.
85///
86/// # Arguments
87/// * `trade_pnls` - P&L of each trade
88///
89/// # Returns
90/// Average P&L of winning trades.
91pub fn avg_win(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
92    let wins: Vec<Decimal> = trade_pnls
93        .iter()
94        .filter(|&&pnl| pnl > Decimal::ZERO)
95        .copied()
96        .collect();
97
98    if wins.is_empty() {
99        return Err(MetricsError::InsufficientData {
100            required: 1,
101            actual: 0,
102        });
103    }
104
105    let sum: Decimal = wins.iter().sum();
106    Ok(sum / Decimal::from(wins.len() as u64))
107}
108
109/// Calculate average losing trade.
110///
111/// # Arguments
112/// * `trade_pnls` - P&L of each trade
113///
114/// # Returns
115/// Average P&L of losing trades (as negative number).
116pub fn avg_loss(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
117    let losses: Vec<Decimal> = trade_pnls
118        .iter()
119        .filter(|&&pnl| pnl < Decimal::ZERO)
120        .copied()
121        .collect();
122
123    if losses.is_empty() {
124        return Err(MetricsError::InsufficientData {
125            required: 1,
126            actual: 0,
127        });
128    }
129
130    let sum: Decimal = losses.iter().sum();
131    Ok(sum / Decimal::from(losses.len() as u64))
132}
133
134/// Calculate expectancy (expected value per trade).
135///
136/// Formula: (win_rate * avg_win) + ((1 - win_rate) * avg_loss)
137///
138/// # Arguments
139/// * `trade_pnls` - P&L of each trade
140///
141/// # Returns
142/// Expected value per trade.
143pub fn expectancy(trade_pnls: &[Decimal]) -> Result<Decimal, MetricsError> {
144    if trade_pnls.is_empty() {
145        return Err(MetricsError::InsufficientData {
146            required: 1,
147            actual: 0,
148        });
149    }
150
151    let wins: Vec<Decimal> = trade_pnls
152        .iter()
153        .filter(|&&pnl| pnl > Decimal::ZERO)
154        .copied()
155        .collect();
156
157    let losses: Vec<Decimal> = trade_pnls
158        .iter()
159        .filter(|&&pnl| pnl < Decimal::ZERO)
160        .copied()
161        .collect();
162
163    let total = trade_pnls.len() as u64;
164    let win_pct = Decimal::from(wins.len() as u64) / Decimal::from(total);
165    let loss_pct = Decimal::ONE - win_pct;
166
167    let avg_w = if wins.is_empty() {
168        Decimal::ZERO
169    } else {
170        wins.iter().sum::<Decimal>() / Decimal::from(wins.len() as u64)
171    };
172
173    let avg_l = if losses.is_empty() {
174        Decimal::ZERO
175    } else {
176        losses.iter().sum::<Decimal>() / Decimal::from(losses.len() as u64)
177    };
178
179    Ok((win_pct * avg_w) + (loss_pct * avg_l))
180}
181
182#[cfg(test)]
183#[path = "trading_tests.rs"]
184mod tests;