Skip to main content

rustrade_risk/
portfolio.rs

1//! Account-level (portfolio) risk.
2//!
3//! The per-symbol [`SessionPnl`](crate::SessionPnl) and
4//! [`CircuitBreaker`](crate::CircuitBreaker) bound each symbol in isolation.
5//! [`PortfolioRisk`] bounds the **whole account**, which is what multi-asset
6//! trading needs: a single bad day across many symbols, or too much aggregate
7//! exposure, should stop new risk even when no individual symbol has tripped.
8//!
9//! It provides two things:
10//!
11//! 1. An **account-wide daily-loss halt** — when net realised PnL summed
12//!    across all symbols breaches the limit, every new entry is halted until
13//!    the next 00:00 UTC rollover. The halt **latches** ([`PortfolioRisk::observe`]):
14//!    once breached it stays halted for the day even if a later realised win
15//!    nudges the sum back above the limit.
16//! 2. A **pre-trade entry gate** ([`PortfolioRisk::check_entry`]) over aggregate
17//!    state: the daily-loss halt, max concurrent open positions, and a
18//!    gross-exposure cap.
19//!
20//! The account net PnL is **derived** from the per-symbol session PnLs by the
21//! framework rather than bookkept separately here — that keeps a single source
22//! of truth and avoids drift. The framework computes the sum (in its periodic
23//! risk sweep and in the pre-trade gate) and hands it in via [`PortfolioRisk::observe`]
24//! and [`PortfolioState::account_net_pnl`].
25//!
26//! Every limit defaults to **off** ([`PortfolioRiskConfig::default`]), so a bot
27//! that doesn't configure portfolio risk behaves exactly as before — it's
28//! purely additive and opt-in.
29//!
30//! Time is read through the [`Clock`] trait so tests advance the clock instead
31//! of sleeping.
32
33use std::fmt;
34use std::sync::Arc;
35
36use serde::{Deserialize, Serialize};
37
38use crate::clock::{Clock, SystemClock};
39
40/// Configuration for [`PortfolioRisk`]. Every limit is independently
41/// disable-able, and the [`Default`] disables them all (opt-in).
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct PortfolioRiskConfig {
44    /// Account-wide net loss ceiling for the day, in quote currency (a
45    /// negative number, e.g. `-500.0`). When net realised PnL across all
46    /// symbols drops to or below this, **every** new entry is halted until
47    /// the next 00:00 UTC rollover. `f64::NEG_INFINITY` disables the halt.
48    pub max_daily_loss: f64,
49    /// Maximum number of symbols holding a position at once. A new entry on a
50    /// symbol that is currently flat is blocked when this many symbols are
51    /// already open. `0` means unlimited.
52    pub max_concurrent_positions: u32,
53    /// Cap on aggregate **gross** exposure (the sum of `|notional|` across all
54    /// open positions) in quote currency. A new entry is blocked when it would
55    /// push gross exposure past this. `f64::INFINITY` disables the cap.
56    pub max_gross_exposure: f64,
57}
58
59impl Default for PortfolioRiskConfig {
60    fn default() -> Self {
61        // All-off: a bot that doesn't opt in is unaffected.
62        Self {
63            max_daily_loss: f64::NEG_INFINITY,
64            max_concurrent_positions: 0,
65            max_gross_exposure: f64::INFINITY,
66        }
67    }
68}
69
70/// Why [`PortfolioRisk::check_entry`] blocked a new entry.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum PortfolioBlock {
73    /// The account-wide daily loss limit is breached; halted until UTC rollover.
74    DailyLossHalt {
75        /// Net realised PnL across the account.
76        net_pnl: f64,
77        /// The configured ceiling.
78        limit: f64,
79    },
80    /// Already at the maximum number of concurrent open positions.
81    MaxConcurrentPositions {
82        /// How many symbols are currently open.
83        open: u32,
84        /// The configured ceiling.
85        limit: u32,
86    },
87    /// Adding this position would exceed the gross-exposure cap.
88    GrossExposureCap {
89        /// Gross exposure already on the book.
90        current: f64,
91        /// Notional this entry would add.
92        additional: f64,
93        /// The configured ceiling.
94        limit: f64,
95    },
96}
97
98impl fmt::Display for PortfolioBlock {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            Self::DailyLossHalt { net_pnl, limit } => write!(
102                f,
103                "account daily-loss halt (net {net_pnl:.2} ≤ limit {limit:.2})"
104            ),
105            Self::MaxConcurrentPositions { open, limit } => write!(
106                f,
107                "max concurrent positions reached ({open} open ≥ limit {limit})"
108            ),
109            Self::GrossExposureCap {
110                current,
111                additional,
112                limit,
113            } => write!(
114                f,
115                "gross-exposure cap (current {current:.2} + {additional:.2} > limit {limit:.2})"
116            ),
117        }
118    }
119}
120
121/// Aggregate account state at the moment of a pre-trade check, assembled by
122/// the framework from its per-symbol risk + position state.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct PortfolioState {
125    /// Number of symbols currently holding a non-flat position.
126    pub open_positions: u32,
127    /// Aggregate gross exposure already on the book (quote currency).
128    pub gross_exposure: f64,
129    /// Notional the proposed new entry would add (quote currency).
130    pub new_notional: f64,
131    /// Whether the entry's symbol already holds a position — if so the entry
132    /// adds to an existing slot rather than consuming a new concurrency slot.
133    pub symbol_already_open: bool,
134    /// Account-wide **net realised** PnL (sum of every symbol's session net
135    /// PnL). Checked live so a fresh breach blocks immediately, independent of
136    /// the latched halt.
137    pub account_net_pnl: f64,
138}
139
140/// Restart-durable snapshot of [`PortfolioRisk`]'s latch state.
141///
142/// The account PnL itself is derived from the per-symbol session snapshots, so
143/// only the halt latch + rollover day are persisted here.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct PortfolioRiskSnapshot {
146    /// Whether the account was halted by the daily-loss limit when snapshotted.
147    pub halted: bool,
148    /// UTC day number of the last reset, for rollover detection on restore.
149    pub last_reset_day: u64,
150}
151
152/// Account-wide risk: a latching daily-loss halt plus a pre-trade entry gate
153/// over aggregate exposure and concurrency. See the [module docs](self).
154///
155/// # Example
156///
157/// ```
158/// use rustrade_risk::{PortfolioRisk, PortfolioRiskConfig, PortfolioState, PortfolioBlock};
159///
160/// let mut pf = PortfolioRisk::new(PortfolioRiskConfig {
161///     max_daily_loss: -100.0,
162///     max_concurrent_positions: 2,
163///     max_gross_exposure: 10_000.0,
164/// });
165///
166/// // A fresh entry that fits every limit is allowed.
167/// assert!(pf.check_entry(PortfolioState {
168///     open_positions: 1,
169///     gross_exposure: 3_000.0,
170///     new_notional: 2_000.0,
171///     symbol_already_open: false,
172///     account_net_pnl: -10.0,
173/// }).is_ok());
174///
175/// // Once the account net PnL breaches the limit the whole account halts.
176/// pf.observe(-120.0);
177/// assert!(pf.is_halted());
178/// assert!(matches!(
179///     pf.check_entry(PortfolioState {
180///         open_positions: 0, gross_exposure: 0.0, new_notional: 1.0,
181///         symbol_already_open: false, account_net_pnl: -120.0,
182///     }),
183///     Err(PortfolioBlock::DailyLossHalt { .. })
184/// ));
185/// ```
186#[derive(Debug, Clone)]
187pub struct PortfolioRisk {
188    config: PortfolioRiskConfig,
189    halted: bool,
190    last_reset_day: u64,
191    clock: Arc<dyn Clock>,
192}
193
194impl PortfolioRisk {
195    /// Create with the default system clock.
196    pub fn new(config: PortfolioRiskConfig) -> Self {
197        Self::with_clock(config, Arc::new(SystemClock))
198    }
199
200    /// Create with an injected clock — typically `Arc<ManualClock>` in tests.
201    pub fn with_clock(config: PortfolioRiskConfig, clock: Arc<dyn Clock>) -> Self {
202        let last_reset_day = clock.utc_day_number();
203        Self {
204            config,
205            halted: false,
206            last_reset_day,
207            clock,
208        }
209    }
210
211    /// Is the account currently halted by the daily-loss limit?
212    pub fn is_halted(&self) -> bool {
213        self.halted
214    }
215
216    /// Borrow the configuration.
217    pub fn config(&self) -> &PortfolioRiskConfig {
218        &self.config
219    }
220
221    /// Observe the account-wide net realised PnL (the sum of every symbol's
222    /// session net PnL). Latches the daily-loss halt once it breaches the
223    /// limit; the latch is sticky until the next UTC rollover ([`Self::tick`]).
224    /// The framework calls this from its periodic risk sweep.
225    pub fn observe(&mut self, account_net_pnl: f64) {
226        if !self.halted && account_net_pnl <= self.config.max_daily_loss {
227            self.halted = true;
228            tracing::warn!(
229                target: "portfolio",
230                net_pnl = format!("{:.4}", account_net_pnl),
231                limit = format!("{:.4}", self.config.max_daily_loss),
232                "account daily-loss limit breached — all new entries halted",
233            );
234        }
235    }
236
237    /// Pre-trade gate for a **new entry**. Returns `Err` with the binding
238    /// reason if any account-level limit blocks it. Exits / reduce-only orders
239    /// should not be gated by this — only entries that add risk.
240    ///
241    /// The daily-loss check fires on either the latched halt or a live breach
242    /// in `state.account_net_pnl`, so a fresh breach blocks immediately even
243    /// between sweeps.
244    pub fn check_entry(&self, state: PortfolioState) -> Result<(), PortfolioBlock> {
245        if self.halted || state.account_net_pnl <= self.config.max_daily_loss {
246            return Err(PortfolioBlock::DailyLossHalt {
247                net_pnl: state.account_net_pnl,
248                limit: self.config.max_daily_loss,
249            });
250        }
251
252        if self.config.max_concurrent_positions > 0
253            && !state.symbol_already_open
254            && state.open_positions >= self.config.max_concurrent_positions
255        {
256            return Err(PortfolioBlock::MaxConcurrentPositions {
257                open: state.open_positions,
258                limit: self.config.max_concurrent_positions,
259            });
260        }
261
262        if state.gross_exposure + state.new_notional > self.config.max_gross_exposure {
263            return Err(PortfolioBlock::GrossExposureCap {
264                current: state.gross_exposure,
265                additional: state.new_notional,
266                limit: self.config.max_gross_exposure,
267            });
268        }
269
270        Ok(())
271    }
272
273    /// Call periodically to detect the 00:00 UTC rollover and clear the halt
274    /// latch. Mirrors [`SessionPnl::tick`](crate::SessionPnl::tick).
275    pub fn tick(&mut self) {
276        let today = self.clock.utc_day_number();
277        if today > self.last_reset_day {
278            self.reset_session();
279            self.last_reset_day = today;
280        }
281    }
282
283    /// Force a session reset (normally automatic at the UTC rollover).
284    pub fn reset_session(&mut self) {
285        if self.halted {
286            tracing::info!(target: "portfolio", "account session reset — daily-loss halt cleared");
287        }
288        self.halted = false;
289    }
290
291    /// Capture the latch state for persistence.
292    pub fn snapshot(&self) -> PortfolioRiskSnapshot {
293        PortfolioRiskSnapshot {
294            halted: self.halted,
295            last_reset_day: self.last_reset_day,
296        }
297    }
298
299    /// Restore latch state from a snapshot, keeping the live config + clock.
300    /// Call [`Self::tick`] afterwards so a snapshot from an earlier UTC day
301    /// rolls over instead of resuming a stale halt.
302    pub fn restore(&mut self, snap: PortfolioRiskSnapshot) {
303        self.halted = snap.halted;
304        self.last_reset_day = snap.last_reset_day;
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::clock::ManualClock;
312
313    fn cfg(loss: f64, max_pos: u32, max_gross: f64) -> PortfolioRiskConfig {
314        PortfolioRiskConfig {
315            max_daily_loss: loss,
316            max_concurrent_positions: max_pos,
317            max_gross_exposure: max_gross,
318        }
319    }
320
321    fn state(open: u32, gross: f64, new: f64, already: bool, net: f64) -> PortfolioState {
322        PortfolioState {
323            open_positions: open,
324            gross_exposure: gross,
325            new_notional: new,
326            symbol_already_open: already,
327            account_net_pnl: net,
328        }
329    }
330
331    #[test]
332    fn default_config_blocks_nothing() {
333        let pf = PortfolioRisk::new(PortfolioRiskConfig::default());
334        // Huge exposure, many positions, big loss — all allowed when off.
335        assert!(
336            pf.check_entry(state(1_000, 1e12, 1e12, false, -1e12))
337                .is_ok()
338        );
339    }
340
341    #[test]
342    fn live_daily_loss_blocks_even_before_latch() {
343        let pf = PortfolioRisk::new(cfg(-100.0, 0, f64::INFINITY));
344        // Not latched yet, but the live net is already past the limit.
345        assert!(matches!(
346            pf.check_entry(state(0, 0.0, 1.0, false, -150.0)),
347            Err(PortfolioBlock::DailyLossHalt { .. })
348        ));
349        // Comfortably above the limit → allowed.
350        assert!(pf.check_entry(state(0, 0.0, 1.0, false, -50.0)).is_ok());
351    }
352
353    #[test]
354    fn observe_latches_sticky_halt() {
355        let mut pf = PortfolioRisk::new(cfg(-100.0, 0, f64::INFINITY));
356        pf.observe(-60.0);
357        assert!(!pf.is_halted());
358        pf.observe(-120.0); // breach → latch
359        assert!(pf.is_halted());
360        // Even if PnL recovers above the limit, the latch holds for the day.
361        assert!(matches!(
362            pf.check_entry(state(0, 0.0, 1.0, false, 50.0)),
363            Err(PortfolioBlock::DailyLossHalt { .. })
364        ));
365    }
366
367    #[test]
368    fn max_concurrent_blocks_new_symbol_only() {
369        let pf = PortfolioRisk::new(cfg(f64::NEG_INFINITY, 2, f64::INFINITY));
370        assert!(matches!(
371            pf.check_entry(state(2, 0.0, 1.0, false, 0.0)),
372            Err(PortfolioBlock::MaxConcurrentPositions { open: 2, limit: 2 })
373        ));
374        // Adding to an already-open symbol consumes no new slot.
375        assert!(pf.check_entry(state(2, 0.0, 1.0, true, 0.0)).is_ok());
376        // Below the cap a new symbol is fine.
377        assert!(pf.check_entry(state(1, 0.0, 1.0, false, 0.0)).is_ok());
378    }
379
380    #[test]
381    fn gross_exposure_cap_blocks_when_exceeded() {
382        let pf = PortfolioRisk::new(cfg(f64::NEG_INFINITY, 0, 10_000.0));
383        assert!(
384            pf.check_entry(state(1, 8_000.0, 1_500.0, false, 0.0))
385                .is_ok()
386        );
387        assert!(matches!(
388            pf.check_entry(state(1, 8_000.0, 2_500.0, false, 0.0)),
389            Err(PortfolioBlock::GrossExposureCap { .. })
390        ));
391    }
392
393    #[test]
394    fn halt_takes_precedence_over_other_gates() {
395        let pf = PortfolioRisk::new(cfg(-10.0, 1, 100.0));
396        // Live breach blocks even an entry that fits concurrency + exposure.
397        assert!(matches!(
398            pf.check_entry(state(0, 0.0, 1.0, false, -20.0)),
399            Err(PortfolioBlock::DailyLossHalt { .. })
400        ));
401    }
402
403    #[test]
404    fn utc_rollover_clears_latch_via_tick() {
405        let day = 100u64;
406        let clock = Arc::new(ManualClock::new(day * 86_400));
407        let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
408        pf.observe(-20.0);
409        assert!(pf.is_halted());
410
411        clock.advance_secs(3_600);
412        pf.tick(); // same day → still halted
413        assert!(pf.is_halted());
414
415        clock.set((day + 1) * 86_400 + 5);
416        pf.tick(); // next UTC day → cleared
417        assert!(!pf.is_halted());
418    }
419
420    #[test]
421    fn snapshot_restore_preserves_latch_same_day() {
422        let clock = Arc::new(ManualClock::new(200 * 86_400 + 100));
423        let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
424        pf.observe(-20.0);
425        let snap = pf.snapshot();
426
427        let mut q = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock.clone());
428        q.restore(snap.clone());
429        assert_eq!(q.snapshot(), snap);
430        q.tick(); // same day → latch survives
431        assert!(q.is_halted());
432    }
433
434    #[test]
435    fn restore_then_tick_rolls_over_stale_day() {
436        let day = 300u64;
437        let clock = Arc::new(ManualClock::new(day * 86_400 + 100));
438        let mut pf = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), clock);
439        pf.observe(-50.0);
440        let snap = pf.snapshot();
441
442        let next = Arc::new(ManualClock::new((day + 1) * 86_400 + 5));
443        let mut q = PortfolioRisk::with_clock(cfg(-10.0, 0, f64::INFINITY), next);
444        q.restore(snap);
445        q.tick();
446        assert!(!q.is_halted(), "stale halted day must roll over fresh");
447    }
448}