use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::agent_adjuster::{PlaybookOverride, StrategyAdjustment};
use hyper_strategy::strategy_config::StrategyGroup;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AdjusterGuardrails {
#[serde(default = "default_min_interval_secs")]
pub min_interval_secs: u64,
#[serde(default = "default_max_sl_delta")]
pub max_sl_delta: f64,
#[serde(default = "default_max_tp_delta")]
pub max_tp_delta: f64,
#[serde(default = "default_max_position_delta_pct")]
pub max_position_delta_pct: f64,
#[serde(default = "default_freeze_on_loss_pct")]
pub freeze_on_loss_pct: f64,
}
fn default_min_interval_secs() -> u64 {
1800
}
fn default_max_sl_delta() -> f64 {
2.0
}
fn default_max_tp_delta() -> f64 {
5.0
}
fn default_max_position_delta_pct() -> f64 {
20.0
}
fn default_freeze_on_loss_pct() -> f64 {
50.0
}
impl Default for AdjusterGuardrails {
fn default() -> Self {
Self {
min_interval_secs: default_min_interval_secs(),
max_sl_delta: default_max_sl_delta(),
max_tp_delta: default_max_tp_delta(),
max_position_delta_pct: default_max_position_delta_pct(),
freeze_on_loss_pct: default_freeze_on_loss_pct(),
}
}
}
pub struct GuardrailState {
pub last_adjustment_at: Option<DateTime<Utc>>,
}
#[derive(Debug, PartialEq)]
pub enum GuardrailVerdict {
Allow,
RateLimited {
next_allowed_at: String,
},
FrozenDueToLoss {
daily_loss_pct: f64,
},
}
pub fn check_can_adjust(
guardrails: &AdjusterGuardrails,
state: &GuardrailState,
daily_pnl: f64,
max_daily_loss: f64,
) -> GuardrailVerdict {
if max_daily_loss > 0.0 && daily_pnl < 0.0 {
let loss_pct = (daily_pnl.abs() / max_daily_loss) * 100.0;
if loss_pct >= guardrails.freeze_on_loss_pct {
return GuardrailVerdict::FrozenDueToLoss {
daily_loss_pct: loss_pct,
};
}
}
if let Some(last) = state.last_adjustment_at {
let now = Utc::now();
let elapsed = now.signed_duration_since(last);
let min_interval = chrono::Duration::seconds(guardrails.min_interval_secs as i64);
if elapsed < min_interval {
let next_allowed = last + min_interval;
return GuardrailVerdict::RateLimited {
next_allowed_at: next_allowed.to_rfc3339(),
};
}
}
GuardrailVerdict::Allow
}
pub fn clamp_adjustment(
adjustment: &mut StrategyAdjustment,
current: &StrategyGroup,
guardrails: &AdjusterGuardrails,
) -> Vec<String> {
let mut clamped: Vec<String> = Vec::new();
let overrides = match adjustment.playbook_overrides.as_mut() {
Some(o) => o,
None => return clamped,
};
for (regime_name, pb_override) in overrides.iter_mut() {
let playbook = match current.playbooks.get(regime_name) {
Some(p) => p,
None => continue,
};
clamp_stop_loss(
pb_override,
playbook.stop_loss_pct,
guardrails,
regime_name,
&mut clamped,
);
clamp_take_profit(
pb_override,
playbook.take_profit_pct,
guardrails,
regime_name,
&mut clamped,
);
clamp_position_size(
pb_override,
playbook.max_position_size,
guardrails,
regime_name,
&mut clamped,
);
}
clamped
}
fn clamp_stop_loss(
pb: &mut PlaybookOverride,
current_sl: Option<f64>,
guardrails: &AdjusterGuardrails,
regime: &str,
clamped: &mut Vec<String>,
) {
if let Some(new_sl) = pb.stop_loss_pct {
let cur = current_sl.unwrap_or(0.0);
let delta = new_sl - cur;
if delta.abs() > guardrails.max_sl_delta {
let clamped_val = cur + delta.signum() * guardrails.max_sl_delta;
pb.stop_loss_pct = Some(clamped_val);
clamped.push(format!(
"{}.stop_loss_pct: {:.2} -> {:.2} (capped delta {:.2})",
regime, new_sl, clamped_val, guardrails.max_sl_delta,
));
}
}
}
fn clamp_take_profit(
pb: &mut PlaybookOverride,
current_tp: Option<f64>,
guardrails: &AdjusterGuardrails,
regime: &str,
clamped: &mut Vec<String>,
) {
if let Some(new_tp) = pb.take_profit_pct {
let cur = current_tp.unwrap_or(0.0);
let delta = new_tp - cur;
if delta.abs() > guardrails.max_tp_delta {
let clamped_val = cur + delta.signum() * guardrails.max_tp_delta;
pb.take_profit_pct = Some(clamped_val);
clamped.push(format!(
"{}.take_profit_pct: {:.2} -> {:.2} (capped delta {:.2})",
regime, new_tp, clamped_val, guardrails.max_tp_delta,
));
}
}
}
fn clamp_position_size(
pb: &mut PlaybookOverride,
current_pos: f64,
guardrails: &AdjusterGuardrails,
regime: &str,
clamped: &mut Vec<String>,
) {
if let Some(new_pos) = pb.max_position_size {
if current_pos > 0.0 {
let delta_pct = ((new_pos - current_pos) / current_pos).abs() * 100.0;
if delta_pct > guardrails.max_position_delta_pct {
let direction = if new_pos > current_pos { 1.0 } else { -1.0 };
let clamped_val =
current_pos * (1.0 + direction * guardrails.max_position_delta_pct / 100.0);
pb.max_position_size = Some(clamped_val);
clamped.push(format!(
"{}.max_position_size: {:.2} -> {:.2} (capped delta {:.1}%)",
regime, new_pos, clamped_val, guardrails.max_position_delta_pct,
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_adjuster::{PlaybookOverride, StrategyAdjustment};
use chrono::Duration;
use hyper_strategy::strategy_config::{HysteresisConfig, Playbook, StrategyGroup};
use std::collections::HashMap;
fn default_guardrails() -> AdjusterGuardrails {
AdjusterGuardrails::default()
}
fn make_group() -> StrategyGroup {
let mut playbooks = HashMap::new();
playbooks.insert(
"bull".to_string(),
Playbook {
rules: vec![],
entry_rules: vec![],
exit_rules: vec![],
system_prompt: "bull".into(),
max_position_size: 1000.0,
stop_loss_pct: Some(5.0),
take_profit_pct: Some(10.0),
timeout_secs: None,
side: None,
},
);
StrategyGroup {
id: "sg-test".into(),
name: "Test".into(),
vault_address: None,
is_active: true,
created_at: "2026-01-01".into(),
symbol: "BTC-USD".into(),
interval_secs: 300,
regime_rules: vec![],
default_regime: "bull".into(),
hysteresis: HysteresisConfig {
min_hold_secs: 3600,
confirmation_count: 3,
},
playbooks,
}
}
#[test]
fn allows_when_no_previous_adjustment() {
let g = default_guardrails();
let state = GuardrailState {
last_adjustment_at: None,
};
let verdict = check_can_adjust(&g, &state, 0.0, 1000.0);
assert_eq!(verdict, GuardrailVerdict::Allow);
}
#[test]
fn rate_limits_within_interval() {
let g = default_guardrails();
let state = GuardrailState {
last_adjustment_at: Some(Utc::now() - Duration::seconds(60)),
};
let verdict = check_can_adjust(&g, &state, 0.0, 1000.0);
assert!(matches!(verdict, GuardrailVerdict::RateLimited { .. }));
}
#[test]
fn allows_after_interval_elapsed() {
let g = default_guardrails();
let state = GuardrailState {
last_adjustment_at: Some(Utc::now() - Duration::seconds(3600)),
};
let verdict = check_can_adjust(&g, &state, 0.0, 1000.0);
assert_eq!(verdict, GuardrailVerdict::Allow);
}
#[test]
fn freezes_on_high_loss() {
let g = default_guardrails(); let state = GuardrailState {
last_adjustment_at: None,
};
let verdict = check_can_adjust(&g, &state, -600.0, 1000.0);
match verdict {
GuardrailVerdict::FrozenDueToLoss { daily_loss_pct } => {
assert!((daily_loss_pct - 60.0).abs() < 0.01);
}
other => panic!("expected FrozenDueToLoss, got {:?}", other),
}
}
#[test]
fn allows_when_loss_below_threshold() {
let g = default_guardrails();
let state = GuardrailState {
last_adjustment_at: None,
};
let verdict = check_can_adjust(&g, &state, -400.0, 1000.0);
assert_eq!(verdict, GuardrailVerdict::Allow);
}
#[test]
fn clamps_stop_loss_delta() {
let g = default_guardrails(); let group = make_group(); let mut adj = StrategyAdjustment {
regime_rules: None,
default_regime: None,
hysteresis: None,
playbook_overrides: Some(HashMap::from([(
"bull".into(),
PlaybookOverride {
rules: None,
max_position_size: None,
stop_loss_pct: Some(10.0), take_profit_pct: None,
},
)])),
};
let clamped = clamp_adjustment(&mut adj, &group, &g);
let new_sl = adj
.playbook_overrides
.as_ref()
.unwrap()
.get("bull")
.unwrap()
.stop_loss_pct
.unwrap();
assert!((new_sl - 7.0).abs() < 0.01); assert_eq!(clamped.len(), 1);
assert!(clamped[0].contains("stop_loss_pct"));
}
#[test]
fn clamps_take_profit_delta() {
let g = default_guardrails(); let group = make_group(); let mut adj = StrategyAdjustment {
regime_rules: None,
default_regime: None,
hysteresis: None,
playbook_overrides: Some(HashMap::from([(
"bull".into(),
PlaybookOverride {
rules: None,
max_position_size: None,
stop_loss_pct: None,
take_profit_pct: Some(25.0), },
)])),
};
let clamped = clamp_adjustment(&mut adj, &group, &g);
let new_tp = adj
.playbook_overrides
.as_ref()
.unwrap()
.get("bull")
.unwrap()
.take_profit_pct
.unwrap();
assert!((new_tp - 15.0).abs() < 0.01); assert_eq!(clamped.len(), 1);
assert!(clamped[0].contains("take_profit_pct"));
}
#[test]
fn clamps_position_size_delta() {
let g = default_guardrails(); let group = make_group(); let mut adj = StrategyAdjustment {
regime_rules: None,
default_regime: None,
hysteresis: None,
playbook_overrides: Some(HashMap::from([(
"bull".into(),
PlaybookOverride {
rules: None,
max_position_size: Some(1500.0), stop_loss_pct: None,
take_profit_pct: None,
},
)])),
};
let clamped = clamp_adjustment(&mut adj, &group, &g);
let new_pos = adj
.playbook_overrides
.as_ref()
.unwrap()
.get("bull")
.unwrap()
.max_position_size
.unwrap();
assert!((new_pos - 1200.0).abs() < 0.01); assert_eq!(clamped.len(), 1);
assert!(clamped[0].contains("max_position_size"));
}
#[test]
fn returns_clamped_field_names() {
let g = default_guardrails();
let group = make_group();
let mut adj = StrategyAdjustment {
regime_rules: None,
default_regime: None,
hysteresis: None,
playbook_overrides: Some(HashMap::from([(
"bull".into(),
PlaybookOverride {
rules: None,
max_position_size: Some(2000.0), stop_loss_pct: Some(15.0), take_profit_pct: Some(30.0), },
)])),
};
let clamped = clamp_adjustment(&mut adj, &group, &g);
assert_eq!(clamped.len(), 3);
assert!(clamped.iter().any(|s| s.contains("stop_loss_pct")));
assert!(clamped.iter().any(|s| s.contains("take_profit_pct")));
assert!(clamped.iter().any(|s| s.contains("max_position_size")));
}
#[test]
fn no_clamping_when_within_limits() {
let g = default_guardrails();
let group = make_group();
let mut adj = StrategyAdjustment {
regime_rules: None,
default_regime: None,
hysteresis: None,
playbook_overrides: Some(HashMap::from([(
"bull".into(),
PlaybookOverride {
rules: None,
max_position_size: Some(1100.0), stop_loss_pct: Some(6.0), take_profit_pct: Some(12.0), },
)])),
};
let clamped = clamp_adjustment(&mut adj, &group, &g);
assert!(clamped.is_empty());
}
#[test]
fn clamps_downward_stop_loss_delta() {
let g = default_guardrails(); let group = make_group(); let mut adj = StrategyAdjustment {
regime_rules: None,
default_regime: None,
hysteresis: None,
playbook_overrides: Some(HashMap::from([(
"bull".into(),
PlaybookOverride {
rules: None,
max_position_size: None,
stop_loss_pct: Some(1.0), take_profit_pct: None,
},
)])),
};
let clamped = clamp_adjustment(&mut adj, &group, &g);
let new_sl = adj
.playbook_overrides
.as_ref()
.unwrap()
.get("bull")
.unwrap()
.stop_loss_pct
.unwrap();
assert!((new_sl - 3.0).abs() < 0.01); assert_eq!(clamped.len(), 1);
}
}