1use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use crate::agent_adjuster::{PlaybookOverride, StrategyAdjustment};
17use hyper_strategy::strategy_config::StrategyGroup;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct AdjusterGuardrails {
27 #[serde(default = "default_min_interval_secs")]
29 pub min_interval_secs: u64,
30
31 #[serde(default = "default_max_sl_delta")]
33 pub max_sl_delta: f64,
34
35 #[serde(default = "default_max_tp_delta")]
37 pub max_tp_delta: f64,
38
39 #[serde(default = "default_max_position_delta_pct")]
41 pub max_position_delta_pct: f64,
42
43 #[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
77pub struct GuardrailState {
83 pub last_adjustment_at: Option<DateTime<Utc>>,
85}
86
87#[derive(Debug, PartialEq)]
89pub enum GuardrailVerdict {
90 Allow,
92 RateLimited {
94 next_allowed_at: String,
96 },
97 FrozenDueToLoss {
99 daily_loss_pct: f64,
101 },
102}
103
104pub fn check_can_adjust(
116 guardrails: &AdjusterGuardrails,
117 state: &GuardrailState,
118 daily_pnl: f64,
119 max_daily_loss: f64,
120) -> GuardrailVerdict {
121 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 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
147pub 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
195fn 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#[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 #[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(); let state = GuardrailState {
351 last_adjustment_at: None,
352 };
353 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 let verdict = check_can_adjust(&g, &state, -400.0, 1000.0);
371 assert_eq!(verdict, GuardrailVerdict::Allow);
372 }
373
374 #[test]
377 fn clamps_stop_loss_delta() {
378 let g = default_guardrails(); let group = make_group(); 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), 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); 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(); let group = make_group(); 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), },
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); 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(); let group = make_group(); 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), 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); 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), stop_loss_pct: Some(15.0), take_profit_pct: Some(30.0), },
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), stop_loss_pct: Some(6.0), take_profit_pct: Some(12.0), },
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(); let group = make_group(); 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), 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); assert_eq!(clamped.len(), 1);
548 }
549}