Skip to main content

portfolio_analyzer/strategy/
mod.rs

1//! # Strategies: turning SD into option trades
2//!
3//! A *strategy* answers two questions every day:
4//!
5//! 1. **Flat?** Should I open a trade, and if so, which option legs?
6//! 2. **In a trade?** Should I close it today?
7//!
8//! That is the whole [`Strategy`] trait.  Pricing, costs, expiry, marking to
9//! market and bookkeeping all live in [`sim`], so a new idea is ~30 lines:
10//! implement `name`, `thesis`, `enter` and `exit`, add it to [`catalog`], and
11//! run `cargo run --release --bin sd_options`.
12//!
13//! ## The ideas, and why each exists
14//!
15//! The backtests in `docs/` showed SD v2 is good at **how big** and poor at
16//! **which way**.  Options let you trade those two separately:
17//!
18//! | Strategy | Bets on | Uses from SD | Why it might work |
19//! | --- | --- | --- | --- |
20//! | [`CoilStraddle`] | a big move, either way | squeeze + expected move vs implied move | squeezes precede expansions; buy vol when it looks cheap |
21//! | [`QuietCondor`] | a small move | low expected move, no squeeze, no live fire | sell vol when SD expects calm *and* options price more |
22//! | [`FireVertical`] | direction after a fire | lean, intensity, decay | the classic SD trade, kept to measure it honestly |
23//! | [`SdRouter`] | whichever regime SD sees | everything | one account, one position, the right tool per regime |
24//! | `Always*` baselines | nothing | nothing | the same trades on a timer: SD must beat these to earn its keep |
25
26pub mod pricing;
27pub mod book;
28pub mod sim;
29
30use crate::sd2::engine::Sd2Reading;
31use pricing::{implied_abs_move, Kind};
32
33/// Everything a strategy may look at on one bar.  All of it is known at the
34/// close of that bar; nothing comes from the future.
35#[derive(Debug, Clone)]
36pub struct Context<'a> {
37    pub spot: f64,
38    /// ATM implied volatility, annualized (real index or proxy).
39    pub iv: f64,
40    pub sd: &'a Sd2Reading,
41    /// SD on the previous bar (for "did it just fire?" questions).
42    pub prev: Option<&'a Sd2Reading>,
43}
44
45impl Context<'_> {
46    /// SD's expected move ÷ the move implied by option prices, both over
47    /// SD's horizon.  Above 1: SD expects more movement than the market is
48    /// charging for.
49    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/// One option leg, with strike given as a multiple of spot (1.00 = ATM).
58#[derive(Debug, Clone, Copy, serde::Serialize)]
59pub struct LegSpec {
60    pub kind: Kind,
61    pub moneyness: f64,
62    /// +1 bought, -1 sold.
63    pub qty: f64,
64}
65
66/// What to open.
67#[derive(Debug, Clone, serde::Serialize)]
68pub struct Plan {
69    pub legs: Vec<LegSpec>,
70    /// Trading days to expiry.
71    pub dte: usize,
72    /// Free-text tag shown in trade logs ("straddle", "bull call", …).
73    pub label: &'static str,
74}
75
76/// What an open trade looks like to `exit`.
77#[derive(Debug, Clone)]
78pub struct OpenView {
79    pub label: &'static str,
80    pub days_held: usize,
81    pub days_left: usize,
82    /// Current value ÷ capital at risk − 1 (e.g. +0.5 = up 50% on risk).
83    pub return_on_risk: f64,
84    /// Fraction of the maximum possible profit already earned (spreads).
85    pub fraction_of_max: f64,
86    /// Lean at entry (to detect a direction flip).
87    pub entry_lean: f64,
88}
89
90/// The whole contract between an idea and the simulator.
91pub trait Strategy: Send + Sync {
92    fn name(&self) -> &'static str;
93    /// One paragraph: what it bets on and why SD should help.
94    fn thesis(&self) -> &'static str;
95    fn enter(&self, ctx: &Context) -> Option<Plan>;
96    fn exit(&self, ctx: &Context, open: &OpenView) -> bool;
97}
98
99/// A month of option life in trading days (≈30 calendar days).
100pub 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
113/// Iron condor with short strikes at ±`short` and wings at ±`wing` (as
114/// fractions of spot).
115fn 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
128/// Debit vertical: buy ATM, sell at `width` beyond, in direction `dir`.
129fn 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
142// ── Long volatility ────────────────────────────────────────────────────────
143
144/// Buy a one-month straddle while price is coiled *and* SD expects a bigger
145/// move than options are pricing.
146pub struct CoilStraddle {
147    /// Minimum `vol_edge` to buy.  1.30 was chosen on 2001-2018 data only
148    /// (`sd_options --sweep`): returns rise steadily with the edge, and
149    /// above 1.45 there are too few trades to trust.
150    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
173// ── Short volatility ───────────────────────────────────────────────────────
174
175/// Sell a defined-risk iron condor when SD expects calm and options price
176/// more movement than SD does.
177pub struct QuietCondor {
178    /// Maximum `vol_edge` to sell.  0.60 was the best pre-2019 setting, but
179    /// no setting shows a reliable edge after costs out of sample.
180    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; // ≈ one σ
199        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
206// ── Direction ──────────────────────────────────────────────────────────────
207
208/// The classic SD trade: when a squeeze fires with a clear lean, buy a debit
209/// spread toward SD's expected move.
210pub 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
236// ── The composite ──────────────────────────────────────────────────────────
237
238/// One position at a time, the right tool for the regime SD reports.
239pub struct SdRouter {
240    pub straddle: CoilStraddle,
241    /// Off by default: no condor setting showed a reliable edge after costs
242    /// out of sample (see `docs/05-options.md`).  Turn it on to experiment.
243    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
266// ── Baselines ──────────────────────────────────────────────────────────────
267
268/// The same trade structure on a timer, ignoring SD entirely.
269pub 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
304/// Gives any strategy a different display name (handy for variants).
305pub 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
329/// A week of option life (trading days).
330pub const WEEK: usize = 7;
331
332/// CoilStraddle on a one-week clock: the same "coiled and cheap" test, a
333/// straddle that expires in about a week, and quick exits.  Short options
334/// carry more gamma (they react more to a sudden move) but also lose value
335/// faster each day and pay proportionally more in spreads.
336pub 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
356/// Short-horizon option strategies (intra-week / few-day setups).
357pub 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
365/// Every strategy the options backtester runs.
366pub 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}