Skip to main content

portfolio_analyzer/
timing.rs

1//! # Timing: should I own this for the long run, and when should I buy it?
2//!
3//! SD v2 is good at one thing: telling how *big* the next move will be
4//! (chapter 3).  It is not good at calling direction over days or weeks
5//! (chapters 2 and 8).  A long-term investor needs two different answers,
6//! and this module gives both:
7//!
8//! 1. **Long-term probability of going up** ([`LongModel`]): the chance the
9//!    stock closes higher in 126 bars (six months) or 252 bars (one year).
10//!    Over a year, the market's upward drift is strong, so this probability
11//!    is mostly drift, adjusted by trend, momentum, volatility and market
12//!    weather.  `sd_timing research` measures how much of it is skill.
13//! 2. **Short-term entry timing** ([`TimingModel::plan`]): for a stock you
14//!    already want, where is it likely to trade *lower* in the next few
15//!    weeks, how likely is it to get there, and is waiting worth the risk of
16//!    missing the fill?  This is where SD's movement forecast pays: a stock
17//!    whose SD expects a big move has a wider, deeper dip ahead than a calm
18//!    one, and the limit price should sit further away.
19//!
20//! ## The entry-timing idea, from zero
21//!
22//! Suppose you will buy a stock within the next `N` trading days, no matter
23//! what.  You can buy now at today's close `C`, or place a limit order at a
24//! lower price `L` that is good for `N` days.  If price trades through `L`,
25//! you pay `L`.  If it never does, you buy at the close on day `N`, which,
26//! because stocks drift up, is usually *higher* than `C`.
27//!
28//! So the question is a trade-off: a deeper limit saves more when it fills,
29//! but fills less often, and every miss costs you the drift.  We measure the
30//! lowest low over the window in units of the stock's normal movement:
31//!
32//! ```text
33//!   z_min = ln(lowest low over days t+1..t+N  ÷  C)  ÷  (σ · √N)
34//! ```
35//!
36//! where `σ` is the 20-bar daily volatility.  Across 125 symbols and 25
37//! years, the distribution of `z_min` depends on where SD's *intensity*
38//! (the expected-move rank) and squeeze state sit: coiled stocks with a big
39//! expected move dip further.  [`LowTable`] stores that distribution as 101
40//! quantiles per (window, state, intensity decile) bucket.  Then:
41//!
42//! * **P(fill at L)** is the share of history in this bucket whose `z_min`
43//!   reached `L`'s `z`.
44//! * **The limit for certainty `c`** is the `c`-quantile of `z_min`: the
45//!   deepest price that history reached at least `c` of the time.
46//! * **The best plan** ([`EntryPlan`]) searches windows `≤ within` and
47//!   certainties `≥ certainty` for the lowest *expected effective entry*:
48//!   the average, over history, of "limit price if filled, day-`N` close if
49//!   not".  If no plan beats buying now, the answer is "buy now".
50//!
51//! **Exit timing is the mirror image** ([`TimingModel::exit_plan`]): the
52//! highest high over the window, `z_max`, sets sell limits, and the
53//! six-month P(up) sets a "hold" or "sell" verdict ([`TimingConfig`]).
54//! [`plan_for`] returns all of it for one stock in one call.
55//!
56//! Every table is learned from history and graded on later years it never
57//! saw: see `docs/13-timing.md` and `reports/timing_*.txt`.
58
59use crate::direction::{direction_frame, MarketWeather};
60use crate::history::DatedBar;
61use crate::indicators::OHLCV;
62use crate::sd2::engine::Sd2Reading;
63use crate::sd2::features::{FeatureConfig, FeatureFrame};
64use crate::sd2::model::Logistic;
65use serde::{Deserialize, Serialize};
66
67/// Entry windows (trading days) with learned tables.
68pub const WINDOWS: [usize; 5] = [5, 10, 21, 42, 63];
69/// Long-term horizons (trading days): six months and one year.
70pub const LONG_HORIZONS: [usize; 2] = [126, 252];
71/// Certainty grid the planner searches.
72pub const CERTAINTIES: [f64; 14] = [0.30, 0.35, 0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95];
73/// Squeeze states, in bucket order.
74pub const STATES: [&str; 3] = ["coiled", "fired", "quiet"];
75/// Limit orders are placed this far (0.1%) *above* the low history
76/// reached, so a fill always means price traded through the order: merely
77/// touching a price does not guarantee your order was reached in the queue.
78pub const FILL_THROUGH: f64 = 0.001;
79
80/// Squeeze state of a reading: 0 coiled, 1 fired (live), 2 quiet.
81pub fn state_of(r: &Sd2Reading) -> usize {
82    if r.is_active_fire {
83        1
84    } else if r.in_squeeze {
85        0
86    } else {
87        2
88    }
89}
90
91/// Bucket index: state × 10 + intensity decile.
92pub fn bucket_of(r: &Sd2Reading) -> usize {
93    state_of(r) * 10 + ((r.intensity * 10.0).floor() as usize).min(9)
94}
95
96/// The distribution of `z_min` (and what happened next) for one bucket.
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct LowTable {
99    /// 101 quantiles of `z_min`, from the 0th to the 100th percentile.
100    pub quantiles: Vec<f64>,
101    /// Median `z_min`: the typical lowest point of the window.
102    pub median: f64,
103    /// Median day of the low (for exit tables: of the high), as a fraction
104    /// of the window (0..1).
105    pub low_day: f64,
106    /// Mean effective entry (exit tables: exit), as a log ratio to today's
107    /// close, for each of [`CERTAINTIES`]: limit price if filled, day-`N`
108    /// close if not.
109    pub effective: Vec<f64>,
110    /// Rows behind this table.
111    pub n: usize,
112}
113
114impl LowTable {
115    /// P(z_min ≤ z), interpolated from the stored quantiles.
116    pub fn cdf(&self, z: f64) -> f64 {
117        let q = &self.quantiles;
118        if q.is_empty() {
119            return f64::NAN;
120        }
121        if z < q[0] {
122            return 0.0;
123        }
124        if z >= q[q.len() - 1] {
125            return 1.0;
126        }
127        let k = q.partition_point(|&v| v <= z).max(1);
128        let (a, b) = (q[k - 1], q[k]);
129        let frac = if b > a { (z - a) / (b - a) } else { 0.0 };
130        ((k - 1) as f64 + frac) / (q.len() - 1) as f64
131    }
132
133    /// The `c`-quantile of `z_min`: the limit, in z units, that history
134    /// reached `c` of the time.
135    pub fn quantile(&self, c: f64) -> f64 {
136        let q = &self.quantiles;
137        let x = c.clamp(0.0, 1.0) * (q.len() - 1) as f64;
138        let k = (x.floor() as usize).min(q.len() - 2);
139        q[k] + (x - k as f64) * (q[k + 1] - q[k])
140    }
141}
142
143/// Long-term P(up) model over [`crate::direction::GROUPS`] columns.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct LongModel {
146    pub horizon: usize,
147    /// Human-readable name of the feature groups used.
148    pub groups: String,
149    /// Column indices into a [`direction_frame`] row.
150    pub cols: Vec<usize>,
151    pub logistic: Logistic,
152}
153
154impl LongModel {
155    pub fn p_up(&self, row: &[f64]) -> f64 {
156        let x: Vec<f64> = self.cols.iter().map(|&c| row[c]).collect();
157        if x.iter().any(|v| !v.is_finite()) {
158            return crate::sd2::model::sigmoid(self.logistic.intercept);
159        }
160        self.logistic.predict(&x)
161    }
162}
163
164/// Everything the timing indicator needs at run time.  Produced by
165/// `sd_timing research` and embedded from `timing_model.json`.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TimingModel {
168    /// One per [`LONG_HORIZONS`].
169    pub long: Vec<LongModel>,
170    pub windows: Vec<usize>,
171    /// `tables[window][bucket]`, 30 buckets (3 states × 10 deciles).
172    pub tables: Vec<Vec<LowTable>>,
173    /// `unconditional[window]`: the same table ignoring SD (the fallback,
174    /// and the baseline SD has to beat).
175    pub unconditional: Vec<LowTable>,
176    /// The exit mirror: quantiles of `z_max` (highest high), same layout.
177    #[serde(default)]
178    pub exit_tables: Vec<Vec<LowTable>>,
179    #[serde(default)]
180    pub exit_unconditional: Vec<LowTable>,
181    /// Buckets with fewer rows than this fall back to `unconditional`.
182    pub min_rows: usize,
183    pub fitted_on: String,
184}
185
186/// A recommended way to buy one stock.
187#[derive(Debug, Clone, Serialize)]
188pub struct EntryPlan {
189    pub close: f64,
190    /// P(higher in 126 / 252 bars).
191    pub p_up_126: f64,
192    pub p_up_252: f64,
193    /// `"skip"`, `"buy now"` or `"limit"`.  For `"limit"`, the fields below
194    /// describe the best plan found; for `"buy now"` (and `"skip"`), the plan
195    /// exactly as asked, so you can see what waiting would cost.
196    pub action: &'static str,
197    /// Window (trading days) and certainty of the chosen plan.
198    pub window: usize,
199    pub certainty: f64,
200    /// Limit price and its distance below the close (0.03 = 3% lower).
201    pub limit_price: f64,
202    pub limit_discount: f64,
203    /// Historical probability that price trades through the limit in time.
204    pub p_fill: f64,
205    /// Typical lowest price in the window (median of history's lows).
206    pub typical_low: f64,
207    /// Typical trading day of that low (1..window).
208    pub typical_low_day: usize,
209    /// Average effective entry vs buying now (−0.01 = 1% cheaper, +0.01 =
210    /// 1% dearer), from history: limit if filled, day-`window` close if not.
211    pub expected_saving: f64,
212    pub bucket: String,
213    pub reason: String,
214}
215
216/// What the person asked for.
217#[derive(Debug, Clone, Copy)]
218pub struct TimingAsk {
219    /// Lowest acceptable fill probability (e.g. 0.7).
220    pub certainty: f64,
221    /// Longest wait, in trading days (e.g. 21).
222    pub within: usize,
223    /// Skip names whose one-year P(up) is below this.
224    pub min_p_up: f64,
225    /// A plan must save at least this much (log) to beat buying now.
226    pub min_saving: f64,
227}
228
229impl Default for TimingAsk {
230    fn default() -> Self {
231        Self { certainty: 0.7, within: 21, min_p_up: 0.5, min_saving: 0.0025 }
232    }
233}
234
235impl TimingModel {
236    pub fn embedded() -> Self {
237        serde_json::from_str(include_str!("timing_model.json")).expect("valid embedded timing_model.json")
238    }
239
240    /// The exit table for a window index and bucket (falls back when thin).
241    pub fn exit_table(&self, w: usize, bucket: usize) -> &LowTable {
242        let t = &self.exit_tables[w][bucket];
243        if t.n >= self.min_rows { t } else { &self.exit_unconditional[w] }
244    }
245
246    /// The table for a window index and bucket (falls back when thin).
247    pub fn table(&self, w: usize, bucket: usize) -> &LowTable {
248        let t = &self.tables[w][bucket];
249        if t.n >= self.min_rows { t } else { &self.unconditional[w] }
250    }
251
252    /// Long-term probabilities from a [`direction_frame`] row.
253    pub fn long_p(&self, row: &[f64]) -> (f64, f64) {
254        let p = |h: usize| self.long.iter().find(|m| m.horizon == h).map(|m| m.p_up(row)).unwrap_or(f64::NAN);
255        (p(126), p(252))
256    }
257
258    /// Choose how to buy, given today's SD reading and long-term row.
259    pub fn plan(&self, r: &Sd2Reading, long_row: &[f64], ask: TimingAsk) -> EntryPlan {
260        let (p126, p252) = self.long_p(long_row);
261        let bucket = bucket_of(r);
262        let label = format!("{}, intensity decile {}", STATES[bucket / 10], bucket % 10 + 1);
263        let mut best: Option<(f64, usize, usize)> = None; // (effective, window idx, certainty idx)
264        for (w, &n) in self.windows.iter().enumerate() {
265            if n > ask.within {
266                continue;
267            }
268            let t = self.table(w, bucket);
269            for (ci, &c) in CERTAINTIES.iter().enumerate() {
270                if c + 1e-9 < ask.certainty {
271                    continue;
272                }
273                let e = t.effective[ci];
274                if e.is_finite() && best.is_none_or(|(b, _, _)| e < b) {
275                    best = Some((e, w, ci));
276                }
277            }
278        }
279        // The plan exactly as asked: the longest window within reach, the
280        // grid certainty nearest the request.
281        let w_ask = self.windows.iter().rposition(|&n| n <= ask.within).unwrap_or(0);
282        let ci_ask = nearest_certainty(ask.certainty);
283        let asked = (self.table(w_ask, bucket).effective[ci_ask], w_ask, ci_ask);
284        let beats_now = best.is_some_and(|(e, _, _)| -e >= ask.min_saving);
285        // Show the best plan if it beats buying now; otherwise show the
286        // asked-for plan, so its fill odds and expected cost are visible.
287        let (eff, w, ci) = if beats_now { best.unwrap() } else { asked };
288        let n = self.windows[w];
289        let t = self.table(w, bucket);
290        let scale = r.vol20 * (n as f64).sqrt();
291        let c = CERTAINTIES[ci];
292        let z_limit = limit_z(t, c);
293        // The order sits FILL_THROUGH above the reached low, so a low at the
294        // quantile trades through it.
295        let limit = r.close * (z_limit * scale).exp() * (1.0 + FILL_THROUGH);
296        let mut plan = EntryPlan {
297            close: r.close,
298            p_up_126: p126,
299            p_up_252: p252,
300            action: "limit",
301            window: n,
302            certainty: c,
303            limit_price: limit,
304            limit_discount: 1.0 - limit / r.close,
305            p_fill: t.cdf(z_limit),
306            typical_low: r.close * (t.median * scale).exp(),
307            typical_low_day: ((t.low_day * n as f64).round() as usize).clamp(1, n),
308            expected_saving: eff.exp() - 1.0,
309            bucket: label,
310            reason: String::new(),
311        };
312        if p252.is_finite() && p252 < ask.min_p_up {
313            plan.action = "skip";
314            plan.reason = format!("one-year P(up) {:.0}% is below your {:.0}% floor", 100.0 * p252, 100.0 * ask.min_p_up);
315        } else if !beats_now || plan.limit_discount <= 0.0 {
316            plan.action = "buy now";
317            plan.reason = format!(
318                "no limit good for ≤{} days with ≥{:.0}% fill odds beat buying now by {:.2}% on average; \
319                 if you still wait, a limit at {:.2} ({:.2}% below) fills {:.0}% of the time but averages {:+.2}% vs buying now, misses included",
320                ask.within,
321                100.0 * ask.certainty,
322                100.0 * ask.min_saving,
323                plan.limit_price,
324                100.0 * plan.limit_discount,
325                100.0 * plan.p_fill,
326                100.0 * plan.expected_saving
327            );
328        } else {
329            plan.reason = format!(
330                "limit {:.2}% below the close, good for {n} days: history filled it {:.0}% of the time and averaged {:.2}% cheaper than buying now, misses included",
331                100.0 * plan.limit_discount,
332                100.0 * plan.p_fill,
333                -100.0 * plan.expected_saving
334            );
335        }
336        plan
337    }
338}
339
340/// The limit (in z units) that a share `c` of history traded through.
341///
342/// `P(z_min ≤ z) = c` ⇔ `z` is the `c`-quantile of `z_min`.  High certainty
343/// means a shallow limit (a high quantile, near or above 0); low certainty
344/// a deep one.
345///
346/// Never above 0: a "limit" above today's close is just buying now.
347pub fn limit_z(t: &LowTable, c: f64) -> f64 {
348    t.quantile(c).min(0.0)
349}
350
351/// The sell limit (in z units of the *highest high*) that a share `c` of
352/// history traded through: `P(z_max ≥ z) = c` ⇔ the `(1 − c)`-quantile.
353/// Never below 0: a sell limit under today's close is just selling now.
354pub fn sell_z(t: &LowTable, c: f64) -> f64 {
355    t.quantile(1.0 - c).max(0.0)
356}
357
358/// A recommended way to sell (or trim) one holding.
359#[derive(Debug, Clone, Serialize)]
360pub struct ExitPlan {
361    /// `"sell now"`, `"sell limit"` (both only when the verdict is sell), or
362    /// `"hold"` (the limit below is then a take-profit level, for reference).
363    pub action: &'static str,
364    pub window: usize,
365    pub certainty: f64,
366    /// Sell limit and its distance above the close (0.03 = 3% higher).
367    pub limit_price: f64,
368    pub limit_premium: f64,
369    /// Historical probability that price trades through the limit in time.
370    pub p_fill: f64,
371    /// Typical highest price in the window, and the day it tends to come.
372    pub typical_high: f64,
373    pub typical_high_day: usize,
374    /// Average exit vs selling now (+0.01 = 1% better), misses included
375    /// (a miss sells at the window's last close).
376    pub expected_gain: f64,
377    pub reason: String,
378}
379
380/// Everything SD timing says about one stock today.
381#[derive(Debug, Clone, Serialize)]
382pub struct TimingPlan {
383    pub date: chrono::NaiveDate,
384    pub close: f64,
385    pub p_up_126: f64,
386    pub p_up_252: f64,
387    /// The SD v2 number and its parts, for context.
388    pub sd: f64,
389    pub intensity: f64,
390    pub state: &'static str,
391    /// `"hold"` or `"sell"` for a position you own (see [`TimingConfig`]).
392    pub verdict: &'static str,
393    pub entry: EntryPlan,
394    pub exit: ExitPlan,
395}
396
397/// Knobs for [`plan_for`].
398#[derive(Debug, Clone, Copy)]
399pub struct TimingConfig {
400    pub entry: TimingAsk,
401    /// Lowest acceptable fill probability for a sell limit.
402    pub exit_certainty: f64,
403    /// Longest wait for a sell limit, trading days.
404    pub exit_within: usize,
405    /// Verdict "sell" when six-month P(up) is below this.  **Off (0) by
406    /// default**: walk-forward on 2019+, names with the *lowest* six-month
407    /// P(up) went on to do *best* (see `reports/timing_research.txt`), so a
408    /// sell verdict from it would have hurt.  Raise it only to experiment.
409    pub sell_below_p126: f64,
410    /// A sell limit must beat selling now by this much (log) on average.
411    pub min_gain: f64,
412}
413
414impl Default for TimingConfig {
415    fn default() -> Self {
416        Self { entry: TimingAsk::default(), exit_certainty: 0.7, exit_within: 21, sell_below_p126: 0.0, min_gain: 0.0025 }
417    }
418}
419
420impl TimingModel {
421    /// Choose how to sell, given today's reading and whether the verdict is sell.
422    pub fn exit_plan(&self, r: &Sd2Reading, cfg: &TimingConfig, sell: bool) -> ExitPlan {
423        let bucket = bucket_of(r);
424        let mut best: Option<(f64, usize, usize)> = None; // (effective, window, certainty), higher is better
425        for (w, &n) in self.windows.iter().enumerate() {
426            if n > cfg.exit_within || self.exit_tables.is_empty() {
427                continue;
428            }
429            let t = self.exit_table(w, bucket);
430            for (ci, &c) in CERTAINTIES.iter().enumerate() {
431                if c + 1e-9 < cfg.exit_certainty {
432                    continue;
433                }
434                let e = t.effective[ci];
435                if e.is_finite() && best.is_none_or(|(b, _, _)| e > b) {
436                    best = Some((e, w, ci));
437                }
438            }
439        }
440        let w_ask = self.windows.iter().rposition(|&n| n <= cfg.exit_within).unwrap_or(0);
441        let ci_ask = nearest_certainty(cfg.exit_certainty);
442        let beats_now = best.is_some_and(|(e, _, _)| e >= cfg.min_gain);
443        let (eff, w, ci) = if beats_now { best.unwrap() } else { (self.exit_table(w_ask, bucket).effective[ci_ask], w_ask, ci_ask) };
444        let n = self.windows[w];
445        let t = self.exit_table(w, bucket);
446        let scale = r.vol20 * (n as f64).sqrt();
447        let zq = sell_z(t, CERTAINTIES[ci]);
448        // The order sits FILL_THROUGH below the reached high.
449        let limit = r.close * (zq * scale).exp() / (1.0 + FILL_THROUGH);
450        let mut plan = ExitPlan {
451            action: "hold",
452            window: n,
453            certainty: CERTAINTIES[ci],
454            limit_price: limit,
455            limit_premium: limit / r.close - 1.0,
456            p_fill: 1.0 - t.cdf(zq),
457            typical_high: r.close * (t.median * scale).exp(),
458            typical_high_day: ((t.low_day * n as f64).round() as usize).clamp(1, n),
459            expected_gain: eff.exp() - 1.0,
460            reason: String::new(),
461        };
462        if !sell {
463            plan.reason = format!(
464                "for reference, a take-profit at {:.2} ({:.2}% above) within {n} days filled {:.0}% of the time",
465                plan.limit_price,
466                100.0 * plan.limit_premium,
467                100.0 * plan.p_fill
468            );
469        } else if beats_now && plan.limit_premium > 0.0 {
470            plan.action = "sell limit";
471            plan.reason = format!(
472                "sell with a limit {:.2}% above the close, good for {n} days: history filled it {:.0}% of the time and averaged {:+.2}% vs selling now, misses included",
473                100.0 * plan.limit_premium,
474                100.0 * plan.p_fill,
475                100.0 * plan.expected_gain
476            );
477        } else {
478            plan.action = "sell now";
479            plan.reason = format!(
480                "no sell limit within {} days with ≥{:.0}% fill odds beat selling now; waiting for {:.2} averages {:+.2}%",
481                cfg.exit_within,
482                100.0 * cfg.exit_certainty,
483                plan.limit_price,
484                100.0 * plan.expected_gain
485            );
486        }
487        plan
488    }
489}
490
491/// Index of the [`CERTAINTIES`] grid value nearest `c`.
492pub fn nearest_certainty(c: f64) -> usize {
493    CERTAINTIES
494        .iter()
495        .enumerate()
496        .min_by(|a, b| (a.1 - c).abs().partial_cmp(&(b.1 - c).abs()).unwrap())
497        .map(|x| x.0)
498        .unwrap()
499}
500
501/// The embedded models, parsed once.
502fn models() -> &'static (TimingModel, crate::sd2::engine::Sd2Model) {
503    static M: std::sync::OnceLock<(TimingModel, crate::sd2::engine::Sd2Model)> = std::sync::OnceLock::new();
504    M.get_or_init(|| (TimingModel::embedded(), crate::sd2::engine::Sd2Model::embedded()))
505}
506
507/// One call for everything: long-term P(up), entry plan, exit plan and
508/// verdict for the last bar of `bars`.  `spy` and `vix` are the market's
509/// weather (SPY and ^VIX daily bars; `vix` may be empty).  Needs about
510/// 300 bars; ten years gives the monthly SD columns their full say.
511pub fn plan_for(bars: &[DatedBar], spy: &[DatedBar], vix: &[DatedBar], cfg: &TimingConfig) -> Option<TimingPlan> {
512    let (tm, sd_model) = models();
513    let ohlcv: Vec<OHLCV> = bars.iter().map(|b| b.bar.clone()).collect();
514    let dates: Vec<chrono::NaiveDate> = bars.iter().map(|b| b.date).collect();
515    let r = crate::sd2::engine::sd2_latest(&ohlcv, sd_model)?;
516    let mkt = MarketWeather::new(spy, vix);
517    let long = long_rows(&ohlcv, &dates, &mkt);
518    let row = long.last()?;
519    let entry = tm.plan(&r, row, cfg.entry);
520    let sell = entry.p_up_126.is_finite() && entry.p_up_126 < cfg.sell_below_p126;
521    let exit = tm.exit_plan(&r, cfg, sell);
522    Some(TimingPlan {
523        date: *dates.last()?,
524        close: r.close,
525        p_up_126: entry.p_up_126,
526        p_up_252: entry.p_up_252,
527        sd: r.sd,
528        intensity: r.intensity,
529        state: STATES[state_of(&r)],
530        verdict: if sell { "sell" } else { "hold" },
531        entry,
532        exit,
533    })
534}
535
536/// Direction rows (long-term features) and SD readings for one symbol, the
537/// way `sd_timing` computes them live and in research.
538pub fn long_rows(bars: &[OHLCV], dates: &[chrono::NaiveDate], mkt: &MarketWeather) -> Vec<Vec<f64>> {
539    let f = FeatureFrame::compute(bars, FeatureConfig::default());
540    direction_frame(bars, dates, &f, mkt)
541}
542
543/// Convenience: build market weather from SPY and VIX bars.
544pub fn weather(spy: &[DatedBar], vix: &[DatedBar]) -> MarketWeather {
545    MarketWeather::new(spy, vix)
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    fn table() -> LowTable {
553        // z_min uniformly spread over [-2, 0].
554        let quantiles: Vec<f64> = (0..=100).map(|k| -2.0 + 2.0 * k as f64 / 100.0).collect();
555        LowTable { quantiles, median: -1.0, low_day: 0.5, effective: vec![0.0; CERTAINTIES.len()], n: 1000 }
556    }
557
558    #[test]
559    fn quantile_and_cdf_are_inverses() {
560        let t = table();
561        for c in [0.1, 0.3, 0.5, 0.7, 0.9] {
562            assert!((t.cdf(t.quantile(c)) - c).abs() < 1e-9);
563        }
564    }
565
566    #[test]
567    fn higher_certainty_means_a_shallower_buy_limit() {
568        let t = table();
569        assert!(limit_z(&t, 0.9) > limit_z(&t, 0.5));
570        assert!(limit_z(&t, 1.0) <= 0.0, "a buy limit never sits above the close");
571    }
572
573    #[test]
574    fn higher_certainty_means_a_lower_sell_limit() {
575        // Mirror: z_max uniformly over [0, 2].
576        let mut t = table();
577        t.quantiles = (0..=100).map(|k| 2.0 * k as f64 / 100.0).collect();
578        assert!(sell_z(&t, 0.9) < sell_z(&t, 0.5));
579        assert!(sell_z(&t, 0.5) >= 0.0);
580        assert!((1.0 - t.cdf(sell_z(&t, 0.7)) - 0.7).abs() < 1e-9);
581    }
582
583    #[test]
584    fn embedded_model_loads_and_has_every_table() {
585        let m = TimingModel::embedded();
586        assert_eq!(m.tables.len(), WINDOWS.len());
587        assert_eq!(m.exit_tables.len(), WINDOWS.len());
588        assert!(m.tables.iter().all(|w| w.len() == 30));
589        assert_eq!(m.long.len(), LONG_HORIZONS.len());
590    }
591}