Files
foxhunt/services/trading_service/src/observability.rs
jgrusewski efc9a057d5 feat(risk,trading): add GraduatedRecovery, RiskGate, OperatingMode FSM, and SystemState
- GraduatedRecovery: post-emergency position ramp (25% → 100% over 15 days) (risk)
- RiskGate: pipeline stage with LogOnly/Enforcing modes, kill switch + drawdown checks (trading_service)
- OperatingMode: Backtest→Paper→Shadow→Live state machine with validated transitions (trading_service)
- SystemState: observability snapshot aggregating health, risk, positions, drift, models (trading_service)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 12:12:15 +01:00

394 lines
11 KiB
Rust

//! System state observability snapshot.
//!
//! Aggregates health, risk, and model state into a single serializable
//! structure for monitoring and alerting (Prometheus/Grafana).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Complete system state snapshot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemState {
/// When this snapshot was captured.
pub timestamp: DateTime<Utc>,
/// Pipeline health status.
pub pipeline_health: PipelineHealth,
/// Current drawdown state.
pub drawdown: DrawdownState,
/// Active positions summary.
pub positions: PositionsSummary,
/// Drift detection scores per model.
pub drift_scores: HashMap<String, f64>,
/// Model versions currently loaded.
pub model_versions: HashMap<String, ModelInfo>,
/// Risk enforcement state.
pub risk_state: RiskState,
}
/// Pipeline processing health.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineHealth {
/// Whether the pipeline is currently running.
pub is_running: bool,
/// Messages processed in the last minute.
pub messages_per_minute: u64,
/// Number of rejected messages in the last minute.
pub rejections_per_minute: u64,
/// Average latency in microseconds.
pub avg_latency_us: f64,
}
impl Default for PipelineHealth {
fn default() -> Self {
Self {
is_running: false,
messages_per_minute: 0,
rejections_per_minute: 0,
avg_latency_us: 0.0,
}
}
}
/// Current drawdown information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrawdownState {
/// Current drawdown as fraction (0.05 = 5%).
pub current_pct: f64,
/// Maximum drawdown seen today.
pub max_today_pct: f64,
/// Whether any drawdown threshold is breached.
pub threshold_breached: bool,
/// Active alert severity, if any.
pub active_severity: Option<String>,
}
impl Default for DrawdownState {
fn default() -> Self {
Self {
current_pct: 0.0,
max_today_pct: 0.0,
threshold_breached: false,
active_severity: None,
}
}
}
/// Summary of open positions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionsSummary {
/// Number of open positions.
pub open_count: u32,
/// Total exposure in base currency.
pub total_exposure: f64,
/// Per-symbol exposure.
pub per_symbol: HashMap<String, f64>,
}
impl Default for PositionsSummary {
fn default() -> Self {
Self {
open_count: 0,
total_exposure: 0.0,
per_symbol: HashMap::new(),
}
}
}
/// Information about a loaded model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
/// Semantic version of the model.
pub version: String,
/// When the model was loaded.
pub loaded_at: DateTime<Utc>,
/// Total inferences run since load.
pub inference_count: u64,
/// Average inference latency in microseconds.
pub avg_inference_us: f64,
}
/// Risk enforcement state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskState {
/// Whether the kill switch is active.
pub kill_switch_active: bool,
/// Current position size multiplier (1.0 = full size).
pub position_multiplier: f64,
/// Whether new entries are halted.
pub entries_halted: bool,
/// Whether in recovery mode.
pub in_recovery: bool,
/// Recovery multiplier if in recovery.
pub recovery_multiplier: Option<f64>,
}
impl Default for RiskState {
fn default() -> Self {
Self {
kill_switch_active: false,
position_multiplier: 1.0,
entries_halted: false,
in_recovery: false,
recovery_multiplier: None,
}
}
}
impl SystemState {
/// Create a new system state snapshot at the current time.
pub fn capture(
pipeline_health: PipelineHealth,
drawdown: DrawdownState,
positions: PositionsSummary,
drift_scores: HashMap<String, f64>,
model_versions: HashMap<String, ModelInfo>,
risk_state: RiskState,
) -> Self {
Self {
timestamp: Utc::now(),
pipeline_health,
drawdown,
positions,
drift_scores,
model_versions,
risk_state,
}
}
/// Check if the system is in a healthy state.
///
/// A system is healthy when:
/// - The pipeline is running
/// - The kill switch is not active
/// - No drawdown threshold is breached
/// - New entries are not halted
pub fn is_healthy(&self) -> bool {
self.pipeline_health.is_running
&& !self.risk_state.kill_switch_active
&& !self.drawdown.threshold_breached
&& !self.risk_state.entries_halted
}
/// Serialize to JSON string.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
impl Default for SystemState {
fn default() -> Self {
Self {
timestamp: Utc::now(),
pipeline_health: PipelineHealth::default(),
drawdown: DrawdownState::default(),
positions: PositionsSummary::default(),
drift_scores: HashMap::new(),
model_versions: HashMap::new(),
risk_state: RiskState::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_system_state_not_healthy() {
let state = SystemState::default();
// Default pipeline is not running, so not healthy
assert!(!state.is_healthy());
}
#[test]
fn test_healthy_system() {
let state = SystemState::capture(
PipelineHealth {
is_running: true,
messages_per_minute: 100,
rejections_per_minute: 0,
avg_latency_us: 50.0,
},
DrawdownState::default(),
PositionsSummary::default(),
HashMap::new(),
HashMap::new(),
RiskState::default(),
);
assert!(state.is_healthy());
}
#[test]
fn test_kill_switch_makes_unhealthy() {
let state = SystemState::capture(
PipelineHealth {
is_running: true,
..PipelineHealth::default()
},
DrawdownState::default(),
PositionsSummary::default(),
HashMap::new(),
HashMap::new(),
RiskState {
kill_switch_active: true,
..RiskState::default()
},
);
assert!(!state.is_healthy());
}
#[test]
fn test_serialization_roundtrip() {
let state = SystemState::default();
let json = state.to_json();
assert!(json.is_ok());
if let Ok(json_str) = json {
let deserialized: Result<SystemState, _> = serde_json::from_str(&json_str);
assert!(deserialized.is_ok());
}
}
#[test]
fn test_drawdown_breach_makes_unhealthy() {
let state = SystemState::capture(
PipelineHealth {
is_running: true,
..PipelineHealth::default()
},
DrawdownState {
threshold_breached: true,
..DrawdownState::default()
},
PositionsSummary::default(),
HashMap::new(),
HashMap::new(),
RiskState::default(),
);
assert!(!state.is_healthy());
}
#[test]
fn test_entries_halted_makes_unhealthy() {
let state = SystemState::capture(
PipelineHealth {
is_running: true,
..PipelineHealth::default()
},
DrawdownState::default(),
PositionsSummary::default(),
HashMap::new(),
HashMap::new(),
RiskState {
entries_halted: true,
..RiskState::default()
},
);
assert!(!state.is_healthy());
}
#[test]
fn test_pipeline_health_default() {
let health = PipelineHealth::default();
assert!(!health.is_running);
assert_eq!(health.messages_per_minute, 0);
assert_eq!(health.rejections_per_minute, 0);
assert!((health.avg_latency_us - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_risk_state_default() {
let risk = RiskState::default();
assert!(!risk.kill_switch_active);
assert!((risk.position_multiplier - 1.0).abs() < f64::EPSILON);
assert!(!risk.entries_halted);
assert!(!risk.in_recovery);
assert!(risk.recovery_multiplier.is_none());
}
#[test]
fn test_positions_summary_default() {
let pos = PositionsSummary::default();
assert_eq!(pos.open_count, 0);
assert!((pos.total_exposure - 0.0).abs() < f64::EPSILON);
assert!(pos.per_symbol.is_empty());
}
#[test]
fn test_drawdown_state_default() {
let dd = DrawdownState::default();
assert!((dd.current_pct - 0.0).abs() < f64::EPSILON);
assert!((dd.max_today_pct - 0.0).abs() < f64::EPSILON);
assert!(!dd.threshold_breached);
assert!(dd.active_severity.is_none());
}
#[test]
fn test_system_state_with_drift_scores() {
let mut drift = HashMap::new();
drift.insert("dqn_v1".to_string(), 0.03);
drift.insert("ppo_v2".to_string(), 0.15);
let state = SystemState::capture(
PipelineHealth {
is_running: true,
..PipelineHealth::default()
},
DrawdownState::default(),
PositionsSummary::default(),
drift,
HashMap::new(),
RiskState::default(),
);
assert!(state.is_healthy());
assert_eq!(state.drift_scores.len(), 2);
}
#[test]
fn test_system_state_with_model_versions() {
let mut models = HashMap::new();
models.insert(
"dqn".to_string(),
ModelInfo {
version: "1.2.3".to_string(),
loaded_at: Utc::now(),
inference_count: 1000,
avg_inference_us: 45.0,
},
);
let state = SystemState::capture(
PipelineHealth {
is_running: true,
..PipelineHealth::default()
},
DrawdownState::default(),
PositionsSummary::default(),
HashMap::new(),
models,
RiskState::default(),
);
assert!(state.is_healthy());
assert_eq!(state.model_versions.len(), 1);
}
#[test]
fn test_recovery_mode_still_healthy() {
// Recovery mode alone does not make the system unhealthy
let state = SystemState::capture(
PipelineHealth {
is_running: true,
..PipelineHealth::default()
},
DrawdownState::default(),
PositionsSummary::default(),
HashMap::new(),
HashMap::new(),
RiskState {
in_recovery: true,
recovery_multiplier: Some(0.5),
..RiskState::default()
},
);
assert!(state.is_healthy());
}
}