1use 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
67pub const WINDOWS: [usize; 5] = [5, 10, 21, 42, 63];
69pub const LONG_HORIZONS: [usize; 2] = [126, 252];
71pub 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];
73pub const STATES: [&str; 3] = ["coiled", "fired", "quiet"];
75pub const FILL_THROUGH: f64 = 0.001;
79
80pub 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
91pub fn bucket_of(r: &Sd2Reading) -> usize {
93 state_of(r) * 10 + ((r.intensity * 10.0).floor() as usize).min(9)
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct LowTable {
99 pub quantiles: Vec<f64>,
101 pub median: f64,
103 pub low_day: f64,
106 pub effective: Vec<f64>,
110 pub n: usize,
112}
113
114impl LowTable {
115 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct LongModel {
146 pub horizon: usize,
147 pub groups: String,
149 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#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct TimingModel {
168 pub long: Vec<LongModel>,
170 pub windows: Vec<usize>,
171 pub tables: Vec<Vec<LowTable>>,
173 pub unconditional: Vec<LowTable>,
176 #[serde(default)]
178 pub exit_tables: Vec<Vec<LowTable>>,
179 #[serde(default)]
180 pub exit_unconditional: Vec<LowTable>,
181 pub min_rows: usize,
183 pub fitted_on: String,
184}
185
186#[derive(Debug, Clone, Serialize)]
188pub struct EntryPlan {
189 pub close: f64,
190 pub p_up_126: f64,
192 pub p_up_252: f64,
193 pub action: &'static str,
197 pub window: usize,
199 pub certainty: f64,
200 pub limit_price: f64,
202 pub limit_discount: f64,
203 pub p_fill: f64,
205 pub typical_low: f64,
207 pub typical_low_day: usize,
209 pub expected_saving: f64,
212 pub bucket: String,
213 pub reason: String,
214}
215
216#[derive(Debug, Clone, Copy)]
218pub struct TimingAsk {
219 pub certainty: f64,
221 pub within: usize,
223 pub min_p_up: f64,
225 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 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 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 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 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; 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 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 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 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
340pub fn limit_z(t: &LowTable, c: f64) -> f64 {
348 t.quantile(c).min(0.0)
349}
350
351pub fn sell_z(t: &LowTable, c: f64) -> f64 {
355 t.quantile(1.0 - c).max(0.0)
356}
357
358#[derive(Debug, Clone, Serialize)]
360pub struct ExitPlan {
361 pub action: &'static str,
364 pub window: usize,
365 pub certainty: f64,
366 pub limit_price: f64,
368 pub limit_premium: f64,
369 pub p_fill: f64,
371 pub typical_high: f64,
373 pub typical_high_day: usize,
374 pub expected_gain: f64,
377 pub reason: String,
378}
379
380#[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 pub sd: f64,
389 pub intensity: f64,
390 pub state: &'static str,
391 pub verdict: &'static str,
393 pub entry: EntryPlan,
394 pub exit: ExitPlan,
395}
396
397#[derive(Debug, Clone, Copy)]
399pub struct TimingConfig {
400 pub entry: TimingAsk,
401 pub exit_certainty: f64,
403 pub exit_within: usize,
405 pub sell_below_p126: f64,
410 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 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; 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 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
491pub 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
501fn 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
507pub 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
536pub 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
543pub 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 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 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}