scirs2-integrate 0.4.3

Numerical integration module for SciRS2 (scirs2-integrate)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
//! Real-time risk monitoring and alerting
//!
//! This module provides real-time risk calculation, monitoring, and alerting capabilities
//! for trading systems and portfolio management applications.
//!
//! # Features
//! - Streaming risk calculations
//! - Real-time Greeks updates
//! - Position limit monitoring
//! - Risk limit breach detection
//! - Incremental VaR updates
//! - Low-latency risk aggregation

use crate::error::{IntegrateError, Result};
use crate::specialized::finance::pricing::black_scholes::normal_cdf;
use std::collections::{HashMap, VecDeque};

// ─────────────────────────────────────────────────────────────────────────────
// Core data types
// ─────────────────────────────────────────────────────────────────────────────

/// A single market data event (one price/volume update for one symbol).
#[derive(Debug, Clone)]
pub struct MarketTick {
    /// Ticker symbol (e.g., "AAPL", "SPX").
    pub symbol: String,
    /// Last-traded price.
    pub price: f64,
    /// Traded volume at this tick.
    pub volume: f64,
    /// Unix timestamp in milliseconds.
    pub timestamp: u64,
    /// Best bid price.
    pub bid: f64,
    /// Best ask price.
    pub ask: f64,
}

/// A risk-limit alert generated by one of the monitors.
#[derive(Debug, Clone)]
pub enum RiskAlert {
    /// A position-size limit has been exceeded.
    LimitBreach {
        /// Affected symbol.
        symbol: String,
        /// Current notional exposure (price × position).
        current: f64,
        /// Configured limit.
        limit: f64,
    },
    /// Portfolio VaR has exceeded its configured threshold.
    VarExceeded {
        /// Portfolio identifier.
        portfolio: String,
        /// Current estimated VaR.
        current_var: f64,
        /// VaR limit.
        limit: f64,
    },
    /// A Greek has breached its threshold.
    GreeksThreshold {
        /// Affected symbol / position label.
        symbol: String,
        /// Name of the Greek ("delta", "gamma", …).
        greek: String,
        /// Current absolute value of the Greek.
        value: f64,
    },
}

// ─────────────────────────────────────────────────────────────────────────────
// RiskMonitor trait
// ─────────────────────────────────────────────────────────────────────────────

/// A real-time risk monitor that reacts to market data ticks.
pub trait RiskMonitor: Send + Sync {
    /// Process one tick, optionally returning an alert.
    fn on_market_data(&mut self, tick: &MarketTick) -> Result<Option<RiskAlert>>;
    /// Human-readable name of this monitor.
    fn name(&self) -> &str;
}

// ─────────────────────────────────────────────────────────────────────────────
// StreamingGreeks
// ─────────────────────────────────────────────────────────────────────────────

/// Option position parameters needed for streaming Greeks.
#[derive(Debug, Clone)]
pub struct OptionPosition {
    /// Strike price.
    pub strike: f64,
    /// Time to expiry in years.
    pub time_to_expiry: f64,
    /// Implied volatility (annualised).
    pub volatility: f64,
    /// Risk-free rate.
    pub risk_free_rate: f64,
    /// Notional position size (signed: positive = long, negative = short).
    pub quantity: f64,
    /// Threshold for the absolute delta value above which an alert is fired.
    pub delta_alert_threshold: f64,
}

