1use 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 pub reserved_risk_usd: f64,
45 pub max_portfolio_risk_usd: f64,
46}
47
48pub 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 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
82pub 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
101pub 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
115pub 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
121pub 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 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
155pub 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 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 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 assert!(roll_money_ok(1.0, 2.0, 1.80, 25.0));
327 assert!(!roll_money_ok(1.0, 2.0, 1.70, 25.0));
329 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}