neural_trader/
risk.rs

1//! Risk management bindings for Node.js
2//!
3//! Provides NAPI bindings for risk calculations including:
4//! - Value at Risk (VaR)
5//! - Conditional VaR (CVaR)
6//! - Kelly Criterion
7//! - Drawdown analysis
8//! - Position sizing
9
10use napi::bindgen_prelude::*;
11use napi_derive::napi;
12
13/// Risk calculation configuration
14#[napi(object)]
15pub struct RiskConfig {
16    pub confidence_level: f64,  // 0.95 for 95% confidence
17    pub lookback_periods: u32,  // Number of historical periods
18    pub method: String,         // "parametric", "historical", "monte_carlo"
19}
20
21impl Default for RiskConfig {
22    fn default() -> Self {
23        Self {
24            confidence_level: 0.95,
25            lookback_periods: 252,  // 1 year of daily data
26            method: "historical".to_string(),
27        }
28    }
29}
30
31/// VaR calculation result
32#[napi(object)]
33pub struct VaRResult {
34    pub var_amount: f64,
35    pub var_percentage: f64,
36    pub confidence_level: f64,
37    pub method: String,
38    pub portfolio_value: f64,
39}
40
41/// CVaR (Expected Shortfall) result
42#[napi(object)]
43pub struct CVaRResult {
44    pub cvar_amount: f64,
45    pub cvar_percentage: f64,
46    pub var_amount: f64,
47    pub confidence_level: f64,
48}
49
50/// Drawdown metrics
51#[napi(object)]
52pub struct DrawdownMetrics {
53    pub max_drawdown: f64,
54    pub max_drawdown_duration: u32,  // In periods
55    pub current_drawdown: f64,
56    pub recovery_factor: f64,
57}
58
59/// Kelly Criterion result for position sizing
60#[napi(object)]
61pub struct KellyResult {
62    pub kelly_fraction: f64,
63    pub half_kelly: f64,
64    pub quarter_kelly: f64,
65    pub win_rate: f64,
66    pub avg_win: f64,
67    pub avg_loss: f64,
68}
69
70/// Position sizing recommendation
71#[napi(object)]
72pub struct PositionSize {
73    pub shares: u32,
74    pub dollar_amount: f64,
75    pub percentage_of_portfolio: f64,
76    pub max_loss: f64,
77    pub reasoning: String,
78}
79
80/// Risk manager for portfolio risk calculations
81#[napi]
82pub struct RiskManager {
83    config: RiskConfig,
84}
85
86#[napi]
87impl RiskManager {
88    /// Create a new risk manager
89    #[napi(constructor)]
90    pub fn new(config: RiskConfig) -> Self {
91        tracing::info!(
92            "Creating risk manager with {} confidence, {} method",
93            config.confidence_level,
94            config.method
95        );
96
97        Self { config }
98    }
99
100    /// Calculate Value at Risk
101    #[napi]
102    pub fn calculate_var(&self, returns: Vec<f64>, portfolio_value: f64) -> Result<VaRResult> {
103        if returns.is_empty() {
104            return Err(Error::from_reason("Returns data is empty"));
105        }
106
107        tracing::debug!("Calculating VaR for {} returns", returns.len());
108
109        // TODO: Implement actual VaR calculation using nt-risk crate
110        // For now, use simplified historical VaR
111        let mut sorted_returns = returns.clone();
112        sorted_returns.sort_by(|a, b| a.partial_cmp(b).unwrap());
113
114        let index = ((1.0 - self.config.confidence_level) * sorted_returns.len() as f64) as usize;
115        let var_percentage = -sorted_returns[index.min(sorted_returns.len() - 1)];
116        let var_amount = var_percentage * portfolio_value;
117
118        Ok(VaRResult {
119            var_amount,
120            var_percentage,
121            confidence_level: self.config.confidence_level,
122            method: self.config.method.clone(),
123            portfolio_value,
124        })
125    }
126
127    /// Calculate Conditional VaR (Expected Shortfall)
128    #[napi]
129    pub fn calculate_cvar(&self, returns: Vec<f64>, portfolio_value: f64) -> Result<CVaRResult> {
130        if returns.is_empty() {
131            return Err(Error::from_reason("Returns data is empty"));
132        }
133
134        tracing::debug!("Calculating CVaR for {} returns", returns.len());
135
136        // First calculate VaR
137        let var_result = self.calculate_var(returns.clone(), portfolio_value)?;
138
139        // TODO: Implement actual CVaR calculation using nt-risk crate
140        // For now, use simplified calculation
141        let mut sorted_returns = returns;
142        sorted_returns.sort_by(|a, b| a.partial_cmp(b).unwrap());
143
144        let var_threshold = -var_result.var_percentage;
145        let tail_returns: Vec<f64> = sorted_returns
146            .iter()
147            .filter(|&&r| r <= var_threshold)
148            .copied()
149            .collect();
150
151        let cvar_percentage = if !tail_returns.is_empty() {
152            -tail_returns.iter().sum::<f64>() / tail_returns.len() as f64
153        } else {
154            var_result.var_percentage
155        };
156
157        let cvar_amount = cvar_percentage * portfolio_value;
158
159        Ok(CVaRResult {
160            cvar_amount,
161            cvar_percentage,
162            var_amount: var_result.var_amount,
163            confidence_level: self.config.confidence_level,
164        })
165    }
166
167    /// Calculate Kelly Criterion for position sizing
168    #[napi]
169    pub fn calculate_kelly(
170        &self,
171        win_rate: f64,
172        avg_win: f64,
173        avg_loss: f64,
174    ) -> Result<KellyResult> {
175        if !(0.0..=1.0).contains(&win_rate) {
176            return Err(Error::from_reason("Win rate must be between 0 and 1"));
177        }
178
179        if avg_win <= 0.0 || avg_loss <= 0.0 {
180            return Err(Error::from_reason("Average win and loss must be positive"));
181        }
182
183        tracing::debug!(
184            "Calculating Kelly: win_rate={}, avg_win={}, avg_loss={}",
185            win_rate,
186            avg_win,
187            avg_loss
188        );
189
190        // Kelly formula: f = (p * b - q) / b
191        // where p = win rate, q = loss rate, b = win/loss ratio
192        let b = avg_win / avg_loss;
193        let p = win_rate;
194        let q = 1.0 - win_rate;
195
196        let kelly_fraction = ((p * b) - q) / b;
197        let kelly_fraction = kelly_fraction.max(0.0).min(1.0); // Clamp between 0 and 1
198
199        Ok(KellyResult {
200            kelly_fraction,
201            half_kelly: kelly_fraction / 2.0,
202            quarter_kelly: kelly_fraction / 4.0,
203            win_rate,
204            avg_win,
205            avg_loss,
206        })
207    }
208
209    /// Calculate drawdown metrics
210    #[napi]
211    pub fn calculate_drawdown(&self, equity_curve: Vec<f64>) -> Result<DrawdownMetrics> {
212        if equity_curve.is_empty() {
213            return Err(Error::from_reason("Equity curve is empty"));
214        }
215
216        tracing::debug!("Calculating drawdown for {} data points", equity_curve.len());
217
218        // TODO: Implement actual drawdown calculation using nt-risk crate
219        let mut max_drawdown = 0.0;
220        let mut max_drawdown_duration = 0u32;
221        let mut current_drawdown = 0.0;
222        let mut peak = equity_curve[0];
223        let mut current_duration = 0u32;
224
225        for &value in &equity_curve {
226            if value > peak {
227                peak = value;
228                current_duration = 0;
229            } else {
230                current_duration += 1;
231                let drawdown = (peak - value) / peak;
232                current_drawdown = drawdown;
233
234                if drawdown > max_drawdown {
235                    max_drawdown = drawdown;
236                    max_drawdown_duration = current_duration;
237                }
238            }
239        }
240
241        let recovery_factor = if max_drawdown > 0.0 {
242            let total_return = (equity_curve.last().unwrap() - equity_curve[0]) / equity_curve[0];
243            total_return / max_drawdown
244        } else {
245            0.0
246        };
247
248        Ok(DrawdownMetrics {
249            max_drawdown,
250            max_drawdown_duration,
251            current_drawdown,
252            recovery_factor,
253        })
254    }
255
256    /// Calculate recommended position size
257    #[napi]
258    pub fn calculate_position_size(
259        &self,
260        portfolio_value: f64,
261        price_per_share: f64,
262        risk_per_trade: f64,  // As percentage (e.g., 0.02 for 2%)
263        stop_loss_distance: f64,  // In dollars
264    ) -> Result<PositionSize> {
265        if portfolio_value <= 0.0 {
266            return Err(Error::from_reason("Portfolio value must be positive"));
267        }
268
269        if price_per_share <= 0.0 {
270            return Err(Error::from_reason("Price per share must be positive"));
271        }
272
273        if risk_per_trade <= 0.0 || risk_per_trade > 1.0 {
274            return Err(Error::from_reason("Risk per trade must be between 0 and 1"));
275        }
276
277        tracing::debug!(
278            "Calculating position size: portfolio=${}, price=${}, risk={}%, stop=${}",
279            portfolio_value,
280            price_per_share,
281            risk_per_trade * 100.0,
282            stop_loss_distance
283        );
284
285        // Position size = (Account Value × Risk per Trade) / Stop Loss Distance
286        let max_risk_amount = portfolio_value * risk_per_trade;
287
288        let shares = if stop_loss_distance > 0.0 {
289            (max_risk_amount / stop_loss_distance).floor() as u32
290        } else {
291            // If no stop loss, use simple percentage of portfolio
292            (portfolio_value * risk_per_trade / price_per_share).floor() as u32
293        };
294
295        let dollar_amount = shares as f64 * price_per_share;
296        let percentage = dollar_amount / portfolio_value;
297
298        Ok(PositionSize {
299            shares,
300            dollar_amount,
301            percentage_of_portfolio: percentage,
302            max_loss: max_risk_amount,
303            reasoning: format!(
304                "Risking ${:.2} ({}%) on this trade with {} shares",
305                max_risk_amount,
306                risk_per_trade * 100.0,
307                shares
308            ),
309        })
310    }
311
312    /// Validate if a position passes risk limits
313    #[napi]
314    pub fn validate_position(
315        &self,
316        position_size: f64,
317        portfolio_value: f64,
318        max_position_percentage: f64,
319    ) -> Result<bool> {
320        let position_percentage = position_size / portfolio_value;
321
322        if position_percentage > max_position_percentage {
323            return Err(Error::from_reason(format!(
324                "Position size ({:.2}%) exceeds maximum allowed ({:.2}%)",
325                position_percentage * 100.0,
326                max_position_percentage * 100.0
327            )));
328        }
329
330        Ok(true)
331    }
332}
333
334/// Calculate Sharpe Ratio
335#[napi]
336pub fn calculate_sharpe_ratio(
337    returns: Vec<f64>,
338    risk_free_rate: f64,
339    annualization_factor: f64,
340) -> Result<f64> {
341    if returns.is_empty() {
342        return Err(Error::from_reason("Returns data is empty"));
343    }
344
345    let mean_return: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
346    let variance: f64 = returns
347        .iter()
348        .map(|r| (r - mean_return).powi(2))
349        .sum::<f64>()
350        / returns.len() as f64;
351
352    let std_dev = variance.sqrt();
353
354    if std_dev == 0.0 {
355        return Ok(0.0);
356    }
357
358    let excess_return = mean_return - risk_free_rate;
359    let sharpe = (excess_return / std_dev) * annualization_factor.sqrt();
360
361    Ok(sharpe)
362}
363
364/// Calculate Sortino Ratio (uses downside deviation only)
365#[napi]
366pub fn calculate_sortino_ratio(
367    returns: Vec<f64>,
368    target_return: f64,
369    annualization_factor: f64,
370) -> Result<f64> {
371    if returns.is_empty() {
372        return Err(Error::from_reason("Returns data is empty"));
373    }
374
375    let mean_return: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
376
377    // Calculate downside deviation
378    let downside_returns: Vec<f64> = returns
379        .iter()
380        .filter(|&&r| r < target_return)
381        .copied()
382        .collect();
383
384    if downside_returns.is_empty() {
385        return Ok(f64::INFINITY);
386    }
387
388    let downside_variance: f64 = downside_returns
389        .iter()
390        .map(|r| (r - target_return).powi(2))
391        .sum::<f64>()
392        / downside_returns.len() as f64;
393
394    let downside_deviation = downside_variance.sqrt();
395
396    if downside_deviation == 0.0 {
397        return Ok(f64::INFINITY);
398    }
399
400    let excess_return = mean_return - target_return;
401    let sortino = (excess_return / downside_deviation) * annualization_factor.sqrt();
402
403    Ok(sortino)
404}
405
406/// Calculate maximum leverage allowed
407#[napi]
408pub fn calculate_max_leverage(
409    _portfolio_value: f64,
410    volatility: f64,
411    max_volatility_target: f64,
412) -> Result<f64> {
413    if volatility <= 0.0 {
414        return Err(Error::from_reason("Volatility must be positive"));
415    }
416
417    if max_volatility_target <= 0.0 {
418        return Err(Error::from_reason("Max volatility target must be positive"));
419    }
420
421    let max_leverage = max_volatility_target / volatility;
422
423    // Cap leverage at reasonable maximum
424    Ok(max_leverage.min(3.0))
425}