/// Standard-normal PDF φ(x) = exp(-x²/2) / √(2π).
fn normal_pdf(x: f64) -> f64 {
    (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
}

/// Black-Scholes call Greeks computed inline from first principles.
///
/// Returns `(delta, gamma, vega, theta, rho)`.
///
/// Panics: never — guarded against degenerate inputs internally.
fn bs_call_greeks(
    spot: f64,
    strike: f64,
    time: f64,
    vol: f64,
    rate: f64,
) -> (f64, f64, f64, f64, f64) {
    if spot <= 0.0 || strike <= 0.0 || time <= 0.0 || vol <= 0.0 {
        return (0.0, 0.0, 0.0, 0.0, 0.0);
    }
    let sqrt_t = time.sqrt();
    let d1 = ((spot / strike).ln() + (rate + 0.5 * vol * vol) * time) / (vol * sqrt_t);
    let d2 = d1 - vol * sqrt_t;

    let nd1 = normal_cdf(d1);
    let nd2 = normal_cdf(d2);
    let phi_d1 = normal_pdf(d1);

    let delta = nd1;
    let gamma = phi_d1 / (spot * vol * sqrt_t);
    let vega = spot * phi_d1 * sqrt_t;
    let theta = -spot * phi_d1 * vol / (2.0 * sqrt_t) - rate * strike * (-rate * time).exp() * nd2;
    let rho = strike * time * (-rate * time).exp() * nd2;

    (delta, gamma, vega, theta, rho)
}

/// Maintains running Black-Scholes Greeks for a set of option positions,
/// updated on every market data tick for the relevant underlying.
#[derive(Debug)]
pub struct StreamingGreeks {
    /// Option positions keyed by underlying symbol.
    positions: HashMap<String, OptionPosition>,
    /// Most-recently computed Greeks, keyed by symbol.
    current_greeks: HashMap<String, (f64, f64, f64, f64, f64)>,
}

impl StreamingGreeks {
    /// Create a new instance with no positions.
    pub fn new() -> Self {
        Self {
            positions: HashMap::new(),
            current_greeks: HashMap::new(),
        }
    }

    /// Register an option position for a given underlying symbol.
    pub fn add_position(&mut self, symbol: String, pos: OptionPosition) {
        self.positions.insert(symbol, pos);
    }

    /// Return the latest Greeks for a symbol, or `None` if unknown.
    pub fn greeks_for(&self, symbol: &str) -> Option<(f64, f64, f64, f64, f64)> {
        self.current_greeks.get(symbol).copied()
    }
}

impl Default for StreamingGreeks {
    fn default() -> Self {
        Self::new()
    }
}

impl RiskMonitor for StreamingGreeks {
    fn name(&self) -> &str {
        "StreamingGreeks"
    }

    fn on_market_data(&mut self, tick: &MarketTick) -> Result<Option<RiskAlert>> {
        if let Some(pos) = self.positions.get(&tick.symbol) {
            let (delta, gamma, vega, theta, rho) = bs_call_greeks(
                tick.price,
                pos.strike,
                pos.time_to_expiry,
                pos.volatility,
                pos.risk_free_rate,
            );
            // Scale by position size
            let scaled_delta = delta * pos.quantity;
            let threshold = pos.delta_alert_threshold;
            self.current_greeks
                .insert(tick.symbol.clone(), (delta, gamma, vega, theta, rho));

            if scaled_delta.abs() > threshold {
                return Ok(Some(RiskAlert::GreeksThreshold {
                    symbol: tick.symbol.clone(),
                    greek: "delta".to_string(),
                    value: scaled_delta.abs(),
                }));
            }
        }
        Ok(None)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// PositionLimitChecker
// ─────────────────────────────────────────────────────────────────────────────

/// Checks whether the notional exposure (price × position) of any symbol
/// exceeds its configured limit, firing a `LimitBreach` alert on violations.
#[derive(Debug)]
pub struct PositionLimitChecker {
    /// symbol → maximum allowed notional exposure.
    limits: HashMap<String, f64>,
    /// symbol → current position size (in contracts / shares).
    current_positions: HashMap<String, f64>,
}

impl PositionLimitChecker {
    /// Create a new checker with empty limits and positions.
    pub fn new() -> Self {
        Self {
            limits: HashMap::new(),
            current_positions: HashMap::new(),
        }
    }

    /// Set the notional limit for a symbol.
    pub fn set_limit(&mut self, symbol: String, limit: f64) {
        self.limits.insert(symbol, limit);
    }

    /// Set the current position size for a symbol.
    pub fn set_position(&mut self, symbol: String, size: f64) {
        self.current_positions.insert(symbol, size);
    }
}

impl Default for PositionLimitChecker {
    fn default() -> Self {
        Self::new()
    }
}

impl RiskMonitor for PositionLimitChecker {
    fn name(&self) -> &str {
        "PositionLimitChecker"
    }

    fn on_market_data(&mut self, tick: &MarketTick) -> Result<Option<RiskAlert>> {
        let Some(&limit) = self.limits.get(&tick.symbol) else {
            return Ok(None);
        };
        let position = self
            .current_positions
            .get(&tick.symbol)
            .copied()
            .unwrap_or(0.0);
        let notional = tick.price * position.abs();

        if notional > limit {
            return Ok(Some(RiskAlert::LimitBreach {
                symbol: tick.symbol.clone(),
                current: notional,
                limit,
            }));
        }
        Ok(None)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// IncrementalVaR
// ─────────────────────────────────────────────────────────────────────────────

/// Maintains a rolling window of log-returns and computes the 95 % historical
/// VaR (5th-percentile loss, negated) after every tick.
///
/// A `VarExceeded` alert is emitted whenever the computed VaR breaches its
/// configured limit.
#[derive(Debug)]
pub struct IncrementalVaR {
    /// Sliding window of recent log-returns (oldest at front).
    window: VecDeque<f64>,
    /// Maximum window size (default: 252 trading days).
    window_size: usize,
    /// The VaR limit; alerts fire if VaR exceeds this.
    var_limit: f64,
    /// Portfolio name (used in alerts).
    portfolio: String,
    /// Previous prices, keyed by symbol (used to compute returns).
    prev_price: HashMap<String, f64>,
}

impl IncrementalVaR {
    /// Create with a given window size and VaR limit.
    pub fn new(portfolio: impl Into<String>, window_size: usize, var_limit: f64) -> Result<Self> {
        if window_size == 0 {
            return Err(IntegrateError::ValueError(
                "window_size must be positive".to_string(),
            ));
        }
        Ok(Self {
            window: VecDeque::with_capacity(window_size),
            window_size,
            var_limit,
            portfolio: portfolio.into(),
            prev_price: HashMap::new(),
        })
    }

    /// Compute the current 95 % historical VaR from the internal window.
    ///
    /// Returns `None` if the window has fewer than 2 observations.
    pub fn current_var(&self) -> Option<f64> {
        if self.window.len() < 2 {
            return None;
        }
        let mut sorted: Vec<f64> = self.window.iter().copied().collect();
        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
        // 5th percentile index (floor)
        let idx = ((1.0 - 0.95) * sorted.len() as f64).floor() as usize;
        let idx = idx.min(sorted.len().saturating_sub(1));
        Some(-sorted[idx])
    }
}

impl RiskMonitor for IncrementalVaR {
    fn name(&self) -> &str {
        "IncrementalVaR"
    }

    fn on_market_data(&mut self, tick: &MarketTick) -> Result<Option<RiskAlert>> {
        if tick.price <= 0.0 {
            return Err(IntegrateError::ValueError(format!(
                "Non-positive price {} for {}",
                tick.price, tick.symbol
            )));
        }
        // Compute log-return from the previous price for this symbol.
        if let Some(&prev) = self.prev_price.get(&tick.symbol) {
            if prev > 0.0 {
                let log_return = (tick.price / prev).ln();
                // Slide the window.
                if self.window.len() >= self.window_size {
                    self.window.pop_front();
                }
                self.window.push_back(log_return);
            }
        }
        self.prev_price.insert(tick.symbol.clone(), tick.price);

        // Check VaR limit.
        if let Some(var) = self.current_var() {
            if var > self.var_limit {
                return Ok(Some(RiskAlert::VarExceeded {
                    portfolio: self.portfolio.clone(),
                    current_var: var,
                    limit: self.var_limit,
                }));
            }
        }
        Ok(None)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// RiskAggregator
// ─────────────────────────────────────────────────────────────────────────────

/// Portfolio-level risk aggregation: sums notional exposures and VaR
/// contributions across all registered positions/symbols.
#[derive(Debug, Default)]
pub struct RiskAggregator {
    /// symbol → (position size, last price)
    positions: HashMap<String, (f64, f64)>,
    /// symbol → VaR contribution (updated externally or via `absorb_var`).
    var_contributions: HashMap<String, f64>,
}

impl RiskAggregator {
    /// Create an empty aggregator.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register or update the position size and last price for a symbol.
    pub fn update_position(&mut self, symbol: String, size: f64, price: f64) {
        self.positions.insert(symbol, (size, price));
    }

    /// Set the VaR contribution for a symbol.
    pub fn set_var_contribution(&mut self, symbol: String, var: f64) {
        self.var_contributions.insert(symbol, var);
    }

    /// Total notional exposure (Σ |size| × price).
    pub fn total_notional(&self) -> f64 {
        self.positions
            .values()
            .map(|(size, price)| size.abs() * price)
            .sum()
    }

    /// Sum of VaR contributions (simple additive aggregation — conservative
    /// as it ignores cross-correlations).
    pub fn total_var(&self) -> f64 {
        self.var_contributions.values().copied().sum()
    }
}

impl RiskMonitor for RiskAggregator {
    fn name(&self) -> &str {
        "RiskAggregator"
    }

    fn on_market_data(&mut self, tick: &MarketTick) -> Result<Option<RiskAlert>> {
        // Update the last price for the symbol if we hold a position.
        if let Some(pos) = self.positions.get_mut(&tick.symbol) {
            pos.1 = tick.price;
        }
        Ok(None)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// EventDrivenRisk
// ─────────────────────────────────────────────────────────────────────────────

/// Dispatcher that fans out every `MarketTick` to all registered monitors and
/// collects the resulting alerts.
pub struct EventDrivenRisk {
    monitors: Vec<Box<dyn RiskMonitor>>,
}

impl EventDrivenRisk {
    /// Create an empty dispatcher.
    pub fn new() -> Self {
        Self {
            monitors: Vec::new(),
        }
    }

    /// Add a monitor to the dispatcher.
    pub fn add_monitor(&mut self, monitor: Box<dyn RiskMonitor>) {
        self.monitors.push(monitor);
    }

    /// Send one tick to all monitors and collect all alerts.
    ///
    /// Errors from individual monitors are surfaced as `IntegrateError`.
    pub fn process_tick(&mut self, tick: &MarketTick) -> Vec<RiskAlert> {
        let mut alerts = Vec::new();
        for monitor in &mut self.monitors {
            match monitor.on_market_data(tick) {
                Ok(Some(alert)) => alerts.push(alert),
                Ok(None) => {}
                Err(e) => {
                    // Non-fatal: log the monitor name and continue.
                    eprintln!("[EventDrivenRisk] monitor '{}' error: {e}", monitor.name());
                }
            }
        }
        alerts
    }
}

impl Default for EventDrivenRisk {
    fn default() -> Self {
        Self::new()
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    fn make_tick(symbol: &str, price: f64) -> MarketTick {
        MarketTick {
            symbol: symbol.to_string(),
            price,
            volume: 1000.0,
            timestamp: 1_700_000_000_000,
            bid: price - 0.01,
            ask: price + 0.01,
        }
    }

    // ──────────────────────────────────────────────────────────────────────
    // Test 1: PositionLimitChecker fires LimitBreach when limit exceeded
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn test_position_limit_breach() {
        let mut checker = PositionLimitChecker::new();
        // Allow up to $5 000 notional exposure for AAPL.
        checker.set_limit("AAPL".to_string(), 5_000.0);
        // Hold 100 shares.
        checker.set_position("AAPL".to_string(), 100.0);

        // Price = $60 → notional = $6 000 > $5 000 → breach.
        let tick = make_tick("AAPL", 60.0);
        let alert = checker
            .on_market_data(&tick)
            .expect("monitor should succeed");

        match alert {
            Some(RiskAlert::LimitBreach {
                symbol,
                current,
                limit,
            }) => {
                assert_eq!(symbol, "AAPL");
                assert!((current - 6_000.0).abs() < 1e-9, "current={current}");
                assert!((limit - 5_000.0).abs() < 1e-9, "limit={limit}");
            }
            other => panic!("Expected LimitBreach, got {other:?}"),
        }
    }

    /// No alert when notional is within limits.
    #[test]
    fn test_position_limit_no_breach() {
        let mut checker = PositionLimitChecker::new();
        checker.set_limit("AAPL".to_string(), 5_000.0);
        checker.set_position("AAPL".to_string(), 100.0);

        // Price = $40 → notional = $4 000 < $5 000 → no alert.
        let tick = make_tick("AAPL", 40.0);
        let alert = checker
            .on_market_data(&tick)
            .expect("monitor should succeed");

        assert!(alert.is_none(), "Expected no alert, got {alert:?}");
    }

    // ──────────────────────────────────────────────────────────────────────
    // Test 2: IncrementalVaR returns correct VaR for known return sequence
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn test_incremental_var_known_sequence() {
        // Feed 20 prices that imply a 5-percent loss at the 5th percentile.
        // We use an arithmetic sequence: p_0 = 100, then alternate +1/-5.
        // After sufficient ticks the window should contain losses of ~5 %.
        let mut ivar = IncrementalVaR::new("TEST", 252, 0.0).expect("should construct");

        // Synthetic prices: start at 100, then drop by 5 for one step and
        // recover, so we get a known worst-case return.
        let prices: Vec<f64> = vec![
            100.0, 95.0, 100.0, 95.0, 100.0, 95.0, 100.0, 95.0, 100.0, 95.0, 100.0, 95.0, 100.0,
            95.0, 100.0, 95.0, 100.0, 95.0, 100.0, 95.0,
        ];

        for &p in &prices {
            let tick = make_tick("SPX", p);
            let _ = ivar.on_market_data(&tick).expect("tick should succeed");
        }

        let var = ivar.current_var().expect("should have enough data");
        // The negative log-returns here are ln(100/95)≈0.051 and
        // the positive ones are ln(95/100)≈-0.051. The 5th percentile of a
        // window that alternates equally between -0.051 and +0.051 is the
        // minimum, so VaR ≈ 0.051.
        assert!(var > 0.0, "VaR must be positive, got {var}");
        assert!(var < 0.2, "VaR seems too large: {var}");
    }

    // ──────────────────────────────────────────────────────────────────────
    // Test 3: EventDrivenRisk dispatches to multiple monitors
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn test_event_driven_dispatches_to_multiple_monitors() {
        let mut checker1 = PositionLimitChecker::new();
        checker1.set_limit("X".to_string(), 1.0); // very small limit → always breach
        checker1.set_position("X".to_string(), 10.0);

        let mut checker2 = PositionLimitChecker::new();
        checker2.set_limit("X".to_string(), 1.0);
        checker2.set_position("X".to_string(), 10.0);

        let mut dispatcher = EventDrivenRisk::new();
        dispatcher.add_monitor(Box::new(checker1));
        dispatcher.add_monitor(Box::new(checker2));

        let tick = make_tick("X", 100.0);
        let alerts = dispatcher.process_tick(&tick);

        assert_eq!(
            alerts.len(),
            2,
            "Both monitors should have fired an alert, got {}",
            alerts.len()
        );
    }

    // ──────────────────────────────────────────────────────────────────────
    // Test 4: StreamingGreeks delta is in (0, 1) for a call option
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn test_streaming_greeks_delta_plausible() {
        let mut sg = StreamingGreeks::new();
        sg.add_position(
            "AAPL".to_string(),
            OptionPosition {
                strike: 150.0,
                time_to_expiry: 0.25, // 3 months
                volatility: 0.20,
                risk_free_rate: 0.05,
                quantity: 1.0,
                delta_alert_threshold: f64::INFINITY, // no alert for this test
            },
        );

        let tick = make_tick("AAPL", 155.0);
        let _ = sg.on_market_data(&tick).expect("tick should succeed");

        let (delta, gamma, vega, _theta, _rho) = sg
            .greeks_for("AAPL")
            .expect("Greeks should be available after tick");

        // Call delta must be strictly between 0 and 1.
        assert!(delta > 0.0 && delta < 1.0, "delta out of range: {delta}");
        // Gamma is always non-negative for vanilla options.
        assert!(gamma >= 0.0, "gamma negative: {gamma}");
        // Vega is always non-negative.
        assert!(vega >= 0.0, "vega negative: {vega}");
    }

    // ──────────────────────────────────────────────────────────────────────
    // Test 5: Full pipeline — create all monitors, feed 20 ticks, collect alerts
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn test_full_pipeline() {
        let mut dispatcher = EventDrivenRisk::new();

        // Position limit checker: limit = 1 000, position = 20 shares.
        // At price > 50 → notional > 1 000 → breach.
        let mut checker = PositionLimitChecker::new();
        checker.set_limit("SPY".to_string(), 1_000.0);
        checker.set_position("SPY".to_string(), 20.0);
        dispatcher.add_monitor(Box::new(checker));

        // Incremental VaR (very low limit → triggers when loss exists).
        let ivar = IncrementalVaR::new("portfolio", 252, -f64::INFINITY).expect("should construct");
        dispatcher.add_monitor(Box::new(ivar));

        // Streaming Greeks.
        let mut sg = StreamingGreeks::new();
        sg.add_position(
            "SPY".to_string(),
            OptionPosition {
                strike: 400.0,
                time_to_expiry: 0.5,
                volatility: 0.18,
                risk_free_rate: 0.04,
                quantity: 1.0,
                delta_alert_threshold: f64::INFINITY,
            },
        );
        dispatcher.add_monitor(Box::new(sg));

        // Risk aggregator.
        let mut agg = RiskAggregator::new();
        agg.update_position("SPY".to_string(), 20.0, 420.0);
        dispatcher.add_monitor(Box::new(agg));

        // Feed 20 ticks at price 60 (notional = 1 200 → above limit of 1 000).
        let mut total_alerts = 0usize;
        for i in 0..20u64 {
            let tick = MarketTick {
                symbol: "SPY".to_string(),
                price: 60.0,
                volume: 500.0,
                timestamp: 1_700_000_000_000 + i * 1_000,
                bid: 59.99,
                ask: 60.01,
            };
            let alerts = dispatcher.process_tick(&tick);
            total_alerts += alerts.len();
        }

        // At a minimum the position limit checker should have fired 20 times.
        assert!(
            total_alerts >= 20,
            "Expected at least 20 alerts across 20 ticks, got {total_alerts}"
        );
    }
}