Skip to main content

hyper_agent_core/
adjuster_guardrails.rs

1//! Guardrails for agent adjustment frequency and magnitude.
2//!
3//! Prevents the AI agent from adjusting strategy parameters too often or too
4//! aggressively. Two independent checks are provided:
5//!
6//! 1. **Rate limiting** -- enforces a minimum interval between adjustments.
7//! 2. **Loss freeze** -- blocks all adjustments when daily losses exceed a
8//!    configurable percentage of the daily loss limit.
9//!
10//! A **magnitude clamp** function caps per-cycle deltas for stop-loss,
11//! take-profit, and position size relative to the current strategy parameters.
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use crate::agent_adjuster::{PlaybookOverride, StrategyAdjustment};
17use hyper_strategy::strategy_config::StrategyGroup;
18
19// ---------------------------------------------------------------------------
20// Config
21// ---------------------------------------------------------------------------
22
23/// Configurable limits on how often and how much the agent can adjust strategy
24/// parameters in a single cycle.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct AdjusterGuardrails {
27    /// Minimum seconds between adjustments (default: 1800 = 30 min).
28    #[serde(default = "default_min_interval_secs")]
29    pub min_interval_secs: u64,
30
31    /// Max stop_loss_pct change per cycle (default: 2.0 percentage points).
32    #[serde(default = "default_max_sl_delta")]
33    pub max_sl_delta: f64,
34
35    /// Max take_profit_pct change per cycle (default: 5.0 percentage points).
36    #[serde(default = "default_max_tp_delta")]
37    pub max_tp_delta: f64,
38
39    /// Max position size change as a percentage of the current value (default: 20.0).
40    #[serde(default = "default_max_position_delta_pct")]
41    pub max_position_delta_pct: f64,
42
43    /// Freeze adjustments when daily loss exceeds this percentage of the daily
44    /// loss limit (default: 50.0).
45    #[serde(default = "default_freeze_on_loss_pct")]
46    pub freeze_on_loss_pct: f64,
47}
48
49fn default_min_interval_secs() -> u64 {
50    1800
51}
52fn default_max_sl_delta() -> f64 {
53    2.0
54}
55fn default_max_tp_delta() -> f64 {
56    5.0
57}
58fn default_max_position_delta_pct() -> f64 {
59    20.0
60}
61fn default_freeze_on_loss_pct() -> f64 {
62    50.0
63}
64
65impl Default for AdjusterGuardrails {
66    fn default() -> Self {
67        Self {
68            min_interval_secs: default_min_interval_secs(),
69            max_sl_delta: default_max_sl_delta(),
70            max_tp_delta: default_max_tp_delta(),
71            max_position_delta_pct: default_max_position_delta_pct(),
72            freeze_on_loss_pct: default_freeze_on_loss_pct(),
73        }
74    }
75}
76
77// ---------------------------------------------------------------------------
78// State + Verdict
79// ---------------------------------------------------------------------------
80
81/// Mutable state tracked across adjustment cycles.
82pub struct GuardrailState {
83    /// Timestamp of the most recent successful adjustment.
84    pub last_adjustment_at: Option<DateTime<Utc>>,
85}
86
87/// Result of the pre-adjustment guardrail check.
88#[derive(Debug, PartialEq)]
89pub enum GuardrailVerdict {
90    /// The adjustment may proceed.
91    Allow,
92    /// The minimum interval has not elapsed yet.
93    RateLimited {
94        /// ISO-8601 timestamp of the earliest allowed next adjustment.
95        next_allowed_at: String,
96    },
97    /// Adjustments are frozen because daily losses are too high.
98    FrozenDueToLoss {
99        /// Current daily loss expressed as a percentage of the daily loss limit.
100        daily_loss_pct: f64,
101    },
102}
103
104// ---------------------------------------------------------------------------
105// Public API
106// ---------------------------------------------------------------------------
107
108/// Check whether an adjustment is allowed right now.
109///
110/// * `daily_pnl` -- the running daily PnL (negative means a loss).
111/// * `max_daily_loss` -- the configured maximum daily loss (positive number).
112///
113/// Returns [`GuardrailVerdict::Allow`] only when both the rate-limit and the
114/// loss-freeze checks pass.
115pub fn check_can_adjust(
116    guardrails: &AdjusterGuardrails,
117    state: &GuardrailState,
118    daily_pnl: f64,
119    max_daily_loss: f64,
120) -> GuardrailVerdict {
121    // --- Loss freeze check (evaluated first -- more critical) ---------------
122    if max_daily_loss > 0.0 && daily_pnl < 0.0 {
123        let loss_pct = (daily_pnl.abs() / max_daily_loss) * 100.0;
124        if loss_pct >= guardrails.freeze_on_loss_pct {
125            return GuardrailVerdict::FrozenDueToLoss {
126                daily_loss_pct: loss_pct,
127            };
128        }
129    }
130
131    // --- Rate-limit check ---------------------------------------------------
132    if let Some(last) = state.last_adjustment_at {
133        let now = Utc::now();
134        let elapsed = now.signed_duration_since(last);
135        let min_interval = chrono::Duration::seconds(guardrails.min_interval_secs as i64);
136        if elapsed < min_interval {
137            let next_allowed = last + min_interval;
138            return GuardrailVerdict::RateLimited {
139                next_allowed_at: next_allowed.to_rfc3339(),
140            };
141        }
142    }
143
144    GuardrailVerdict::Allow
145}
146
147/// Clamp a [`StrategyAdjustment`] so that no single-cycle delta exceeds the
148/// configured magnitude limits.
149///
150/// Returns a list of human-readable descriptions of fields that were clamped.
151pub fn clamp_adjustment(
152    adjustment: &mut StrategyAdjustment,
153    current: &StrategyGroup,
154    guardrails: &AdjusterGuardrails,
155) -> Vec<String> {
156    let mut clamped: Vec<String> = Vec::new();
157
158    let overrides = match adjustment.playbook_overrides.as_mut() {
159        Some(o) => o,
160        None => return clamped,
161    };
162
163    for (regime_name, pb_override) in overrides.iter_mut() {
164        let playbook = match current.playbooks.get(regime_name) {
165            Some(p) => p,
166            None => continue,
167        };
168
169        clamp_stop_loss(
170            pb_override,
171            playbook.stop_loss_pct,
172            guardrails,
173            regime_name,
174            &mut clamped,
175        );
176        clamp_take_profit(
177            pb_override,
178            playbook.take_profit_pct,
179            guardrails,
180            regime_name,
181            &mut clamped,
182        );
183        clamp_position_size(
184            pb_override,
185            playbook.max_position_size,
186            guardrails,
187            regime_name,
188            &mut clamped,
189        );
190    }
191
192    clamped
193}
194
195// ---------------------------------------------------------------------------
196// Internal helpers
197// ---------------------------------------------------------------------------
198
199fn clamp_stop_loss(
200    pb: &mut PlaybookOverride,
201    current_sl: Option<f64>,
202    guardrails: &AdjusterGuardrails,
203    regime: &str,
204    clamped: &mut Vec<String>,
205) {
206    if let Some(new_sl) = pb.stop_loss_pct {
207        let cur = current_sl.unwrap_or(0.0);
208        let delta = new_sl - cur;
209        if delta.abs() > guardrails.max_sl_delta {
210            let clamped_val = cur + delta.signum() * guardrails.max_sl_delta;
211            pb.stop_loss_pct = Some(clamped_val);
212            clamped.push(format!(
213                "{}.stop_loss_pct: {:.2} -> {:.2} (capped delta {:.2})",
214                regime, new_sl, clamped_val, guardrails.max_sl_delta,
215            ));
216        }
217    }
218}
219
220fn clamp_take_profit(
221    pb: &mut PlaybookOverride,
222    current_tp: Option<f64>,
223    guardrails: &AdjusterGuardrails,
224    regime: &str,
225    clamped: &mut Vec<String>,
226) {
227    if let Some(new_tp) = pb.take_profit_pct {
228        let cur = current_tp.unwrap_or(0.0);
229        let delta = new_tp - cur;
230        if delta.abs() > guardrails.max_tp_delta {
231            let clamped_val = cur + delta.signum() * guardrails.max_tp_delta;
232            pb.take_profit_pct = Some(clamped_val);
233            clamped.push(format!(
234                "{}.take_profit_pct: {:.2} -> {:.2} (capped delta {:.2})",
235                regime, new_tp, clamped_val, guardrails.max_tp_delta,
236            ));
237        }
238    }
239}
240
241fn clamp_position_size(
242    pb: &mut PlaybookOverride,
243    current_pos: f64,
244    guardrails: &AdjusterGuardrails,
245    regime: &str,
246    clamped: &mut Vec<String>,
247) {
248    if let Some(new_pos) = pb.max_position_size {
249        if current_pos > 0.0 {
250            let delta_pct = ((new_pos - current_pos) / current_pos).abs() * 100.0;
251            if delta_pct > guardrails.max_position_delta_pct {
252                let direction = if new_pos > current_pos { 1.0 } else { -1.0 };
253                let clamped_val =
254                    current_pos * (1.0 + direction * guardrails.max_position_delta_pct / 100.0);
255                pb.max_position_size = Some(clamped_val);
256                clamped.push(format!(
257                    "{}.max_position_size: {:.2} -> {:.2} (capped delta {:.1}%)",
258                    regime, new_pos, clamped_val, guardrails.max_position_delta_pct,
259                ));
260            }
261        }
262    }
263}
264
265// ---------------------------------------------------------------------------
266// Tests
267// ---------------------------------------------------------------------------
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::agent_adjuster::{PlaybookOverride, StrategyAdjustment};
273    use chrono::Duration;
274    use hyper_strategy::strategy_config::{HysteresisConfig, Playbook, StrategyGroup};
275    use std::collections::HashMap;
276
277    fn default_guardrails() -> AdjusterGuardrails {
278        AdjusterGuardrails::default()
279    }
280
281    fn make_group() -> StrategyGroup {
282        let mut playbooks = HashMap::new();
283        playbooks.insert(
284            "bull".to_string(),
285            Playbook {
286                rules: vec![],
287                entry_rules: vec![],
288                exit_rules: vec![],
289                system_prompt: "bull".into(),
290                max_position_size: 1000.0,
291                stop_loss_pct: Some(5.0),
292                take_profit_pct: Some(10.0),
293                timeout_secs: None,
294                side: None,
295            },
296        );
297        StrategyGroup {
298            id: "sg-test".into(),
299            name: "Test".into(),
300            vault_address: None,
301            is_active: true,
302            created_at: "2026-01-01".into(),
303            symbol: "BTC-USD".into(),
304            interval_secs: 300,
305            regime_rules: vec![],
306            default_regime: "bull".into(),
307            hysteresis: HysteresisConfig {
308                min_hold_secs: 3600,
309                confirmation_count: 3,
310            },
311            playbooks,
312        }
313    }
314
315    // -- check_can_adjust tests -----------------------------------------------
316
317    #[test]
318    fn allows_when_no_previous_adjustment() {
319        let g = default_guardrails();
320        let state = GuardrailState {
321            last_adjustment_at: None,
322        };
323        let verdict = check_can_adjust(&g, &state, 0.0, 1000.0);
324        assert_eq!(verdict, GuardrailVerdict::Allow);
325    }
326
327    #[test]
328    fn rate_limits_within_interval() {
329        let g = default_guardrails();
330        let state = GuardrailState {
331            last_adjustment_at: Some(Utc::now() - Duration::seconds(60)),
332        };
333        let verdict = check_can_adjust(&g, &state, 0.0, 1000.0);
334        assert!(matches!(verdict, GuardrailVerdict::RateLimited { .. }));
335    }
336
337    #[test]
338    fn allows_after_interval_elapsed() {
339        let g = default_guardrails();
340        let state = GuardrailState {
341            last_adjustment_at: Some(Utc::now() - Duration::seconds(3600)),
342        };
343        let verdict = check_can_adjust(&g, &state, 0.0, 1000.0);
344        assert_eq!(verdict, GuardrailVerdict::Allow);
345    }
346
347    #[test]
348    fn freezes_on_high_loss() {
349        let g = default_guardrails(); // freeze_on_loss_pct = 50.0
350        let state = GuardrailState {
351            last_adjustment_at: None,
352        };
353        // daily_pnl = -600, max_daily_loss = 1000 => 60% > 50%
354        let verdict = check_can_adjust(&g, &state, -600.0, 1000.0);
355        match verdict {
356            GuardrailVerdict::FrozenDueToLoss { daily_loss_pct } => {
357                assert!((daily_loss_pct - 60.0).abs() < 0.01);
358            }
359            other => panic!("expected FrozenDueToLoss, got {:?}", other),
360        }
361    }
362
363    #[test]
364    fn allows_when_loss_below_threshold() {
365        let g = default_guardrails();
366        let state = GuardrailState {
367            last_adjustment_at: None,
368        };
369        // daily_pnl = -400, max_daily_loss = 1000 => 40% < 50%
370        let verdict = check_can_adjust(&g, &state, -400.0, 1000.0);
371        assert_eq!(verdict, GuardrailVerdict::Allow);
372    }
373
374    // -- clamp_adjustment tests -----------------------------------------------
375
376    #[test]
377    fn clamps_stop_loss_delta() {
378        let g = default_guardrails(); // max_sl_delta = 2.0
379        let group = make_group(); // bull.stop_loss_pct = 5.0
380        let mut adj = StrategyAdjustment {
381            regime_rules: None,
382            default_regime: None,
383            hysteresis: None,
384            playbook_overrides: Some(HashMap::from([(
385                "bull".into(),
386                PlaybookOverride {
387                    rules: None,
388                    max_position_size: None,
389                    stop_loss_pct: Some(10.0), // delta = 5.0 > 2.0
390                    take_profit_pct: None,
391                },
392            )])),
393        };
394        let clamped = clamp_adjustment(&mut adj, &group, &g);
395        let new_sl = adj
396            .playbook_overrides
397            .as_ref()
398            .unwrap()
399            .get("bull")
400            .unwrap()
401            .stop_loss_pct
402            .unwrap();
403        assert!((new_sl - 7.0).abs() < 0.01); // 5.0 + 2.0
404        assert_eq!(clamped.len(), 1);
405        assert!(clamped[0].contains("stop_loss_pct"));
406    }
407
408    #[test]
409    fn clamps_take_profit_delta() {
410        let g = default_guardrails(); // max_tp_delta = 5.0
411        let group = make_group(); // bull.take_profit_pct = 10.0
412        let mut adj = StrategyAdjustment {
413            regime_rules: None,
414            default_regime: None,
415            hysteresis: None,
416            playbook_overrides: Some(HashMap::from([(
417                "bull".into(),
418                PlaybookOverride {
419                    rules: None,
420                    max_position_size: None,
421                    stop_loss_pct: None,
422                    take_profit_pct: Some(25.0), // delta = 15.0 > 5.0
423                },
424            )])),
425        };
426        let clamped = clamp_adjustment(&mut adj, &group, &g);
427        let new_tp = adj
428            .playbook_overrides
429            .as_ref()
430            .unwrap()
431            .get("bull")
432            .unwrap()
433            .take_profit_pct
434            .unwrap();
435        assert!((new_tp - 15.0).abs() < 0.01); // 10.0 + 5.0
436        assert_eq!(clamped.len(), 1);
437        assert!(clamped[0].contains("take_profit_pct"));
438    }
439
440    #[test]
441    fn clamps_position_size_delta() {
442        let g = default_guardrails(); // max_position_delta_pct = 20.0
443        let group = make_group(); // bull.max_position_size = 1000.0
444        let mut adj = StrategyAdjustment {
445            regime_rules: None,
446            default_regime: None,
447            hysteresis: None,
448            playbook_overrides: Some(HashMap::from([(
449                "bull".into(),
450                PlaybookOverride {
451                    rules: None,
452                    max_position_size: Some(1500.0), // delta = 50% > 20%
453                    stop_loss_pct: None,
454                    take_profit_pct: None,
455                },
456            )])),
457        };
458        let clamped = clamp_adjustment(&mut adj, &group, &g);
459        let new_pos = adj
460            .playbook_overrides
461            .as_ref()
462            .unwrap()
463            .get("bull")
464            .unwrap()
465            .max_position_size
466            .unwrap();
467        assert!((new_pos - 1200.0).abs() < 0.01); // 1000 * 1.20
468        assert_eq!(clamped.len(), 1);
469        assert!(clamped[0].contains("max_position_size"));
470    }
471
472    #[test]
473    fn returns_clamped_field_names() {
474        let g = default_guardrails();
475        let group = make_group();
476        let mut adj = StrategyAdjustment {
477            regime_rules: None,
478            default_regime: None,
479            hysteresis: None,
480            playbook_overrides: Some(HashMap::from([(
481                "bull".into(),
482                PlaybookOverride {
483                    rules: None,
484                    max_position_size: Some(2000.0), // 100% > 20%
485                    stop_loss_pct: Some(15.0),       // delta 10 > 2
486                    take_profit_pct: Some(30.0),     // delta 20 > 5
487                },
488            )])),
489        };
490        let clamped = clamp_adjustment(&mut adj, &group, &g);
491        assert_eq!(clamped.len(), 3);
492        assert!(clamped.iter().any(|s| s.contains("stop_loss_pct")));
493        assert!(clamped.iter().any(|s| s.contains("take_profit_pct")));
494        assert!(clamped.iter().any(|s| s.contains("max_position_size")));
495    }
496
497    #[test]
498    fn no_clamping_when_within_limits() {
499        let g = default_guardrails();
500        let group = make_group();
501        let mut adj = StrategyAdjustment {
502            regime_rules: None,
503            default_regime: None,
504            hysteresis: None,
505            playbook_overrides: Some(HashMap::from([(
506                "bull".into(),
507                PlaybookOverride {
508                    rules: None,
509                    max_position_size: Some(1100.0), // 10% < 20%
510                    stop_loss_pct: Some(6.0),        // delta 1.0 < 2.0
511                    take_profit_pct: Some(12.0),     // delta 2.0 < 5.0
512                },
513            )])),
514        };
515        let clamped = clamp_adjustment(&mut adj, &group, &g);
516        assert!(clamped.is_empty());
517    }
518
519    #[test]
520    fn clamps_downward_stop_loss_delta() {
521        let g = default_guardrails(); // max_sl_delta = 2.0
522        let group = make_group(); // bull.stop_loss_pct = 5.0
523        let mut adj = StrategyAdjustment {
524            regime_rules: None,
525            default_regime: None,
526            hysteresis: None,
527            playbook_overrides: Some(HashMap::from([(
528                "bull".into(),
529                PlaybookOverride {
530                    rules: None,
531                    max_position_size: None,
532                    stop_loss_pct: Some(1.0), // delta = -4.0, |4.0| > 2.0
533                    take_profit_pct: None,
534                },
535            )])),
536        };
537        let clamped = clamp_adjustment(&mut adj, &group, &g);
538        let new_sl = adj
539            .playbook_overrides
540            .as_ref()
541            .unwrap()
542            .get("bull")
543            .unwrap()
544            .stop_loss_pct
545            .unwrap();
546        assert!((new_sl - 3.0).abs() < 0.01); // 5.0 - 2.0
547        assert_eq!(clamped.len(), 1);
548    }
549}