Skip to main content

schwab_cli/agent/
roll.rs

1//! Defensive rolling: when a credit vertical hits the mechanical stop, prefer
2//! close + reopen farther OTM / later DTE over eating a full stop (v1: verticals only).
3
4use serde_json::Value;
5
6use crate::rules::{RollConfig, VerticalEntryRules};
7
8use super::state::TrackedPosition;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum RollSkipReason {
12    Disabled,
13    NotVertical,
14    MissingEntryParams,
15    LowDte,
16    ShortTooClose,
17    MaxRollsPosition,
18    MaxRollsDay,
19    PortfolioRisk,
20}
21
22impl RollSkipReason {
23    pub fn as_str(&self) -> &'static str {
24        match self {
25            Self::Disabled => "roll_disabled",
26            Self::NotVertical => "roll_not_vertical",
27            Self::MissingEntryParams => "roll_missing_entry_params",
28            Self::LowDte => "roll_low_dte",
29            Self::ShortTooClose => "roll_short_too_close",
30            Self::MaxRollsPosition => "roll_max_per_position",
31            Self::MaxRollsDay => "roll_max_per_day",
32            Self::PortfolioRisk => "roll_portfolio_risk",
33        }
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct RollEligibility<'a> {
39    pub tracked: &'a TrackedPosition,
40    pub mark_dte: i64,
41    pub short_otm_pct: Option<f64>,
42    pub rolls_today: u32,
43    /// `reserved_risk_usd` including this position.
44    pub reserved_risk_usd: f64,
45    pub max_portfolio_risk_usd: f64,
46}
47
48/// All eligibility gates for attempting a defensive roll on a stop_loss.
49pub fn roll_eligible(cfg: &RollConfig, input: &RollEligibility<'_>) -> Result<(), RollSkipReason> {
50    if !cfg.enabled {
51        return Err(RollSkipReason::Disabled);
52    }
53    if !input.tracked.strategy.eq_ignore_ascii_case("vertical") {
54        return Err(RollSkipReason::NotVertical);
55    }
56    if input.tracked.entry_params.is_none() {
57        return Err(RollSkipReason::MissingEntryParams);
58    }
59    if input.mark_dte < cfg.min_dte_remaining as i64 {
60        return Err(RollSkipReason::LowDte);
61    }
62    match input.short_otm_pct {
63        Some(otm) if otm >= cfg.min_short_otm_pct => {}
64        Some(_) => return Err(RollSkipReason::ShortTooClose),
65        None => return Err(RollSkipReason::ShortTooClose),
66    }
67    if input.tracked.rolls_used >= cfg.max_rolls_per_position {
68        return Err(RollSkipReason::MaxRollsPosition);
69    }
70    if cfg.max_rolls_per_day > 0 && input.rolls_today >= cfg.max_rolls_per_day {
71        return Err(RollSkipReason::MaxRollsDay);
72    }
73    // After close, reserved drops by this position's max_loss; replacement ≈ same size.
74    let after_close = (input.reserved_risk_usd - input.tracked.max_loss_usd).max(0.0);
75    let replacement = input.tracked.max_loss_usd.max(0.0);
76    if after_close + replacement > input.max_portfolio_risk_usd + 1e-6 {
77        return Err(RollSkipReason::PortfolioRisk);
78    }
79    Ok(())
80}
81
82/// `new_credit - close_debit >= -entry_credit * max_debit_pct / 100`
83pub fn roll_money_ok(
84    entry_credit: f64,
85    close_debit: f64,
86    new_credit: f64,
87    max_debit_pct_of_entry_credit: f64,
88) -> bool {
89    if entry_credit <= f64::EPSILON {
90        return false;
91    }
92    let net = new_credit - close_debit;
93    let floor = -entry_credit * (max_debit_pct_of_entry_credit / 100.0);
94    net >= floor - 1e-9
95}
96
97pub fn roll_net(close_debit: f64, new_credit: f64) -> f64 {
98    new_credit - close_debit
99}
100
101/// Cap |short_delta| for the replacement: min(entry max, target, original − 0.04).
102pub fn roll_biased_short_delta_max(
103    entry_short_delta_max: f64,
104    target_short_delta_max: f64,
105    original_short_delta: Option<f64>,
106) -> f64 {
107    let from_original = original_short_delta
108        .map(|d| (d.abs() - 0.04).max(0.01))
109        .unwrap_or(target_short_delta_max);
110    entry_short_delta_max
111        .min(target_short_delta_max)
112        .min(from_original)
113}
114
115/// Prefer later DTE: max(entry dte_min, closed_dte + extension).
116pub fn roll_biased_dte_min(entry_dte_min: u32, closed_dte: i64, min_dte_extension: u32) -> u32 {
117    let extended = (closed_dte.max(0) as u32).saturating_add(min_dte_extension);
118    entry_dte_min.max(extended)
119}
120
121/// Width ≤ original (or entry max_width).
122pub fn roll_biased_max_width(entry_max_width: f64, original_width: Option<f64>) -> f64 {
123    match original_width {
124        Some(w) if w > f64::EPSILON => entry_max_width.min(w),
125        _ => entry_max_width,
126    }
127}
128
129pub fn original_width_from_params(params: &Value) -> Option<f64> {
130    let short = params.get("short_strike")?.as_f64()?;
131    let long = params.get("long_strike")?.as_f64()?;
132    Some((short - long).abs())
133}
134
135pub fn spread_type_from_tracked(tracked: &TrackedPosition) -> Option<String> {
136    tracked
137        .entry_params
138        .as_ref()
139        .and_then(|p| p.get("spread_type"))
140        .and_then(|v| v.as_str())
141        .map(str::to_string)
142        .or_else(|| {
143            // Infer from strikes if present.
144            let p = tracked.entry_params.as_ref()?;
145            let short = p.get("short_strike")?.as_f64()?;
146            let long = p.get("long_strike")?.as_f64()?;
147            if short > long {
148                Some("put_credit".into())
149            } else {
150                Some("call_credit".into())
151            }
152        })
153}
154
155/// Build vertical entry rules biased for a defensive roll replacement.
156pub fn roll_biased_entry_rules(
157    base: &VerticalEntryRules,
158    roll: &RollConfig,
159    closed_dte: i64,
160    original_short_delta: Option<f64>,
161    original_width: Option<f64>,
162    contracts: u32,
163) -> VerticalEntryRules {
164    let mut entry = base.clone();
165    entry.dte_min = roll_biased_dte_min(base.dte_min, closed_dte, roll.min_dte_extension);
166    if entry.dte_min > entry.dte_max {
167        entry.dte_max = entry.dte_min;
168    }
169    entry.short_delta_max = roll_biased_short_delta_max(
170        base.short_delta_max,
171        roll.target_short_delta_max,
172        original_short_delta,
173    );
174    // Keep a usable band below the capped max.
175    entry.short_delta_min = entry.short_delta_min.min(entry.short_delta_max * 0.5);
176    entry.max_width = roll_biased_max_width(base.max_width, original_width);
177    entry.max_contracts_per_trade = contracts.max(1);
178    // Roll replaces a closed slot — don't block on open-count inside evaluate.
179    entry.max_open_positions = entry.max_open_positions.saturating_add(1);
180    entry
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use chrono::Utc;
187    use serde_json::json;
188
189    fn vertical_tracked(rolls_used: u32) -> TrackedPosition {
190        TrackedPosition {
191            position_id: "p1".into(),
192            account_hash: "acc".into(),
193            underlying: "SPY".into(),
194            expiry: "2026-09-18".into(),
195            strategy: "vertical".into(),
196            opened_at: Utc::now(),
197            entry_credit: Some(1.0),
198            max_loss_usd: 400.0,
199            contracts: 1,
200            entry_params: Some(json!({
201                "underlying": "SPY",
202                "expiry": "2026-09-18",
203                "spread_type": "put_credit",
204                "short_strike": 500.0,
205                "long_strike": 495.0,
206                "contracts": 1.0,
207            })),
208            rolls_used,
209            ..Default::default()
210        }
211    }
212
213    fn cfg() -> RollConfig {
214        RollConfig::default()
215    }
216
217    #[test]
218    fn eligibility_passes_defaults() {
219        let mut roll = cfg();
220        roll.enabled = true;
221        let tracked = vertical_tracked(0);
222        let input = RollEligibility {
223            tracked: &tracked,
224            mark_dte: 30,
225            short_otm_pct: Some(2.0),
226            rolls_today: 0,
227            reserved_risk_usd: 400.0,
228            max_portfolio_risk_usd: 4000.0,
229        };
230        assert!(roll_eligible(&roll, &input).is_ok());
231    }
232
233    #[test]
234    fn eligibility_rejects_disabled() {
235        let tracked = vertical_tracked(0);
236        let input = RollEligibility {
237            tracked: &tracked,
238            mark_dte: 30,
239            short_otm_pct: Some(2.0),
240            rolls_today: 0,
241            reserved_risk_usd: 400.0,
242            max_portfolio_risk_usd: 4000.0,
243        };
244        assert_eq!(
245            roll_eligible(&cfg(), &input),
246            Err(RollSkipReason::Disabled)
247        );
248    }
249
250    #[test]
251    fn eligibility_rejects_itm_short() {
252        let mut roll = cfg();
253        roll.enabled = true;
254        let tracked = vertical_tracked(0);
255        let input = RollEligibility {
256            tracked: &tracked,
257            mark_dte: 30,
258            short_otm_pct: Some(0.2),
259            rolls_today: 0,
260            reserved_risk_usd: 400.0,
261            max_portfolio_risk_usd: 4000.0,
262        };
263        assert_eq!(
264            roll_eligible(&roll, &input),
265            Err(RollSkipReason::ShortTooClose)
266        );
267    }
268
269    #[test]
270    fn eligibility_rejects_low_dte() {
271        let mut roll = cfg();
272        roll.enabled = true;
273        let tracked = vertical_tracked(0);
274        let input = RollEligibility {
275            tracked: &tracked,
276            mark_dte: 10,
277            short_otm_pct: Some(2.0),
278            rolls_today: 0,
279            reserved_risk_usd: 400.0,
280            max_portfolio_risk_usd: 4000.0,
281        };
282        assert_eq!(roll_eligible(&roll, &input), Err(RollSkipReason::LowDte));
283    }
284
285    #[test]
286    fn eligibility_rejects_max_rolls_position() {
287        let mut roll = cfg();
288        roll.enabled = true;
289        let tracked = vertical_tracked(1);
290        let input = RollEligibility {
291            tracked: &tracked,
292            mark_dte: 30,
293            short_otm_pct: Some(2.0),
294            rolls_today: 0,
295            reserved_risk_usd: 400.0,
296            max_portfolio_risk_usd: 4000.0,
297        };
298        assert_eq!(
299            roll_eligible(&roll, &input),
300            Err(RollSkipReason::MaxRollsPosition)
301        );
302    }
303
304    #[test]
305    fn eligibility_rejects_max_rolls_day() {
306        let mut roll = cfg();
307        roll.enabled = true;
308        let tracked = vertical_tracked(0);
309        let input = RollEligibility {
310            tracked: &tracked,
311            mark_dte: 30,
312            short_otm_pct: Some(2.0),
313            rolls_today: 1,
314            reserved_risk_usd: 400.0,
315            max_portfolio_risk_usd: 4000.0,
316        };
317        assert_eq!(
318            roll_eligible(&roll, &input),
319            Err(RollSkipReason::MaxRollsDay)
320        );
321    }
322
323    #[test]
324    fn money_allows_limited_debit() {
325        // entry 1.00, close 2.00, new 1.80 → net -0.20 = 20% debit of entry → ok at 25%
326        assert!(roll_money_ok(1.0, 2.0, 1.80, 25.0));
327        // net -0.30 = 30% → reject
328        assert!(!roll_money_ok(1.0, 2.0, 1.70, 25.0));
329        // prefer credit
330        assert!(roll_money_ok(1.0, 1.50, 1.60, 25.0));
331    }
332
333    #[test]
334    fn delta_bias_takes_lower_of_target_and_original_minus() {
335        assert!((roll_biased_short_delta_max(0.30, 0.12, Some(0.18)) - 0.12).abs() < 1e-9);
336        assert!((roll_biased_short_delta_max(0.30, 0.12, Some(0.14)) - 0.10).abs() < 1e-9);
337        assert!((roll_biased_short_delta_max(0.08, 0.12, Some(0.20)) - 0.08).abs() < 1e-9);
338    }
339
340    #[test]
341    fn dte_bias_extends_past_closed() {
342        assert_eq!(roll_biased_dte_min(30, 25, 7), 32);
343        assert_eq!(roll_biased_dte_min(40, 25, 7), 40);
344    }
345
346    #[test]
347    fn width_bias_caps_to_original() {
348        assert!((roll_biased_max_width(5.0, Some(3.0)) - 3.0).abs() < 1e-9);
349        assert!((roll_biased_max_width(5.0, None) - 5.0).abs() < 1e-9);
350    }
351}