- 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>
394 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|