portfolio_analyzer/strategy/
mod.rs1pub mod pricing;
27pub mod book;
28pub mod sim;
29
30use crate::sd2::engine::Sd2Reading;
31use pricing::{implied_abs_move, Kind};
32
33#[derive(Debug, Clone)]
36pub struct Context<'a> {
37 pub spot: f64,
38 pub iv: f64,
40 pub sd: &'a Sd2Reading,
41 pub prev: Option<&'a Sd2Reading>,
43}
44
45impl Context<'_> {
46 pub fn vol_edge(&self) -> f64 {
50 self.sd.expected_move_pct / implied_abs_move(self.iv, self.sd.horizon as f64).max(1e-9)
51 }
52 pub fn just_fired(&self) -> bool {
53 self.sd.is_active_fire && self.prev.is_some_and(|p| !p.is_active_fire)
54 }
55}
56
57#[derive(Debug, Clone, Copy, serde::Serialize)]
59pub struct LegSpec {
60 pub kind: Kind,
61 pub moneyness: f64,
62 pub qty: f64,
64}
65
66#[derive(Debug, Clone, serde::Serialize)]
68pub struct Plan {
69 pub legs: Vec<LegSpec>,
70 pub dte: usize,
72 pub label: &'static str,
74}
75
76#[derive(Debug, Clone)]
78pub struct OpenView {
79 pub label: &'static str,
80 pub days_held: usize,
81 pub days_left: usize,
82 pub return_on_risk: f64,
84 pub fraction_of_max: f64,
86 pub entry_lean: f64,
88}
89
90pub trait Strategy: Send + Sync {
92 fn name(&self) -> &'static str;
93 fn thesis(&self) -> &'static str;
95 fn enter(&self, ctx: &Context) -> Option<Plan>;
96 fn exit(&self, ctx: &Context, open: &OpenView) -> bool;
97}
98
99pub const MONTH: usize = 22;
101
102fn straddle() -> Plan {
103 Plan {
104 legs: vec![
105 LegSpec { kind: Kind::Call, moneyness: 1.0, qty: 1.0 },
106 LegSpec { kind: Kind::Put, moneyness: 1.0, qty: 1.0 },
107 ],
108 dte: MONTH,
109 label: "straddle",
110 }
111}
112
113fn condor(short: f64, wing: f64) -> Plan {
116 Plan {
117 legs: vec![
118 LegSpec { kind: Kind::Put, moneyness: 1.0 - wing, qty: 1.0 },
119 LegSpec { kind: Kind::Put, moneyness: 1.0 - short, qty: -1.0 },
120 LegSpec { kind: Kind::Call, moneyness: 1.0 + short, qty: -1.0 },
121 LegSpec { kind: Kind::Call, moneyness: 1.0 + wing, qty: 1.0 },
122 ],
123 dte: MONTH,
124 label: "iron condor",
125 }
126}
127
128fn vertical(dir: f64, width: f64) -> Plan {
130 let (kind, far, label) =
131 if dir > 0.0 { (Kind::Call, 1.0 + width, "bull call") } else { (Kind::Put, 1.0 - width, "bear put") };
132 Plan {
133 legs: vec![
134 LegSpec { kind, moneyness: 1.0, qty: 1.0 },
135 LegSpec { kind, moneyness: far, qty: -1.0 },
136 ],
137 dte: MONTH,
138 label,
139 }
140}
141
142pub struct CoilStraddle {
147 pub min_edge: f64,
151}
152
153impl Strategy for CoilStraddle {
154 fn name(&self) -> &'static str {
155 "CoilStraddle"
156 }
157 fn thesis(&self) -> &'static str {
158 "A squeeze is stored energy: realized volatility has collapsed and tends to snap back. \
159 Options are priced off a blend of recent and longer-run volatility, so during a deep coil \
160 they can underprice the coming expansion. Buy the straddle only when SD's expected move \
161 beats the implied move, and sell once the fire has decayed (the expansion is spent)."
162 }
163 fn enter(&self, ctx: &Context) -> Option<Plan> {
164 (ctx.sd.compression >= 2 && ctx.vol_edge() >= self.min_edge).then(straddle)
165 }
166 fn exit(&self, ctx: &Context, open: &OpenView) -> bool {
167 open.return_on_risk >= 0.6
168 || open.days_left <= 5
169 || (ctx.sd.phase.is_some() && !ctx.sd.in_squeeze && ctx.sd.decay_strength < 0.5)
170 }
171}
172
173pub struct QuietCondor {
178 pub max_edge: f64,
181}
182
183impl Strategy for QuietCondor {
184 fn name(&self) -> &'static str {
185 "QuietCondor"
186 }
187 fn thesis(&self) -> &'static str {
188 "Options usually charge more for movement than is delivered (the variance risk premium). \
189 The danger in selling it is being short right before a squeeze fires. So sell only when \
190 SD sees no coil, no live fire and a low expected move, place the short strikes at one \
191 implied move, keep wings for protection, and get out the moment a squeeze starts forming."
192 }
193 fn enter(&self, ctx: &Context) -> Option<Plan> {
194 let quiet = !ctx.sd.in_squeeze && !ctx.sd.is_active_fire && ctx.sd.intensity <= 0.35;
195 if !quiet || ctx.vol_edge() > self.max_edge {
196 return None;
197 }
198 let m = implied_abs_move(ctx.iv, MONTH as f64) * 1.25; Some(condor(m, m * 1.6))
200 }
201 fn exit(&self, ctx: &Context, open: &OpenView) -> bool {
202 open.fraction_of_max >= 0.5 || open.days_left <= 5 || ctx.sd.compression >= 2
203 }
204}
205
206pub struct FireVertical {
211 pub min_lean: f64,
212}
213
214impl Strategy for FireVertical {
215 fn name(&self) -> &'static str {
216 "FireVertical"
217 }
218 fn thesis(&self) -> &'static str {
219 "When a squeeze fires, ride it in the direction momentum leans. The short strike sits at \
220 SD's expected move, so the spread pays in full exactly when SD's size forecast is right. \
221 Exit when the fire decays or the lean flips. Kept mainly as an honest measurement of \
222 SD's directional skill."
223 }
224 fn enter(&self, ctx: &Context) -> Option<Plan> {
225 (ctx.just_fired() && ctx.sd.lean.abs() >= self.min_lean)
226 .then(|| vertical(ctx.sd.lean, ctx.sd.expected_move_pct.clamp(0.02, 0.25)))
227 }
228 fn exit(&self, ctx: &Context, open: &OpenView) -> bool {
229 open.fraction_of_max >= 0.8
230 || open.days_left <= 3
231 || ctx.sd.decay_strength < 0.5
232 || ctx.sd.lean * open.entry_lean < 0.0
233 }
234}
235
236pub struct SdRouter {
240 pub straddle: CoilStraddle,
241 pub condor: Option<QuietCondor>,
244}
245
246impl Strategy for SdRouter {
247 fn name(&self) -> &'static str {
248 "SdRouter"
249 }
250 fn thesis(&self) -> &'static str {
251 "Coiled and cheap: buy the straddle. Calm and expensive: optionally sell the condor (off by \
252 default, no proven edge). Anything else: stay flat. Directional verticals are left out \
253 because SD's lean has not shown an edge."
254 }
255 fn enter(&self, ctx: &Context) -> Option<Plan> {
256 self.straddle.enter(ctx).or_else(|| self.condor.as_ref().and_then(|c| c.enter(ctx)))
257 }
258 fn exit(&self, ctx: &Context, open: &OpenView) -> bool {
259 match (open.label, &self.condor) {
260 ("iron condor", Some(c)) => c.exit(ctx, open),
261 _ => self.straddle.exit(ctx, open),
262 }
263 }
264}
265
266pub struct Always {
270 pub which: &'static str,
271}
272
273impl Strategy for Always {
274 fn name(&self) -> &'static str {
275 match self.which {
276 "straddle" => "AlwaysStraddle",
277 "condor" => "AlwaysCondor",
278 _ => "AlwaysBullCall",
279 }
280 }
281 fn thesis(&self) -> &'static str {
282 "Baseline: open the same structure whenever flat and hold to the same exits by time and \
283 profit alone. Any SD strategy must beat its baseline or SD is not adding value."
284 }
285 fn enter(&self, ctx: &Context) -> Option<Plan> {
286 Some(match self.which {
287 "straddle" => straddle(),
288 "condor" => {
289 let m = implied_abs_move(ctx.iv, MONTH as f64) * 1.25;
290 condor(m, m * 1.6)
291 }
292 _ => vertical(1.0, ctx.sd.expected_move_pct.clamp(0.02, 0.25)),
293 })
294 }
295 fn exit(&self, _ctx: &Context, open: &OpenView) -> bool {
296 match self.which {
297 "straddle" => open.return_on_risk >= 0.6 || open.days_left <= 5,
298 "condor" => open.fraction_of_max >= 0.5 || open.days_left <= 5,
299 _ => open.fraction_of_max >= 0.8 || open.days_left <= 3,
300 }
301 }
302}
303
304pub struct Named<S: Strategy> {
306 pub label: &'static str,
307 pub inner: S,
308}
309impl<S: Strategy> Named<S> {
310 pub fn new(label: &'static str, inner: S) -> Self {
311 Self { label, inner }
312 }
313}
314impl<S: Strategy> Strategy for Named<S> {
315 fn name(&self) -> &'static str {
316 self.label
317 }
318 fn thesis(&self) -> &'static str {
319 self.inner.thesis()
320 }
321 fn enter(&self, ctx: &Context) -> Option<Plan> {
322 self.inner.enter(ctx)
323 }
324 fn exit(&self, ctx: &Context, open: &OpenView) -> bool {
325 self.inner.exit(ctx, open)
326 }
327}
328
329pub const WEEK: usize = 7;
331
332pub struct WeeklyCoilStraddle {
337 pub min_edge: f64,
338}
339
340impl Strategy for WeeklyCoilStraddle {
341 fn name(&self) -> &'static str {
342 "WeeklyCoilStraddle"
343 }
344 fn thesis(&self) -> &'static str {
345 "The coil trade on a few-day clock: buy a one-week straddle when the stock is coiled and SD expects more movement \
346 than options price in; take profits fast."
347 }
348 fn enter(&self, ctx: &Context) -> Option<Plan> {
349 (ctx.sd.compression >= 2 && ctx.vol_edge() >= self.min_edge).then(|| Plan { dte: WEEK, label: "weekly straddle", ..straddle() })
350 }
351 fn exit(&self, _ctx: &Context, open: &OpenView) -> bool {
352 open.return_on_risk >= 0.4 || open.days_left <= 1
353 }
354}
355
356pub fn short_catalog() -> Vec<Box<dyn Strategy>> {
358 vec![
359 Box::new(WeeklyCoilStraddle { min_edge: 1.30 }),
360 Box::new(Named::new("WeeklyCoilStraddle 1.5", WeeklyCoilStraddle { min_edge: 1.50 })),
361 Box::new(Named::new("WeeklyAlwaysStraddle", WeeklyCoilStraddle { min_edge: 0.0 })),
362 ]
363}
364
365pub fn catalog() -> Vec<Box<dyn Strategy>> {
367 vec![
368 Box::new(CoilStraddle { min_edge: 1.30 }),
369 Box::new(QuietCondor { max_edge: 0.60 }),
370 Box::new(FireVertical { min_lean: 0.3 }),
371 Box::new(SdRouter { straddle: CoilStraddle { min_edge: 1.30 }, condor: None }),
372 Box::new(Named::new("SdRouter+Condor", SdRouter { straddle: CoilStraddle { min_edge: 1.30 }, condor: Some(QuietCondor { max_edge: 0.60 }) })),
373 Box::new(Always { which: "straddle" }),
374 Box::new(Always { which: "condor" }),
375 Box::new(Always { which: "bullcall" }),
376 ]
377}