Skip to main content

sandbox_quant/predictor/
mod.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::model::signal::Signal;
4use crate::order_manager::MarketKind;
5
6pub const PREDICTOR_METRIC_WINDOW: usize = 1200;
7pub const PREDICTOR_WINDOW_MAX: usize = 7_200;
8pub const PREDICTOR_R2_MIN_SAMPLES: usize = 60;
9
10#[derive(Debug, Clone, Copy)]
11pub struct YNormal {
12    pub mu: f64,
13    pub sigma: f64,
14}
15
16#[derive(Debug, Clone, Copy)]
17pub struct EwmaYModelConfig {
18    pub alpha_mean: f64,
19    pub alpha_var: f64,
20    pub min_sigma: f64,
21}
22
23impl Default for EwmaYModelConfig {
24    fn default() -> Self {
25        Self {
26            alpha_mean: 0.08,
27            alpha_var: 0.08,
28            min_sigma: 0.001,
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, Default)]
34struct EwmaState {
35    last_price: Option<f64>,
36    mu: f64,
37    var: f64,
38    samples: u64,
39}
40
41#[derive(Debug, Default)]
42pub struct EwmaYModel {
43    cfg: EwmaYModelConfig,
44    by_instrument: HashMap<String, EwmaState>,
45    by_scope_side: HashMap<String, EwmaState>,
46}
47
48impl EwmaYModel {
49    pub fn new(cfg: EwmaYModelConfig) -> Self {
50        Self {
51            cfg,
52            by_instrument: HashMap::new(),
53            by_scope_side: HashMap::new(),
54        }
55    }
56
57    pub fn observe_price(&mut self, instrument: &str, price: f64) {
58        Self::update_state(
59            self.by_instrument
60                .entry(instrument.to_string())
61                .or_default(),
62            price,
63            self.cfg,
64        );
65    }
66
67    pub fn observe_signal_price(
68        &mut self,
69        instrument: &str,
70        source_tag: &str,
71        signal: &Signal,
72        price: f64,
73    ) {
74        let key = scoped_side_key(instrument, source_tag, signal);
75        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
76    }
77
78    pub fn estimate_base(
79        &self,
80        instrument: &str,
81        fallback_mu: f64,
82        fallback_sigma: f64,
83    ) -> YNormal {
84        let Some(st) = self.by_instrument.get(instrument) else {
85            return YNormal {
86                mu: fallback_mu,
87                sigma: fallback_sigma.max(self.cfg.min_sigma),
88            };
89        };
90        let sigma = st.var.max(0.0).sqrt().max(self.cfg.min_sigma);
91        let mu = if st.samples == 0 { fallback_mu } else { st.mu };
92        YNormal { mu, sigma }
93    }
94
95    pub fn estimate_for_signal(
96        &self,
97        instrument: &str,
98        source_tag: &str,
99        signal: &Signal,
100        fallback_mu: f64,
101        fallback_sigma: f64,
102    ) -> YNormal {
103        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
104        let key = scoped_side_key(instrument, source_tag, signal);
105        let Some(scoped) = self.by_scope_side.get(&key) else {
106            return base;
107        };
108        if scoped.samples == 0 {
109            return base;
110        }
111        let n = scoped.samples as f64;
112        let w = n / (n + 20.0);
113        let base_var = base.sigma * base.sigma;
114        let scoped_var = scoped.var.max(0.0);
115        let mu = w * scoped.mu + (1.0 - w) * base.mu;
116        let sigma = (w * scoped_var + (1.0 - w) * base_var)
117            .max(0.0)
118            .sqrt()
119            .max(self.cfg.min_sigma);
120        YNormal { mu, sigma }
121    }
122
123    fn update_state(st: &mut EwmaState, price: f64, cfg: EwmaYModelConfig) {
124        if price <= f64::EPSILON {
125            return;
126        }
127        if let Some(prev) = st.last_price {
128            if prev > f64::EPSILON {
129                let r = (price / prev).ln();
130                let a_mu = cfg.alpha_mean.clamp(0.0, 1.0);
131                let a_var = cfg.alpha_var.clamp(0.0, 1.0);
132                st.mu = if st.samples == 0 {
133                    r
134                } else {
135                    (1.0 - a_mu) * st.mu + a_mu * r
136                };
137                let centered = r - st.mu;
138                let sample_var = centered * centered;
139                st.var = if st.samples == 0 {
140                    sample_var
141                } else {
142                    (1.0 - a_var) * st.var + a_var * sample_var
143                };
144                st.samples = st.samples.saturating_add(1);
145            }
146        }
147        st.last_price = Some(price);
148    }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum PredictorKind {
153    Ewma,
154    Ar1,
155    Holt,
156    Kalman,
157    LinearRls,
158    TsmomRls,
159    MeanRevOu,
160    VolScaledMom,
161    VarRatioAdapt,
162    MicroRevAr,
163    SelfCalibMom,
164    FeatureRls,
165    CrossAssetMacroRls,
166}
167
168#[derive(Debug, Clone, Copy)]
169pub struct PredictorConfig {
170    pub kind: PredictorKind,
171    pub alpha_mean: f64,
172    pub alpha_var: f64,
173    pub min_sigma: f64,
174    pub phi_clip: f64,
175    pub beta_trend: f64,
176    pub process_var: f64,
177    pub measure_var: f64,
178}
179
180#[derive(Debug, Clone, Copy)]
181pub struct PredictorBaseConfig {
182    pub alpha_mean: f64,
183    pub alpha_var: f64,
184    pub min_sigma: f64,
185}
186
187impl Default for PredictorBaseConfig {
188    fn default() -> Self {
189        Self {
190            alpha_mean: 0.08,
191            alpha_var: 0.08,
192            min_sigma: 0.001,
193        }
194    }
195}
196
197pub type PredictorSpecs = Vec<(String, PredictorConfig)>;
198
199pub fn default_predictor_specs(base: PredictorBaseConfig) -> PredictorSpecs {
200    vec![
201        (
202            "ewma-fast-v1".to_string(),
203            PredictorConfig {
204                kind: PredictorKind::Ewma,
205                alpha_mean: 0.18,
206                alpha_var: 0.18,
207                min_sigma: base.min_sigma,
208                phi_clip: 0.98,
209                beta_trend: 0.08,
210                process_var: 1e-6,
211                measure_var: 1e-4,
212            },
213        ),
214        (
215            "ewma-v1".to_string(),
216            PredictorConfig {
217                kind: PredictorKind::Ewma,
218                alpha_mean: base.alpha_mean,
219                alpha_var: base.alpha_var,
220                min_sigma: base.min_sigma,
221                phi_clip: 0.98,
222                beta_trend: 0.08,
223                process_var: 1e-6,
224                measure_var: 1e-4,
225            },
226        ),
227        (
228            "ewma-slow-v1".to_string(),
229            PredictorConfig {
230                kind: PredictorKind::Ewma,
231                alpha_mean: 0.03,
232                alpha_var: 0.03,
233                min_sigma: base.min_sigma,
234                phi_clip: 0.98,
235                beta_trend: 0.04,
236                process_var: 1e-6,
237                measure_var: 1e-4,
238            },
239        ),
240        (
241            "ar1-v1".to_string(),
242            PredictorConfig {
243                kind: PredictorKind::Ar1,
244                alpha_mean: base.alpha_mean,
245                alpha_var: base.alpha_var,
246                min_sigma: base.min_sigma,
247                phi_clip: 0.98,
248                beta_trend: 0.08,
249                process_var: 1e-6,
250                measure_var: 1e-4,
251            },
252        ),
253        (
254            "ar1-fast-v1".to_string(),
255            PredictorConfig {
256                kind: PredictorKind::Ar1,
257                alpha_mean: 0.18,
258                alpha_var: 0.18,
259                min_sigma: base.min_sigma,
260                phi_clip: 0.98,
261                beta_trend: 0.08,
262                process_var: 1e-6,
263                measure_var: 1e-4,
264            },
265        ),
266        (
267            "holt-v1".to_string(),
268            PredictorConfig {
269                kind: PredictorKind::Holt,
270                alpha_mean: base.alpha_mean,
271                alpha_var: base.alpha_var,
272                min_sigma: base.min_sigma,
273                phi_clip: 0.98,
274                beta_trend: 0.08,
275                process_var: 1e-6,
276                measure_var: 1e-4,
277            },
278        ),
279        (
280            "holt-fast-v1".to_string(),
281            PredictorConfig {
282                kind: PredictorKind::Holt,
283                alpha_mean: 0.20,
284                alpha_var: 0.18,
285                min_sigma: base.min_sigma,
286                phi_clip: 0.98,
287                beta_trend: 0.15,
288                process_var: 1e-6,
289                measure_var: 1e-4,
290            },
291        ),
292        (
293            "kalman-v1".to_string(),
294            PredictorConfig {
295                kind: PredictorKind::Kalman,
296                alpha_mean: base.alpha_mean,
297                alpha_var: base.alpha_var,
298                min_sigma: base.min_sigma,
299                phi_clip: 0.98,
300                beta_trend: 0.08,
301                process_var: 1e-6,
302                measure_var: 1e-4,
303            },
304        ),
305        (
306            "lin-ind-v1".to_string(),
307            PredictorConfig {
308                kind: PredictorKind::LinearRls,
309                alpha_mean: 0.20,
310                alpha_var: 0.10,
311                min_sigma: base.min_sigma,
312                phi_clip: 0.995,
313                beta_trend: 0.05,
314                process_var: 1e-2,
315                measure_var: 0.0,
316            },
317        ),
318        (
319            "tsmom-rls-v1".to_string(),
320            PredictorConfig {
321                kind: PredictorKind::TsmomRls,
322                alpha_mean: 0.20,
323                alpha_var: 0.10,
324                min_sigma: base.min_sigma,
325                phi_clip: 0.995,
326                beta_trend: 0.05,
327                process_var: 1e-2,
328                measure_var: 0.0,
329            },
330        ),
331        (
332            "ou-revert-v1".to_string(),
333            PredictorConfig {
334                kind: PredictorKind::MeanRevOu,
335                alpha_mean: 0.002,
336                alpha_var: 0.05,
337                min_sigma: base.min_sigma,
338                phi_clip: 0.02,
339                beta_trend: 3.0,
340                process_var: 0.0,
341                measure_var: 0.0,
342            },
343        ),
344        (
345            "ou-revert-fast-v1".to_string(),
346            PredictorConfig {
347                kind: PredictorKind::MeanRevOu,
348                alpha_mean: 0.008,
349                alpha_var: 0.05,
350                min_sigma: base.min_sigma,
351                phi_clip: 0.035,
352                beta_trend: 3.0,
353                process_var: 0.0,
354                measure_var: 0.0,
355            },
356        ),
357        (
358            "volmom-v1".to_string(),
359            PredictorConfig {
360                kind: PredictorKind::VolScaledMom,
361                alpha_mean: 0.10,
362                alpha_var: 0.05,
363                min_sigma: base.min_sigma,
364                phi_clip: 0.015,
365                beta_trend: 3.0,
366                process_var: 0.02,
367                measure_var: 0.0,
368            },
369        ),
370        (
371            "volmom-fast-v1".to_string(),
372            PredictorConfig {
373                kind: PredictorKind::VolScaledMom,
374                alpha_mean: 0.20,
375                alpha_var: 0.08,
376                min_sigma: base.min_sigma,
377                phi_clip: 0.025,
378                beta_trend: 3.0,
379                process_var: 0.04,
380                measure_var: 0.0,
381            },
382        ),
383        (
384            "varratio-v1".to_string(),
385            PredictorConfig {
386                kind: PredictorKind::VarRatioAdapt,
387                alpha_mean: 0.15,
388                alpha_var: 0.02,
389                min_sigma: base.min_sigma,
390                phi_clip: 0.015,
391                beta_trend: 1.0,
392                process_var: 0.06,
393                measure_var: 0.0,
394            },
395        ),
396        (
397            "varratio-fast-v1".to_string(),
398            PredictorConfig {
399                kind: PredictorKind::VarRatioAdapt,
400                alpha_mean: 0.25,
401                alpha_var: 0.04,
402                min_sigma: base.min_sigma,
403                phi_clip: 0.025,
404                beta_trend: 1.0,
405                process_var: 0.10,
406                measure_var: 0.0,
407            },
408        ),
409        (
410            "microrev-v1".to_string(),
411            PredictorConfig {
412                kind: PredictorKind::MicroRevAr,
413                alpha_mean: 0.04,
414                alpha_var: 0.04,
415                min_sigma: base.min_sigma,
416                phi_clip: 0.15,
417                beta_trend: 0.0,
418                process_var: 0.0,
419                measure_var: 0.0,
420            },
421        ),
422        (
423            "microrev-fast-v1".to_string(),
424            PredictorConfig {
425                kind: PredictorKind::MicroRevAr,
426                alpha_mean: 0.10,
427                alpha_var: 0.10,
428                min_sigma: base.min_sigma,
429                phi_clip: 0.15,
430                beta_trend: 0.0,
431                process_var: 0.0,
432                measure_var: 0.0,
433            },
434        ),
435        (
436            "selfcalib-v1".to_string(),
437            PredictorConfig {
438                kind: PredictorKind::SelfCalibMom,
439                alpha_mean: 0.12,
440                alpha_var: 0.05,
441                min_sigma: base.min_sigma,
442                phi_clip: 0.0,
443                beta_trend: 0.03,
444                process_var: 0.03,
445                measure_var: 0.0,
446            },
447        ),
448        (
449            "selfcalib-fast-v1".to_string(),
450            PredictorConfig {
451                kind: PredictorKind::SelfCalibMom,
452                alpha_mean: 0.20,
453                alpha_var: 0.08,
454                min_sigma: base.min_sigma,
455                phi_clip: 0.0,
456                beta_trend: 0.05,
457                process_var: 0.05,
458                measure_var: 0.0,
459            },
460        ),
461        (
462            "feat-rls-v1".to_string(),
463            PredictorConfig {
464                kind: PredictorKind::FeatureRls,
465                alpha_mean: 0.12,
466                alpha_var: 0.04,
467                min_sigma: base.min_sigma,
468                phi_clip: 0.998,
469                beta_trend: 0.02,
470                process_var: 0.05,
471                measure_var: 3.0,
472            },
473        ),
474        (
475            "feat-rls-robust-v1".to_string(),
476            PredictorConfig {
477                kind: PredictorKind::FeatureRls,
478                alpha_mean: 0.08,
479                alpha_var: 0.03,
480                min_sigma: base.min_sigma,
481                phi_clip: 0.999,
482                beta_trend: 0.015,
483                process_var: 0.08,
484                measure_var: 2.2,
485            },
486        ),
487        (
488            "feat-rls-fast-v1".to_string(),
489            PredictorConfig {
490                kind: PredictorKind::FeatureRls,
491                alpha_mean: 0.20,
492                alpha_var: 0.08,
493                min_sigma: base.min_sigma,
494                phi_clip: 0.996,
495                beta_trend: 0.04,
496                process_var: 0.05,
497                measure_var: 3.0,
498            },
499        ),
500        (
501            "xasset-macro-rls-v1".to_string(),
502            PredictorConfig {
503                kind: PredictorKind::CrossAssetMacroRls,
504                alpha_mean: 0.10,
505                alpha_var: 0.08,
506                min_sigma: base.min_sigma,
507                phi_clip: 0.998,
508                beta_trend: 0.02,
509                process_var: 0.05,
510                measure_var: 2.5,
511            },
512        ),
513    ]
514}
515
516pub fn default_predictor_horizons() -> Vec<(String, u64)> {
517    vec![
518        ("1m".to_string(), 60_000),
519        ("3m".to_string(), 180_000),
520        ("5m".to_string(), 300_000),
521    ]
522}
523
524pub fn build_predictor_models(
525    specs: &[(String, PredictorConfig)],
526) -> HashMap<String, PredictorModel> {
527    let mut out = HashMap::new();
528    for (id, cfg) in specs {
529        let model = match cfg.kind {
530            PredictorKind::Ewma => PredictorModel::Ewma(EwmaYModel::new(EwmaYModelConfig {
531                alpha_mean: cfg.alpha_mean,
532                alpha_var: cfg.alpha_var,
533                min_sigma: cfg.min_sigma,
534            })),
535            PredictorKind::Ar1 => PredictorModel::Ar1(Ar1YModel::new(Ar1YModelConfig {
536                alpha_mean: cfg.alpha_mean,
537                alpha_var: cfg.alpha_var,
538                min_sigma: cfg.min_sigma,
539                phi_clip: cfg.phi_clip,
540            })),
541            PredictorKind::Holt => PredictorModel::Holt(HoltYModel::new(HoltYModelConfig {
542                alpha_mean: cfg.alpha_mean,
543                beta_trend: cfg.beta_trend,
544                alpha_var: cfg.alpha_var,
545                min_sigma: cfg.min_sigma,
546            })),
547            PredictorKind::Kalman => {
548                PredictorModel::Kalman(KalmanYModel::new(KalmanYModelConfig {
549                    process_var: cfg.process_var,
550                    measure_var: cfg.measure_var,
551                    min_sigma: cfg.min_sigma,
552                }))
553            }
554            PredictorKind::LinearRls => {
555                PredictorModel::LinearRls(LinearRlsYModel::new(LinearRlsYModelConfig {
556                    alpha_fast: cfg.alpha_mean,
557                    alpha_slow: cfg.beta_trend,
558                    alpha_vol: cfg.alpha_var,
559                    forgetting: cfg.phi_clip,
560                    ridge: cfg.process_var,
561                    min_sigma: cfg.min_sigma,
562                }))
563            }
564            PredictorKind::TsmomRls => {
565                PredictorModel::TsmomRls(TsmomRlsYModel::new(LinearRlsYModelConfig {
566                    alpha_fast: cfg.alpha_mean,
567                    alpha_slow: cfg.beta_trend,
568                    alpha_vol: cfg.alpha_var,
569                    forgetting: cfg.phi_clip,
570                    ridge: cfg.process_var,
571                    min_sigma: cfg.min_sigma,
572                }))
573            }
574            PredictorKind::MeanRevOu => {
575                PredictorModel::MeanRevOu(MeanRevOuYModel::new(MeanRevOuYModelConfig {
576                    alpha_level: cfg.alpha_mean,
577                    alpha_var: cfg.alpha_var,
578                    kappa: cfg.phi_clip,
579                    z_clip: cfg.beta_trend,
580                    min_sigma: cfg.min_sigma,
581                }))
582            }
583            PredictorKind::VolScaledMom => {
584                PredictorModel::VolScaledMom(VolScaledMomYModel::new(VolScaledMomYModelConfig {
585                    alpha_fast: cfg.alpha_mean,
586                    alpha_slow: cfg.process_var,
587                    alpha_vol: cfg.alpha_var,
588                    kappa: cfg.phi_clip,
589                    signal_clip: cfg.beta_trend,
590                    min_sigma: cfg.min_sigma,
591                }))
592            }
593            PredictorKind::VarRatioAdapt => {
594                PredictorModel::VarRatioAdapt(VarRatioAdaptYModel::new(VarRatioAdaptYModelConfig {
595                    alpha_fast_var: cfg.alpha_mean,
596                    alpha_slow_var: cfg.alpha_var,
597                    alpha_trend: cfg.process_var,
598                    kappa: cfg.phi_clip,
599                    regime_clip: cfg.beta_trend,
600                    min_sigma: cfg.min_sigma,
601                }))
602            }
603            PredictorKind::MicroRevAr => {
604                PredictorModel::MicroRevAr(MicroRevArYModel::new(MicroRevArYModelConfig {
605                    alpha: cfg.alpha_mean,
606                    phi_max: cfg.phi_clip,
607                    min_sigma: cfg.min_sigma,
608                }))
609            }
610            PredictorKind::SelfCalibMom => {
611                PredictorModel::SelfCalibMom(SelfCalibMomYModel::new(SelfCalibMomYModelConfig {
612                    alpha_fast: cfg.alpha_mean,
613                    alpha_slow: cfg.beta_trend,
614                    alpha_var: cfg.alpha_var,
615                    alpha_calib: cfg.process_var,
616                    min_sigma: cfg.min_sigma,
617                }))
618            }
619            PredictorKind::FeatureRls => {
620                PredictorModel::FeatureRls(FeatureRlsYModel::new(FeatureRlsYModelConfig {
621                    alpha_fast: cfg.alpha_mean,
622                    alpha_slow: cfg.beta_trend,
623                    alpha_var: cfg.alpha_var,
624                    forgetting: cfg.phi_clip,
625                    ridge: cfg.process_var,
626                    pred_clip: cfg.measure_var,
627                    min_sigma: cfg.min_sigma,
628                }))
629            }
630            PredictorKind::CrossAssetMacroRls => PredictorModel::CrossAssetMacroRls(
631                CrossAssetMacroRlsYModel::new(CrossAssetMacroRlsYModelConfig {
632                    alpha_factor: cfg.alpha_mean,
633                    alpha_resid: cfg.alpha_var,
634                    forgetting: cfg.phi_clip,
635                    ridge: cfg.process_var,
636                    pred_clip: cfg.measure_var,
637                    min_sigma: cfg.min_sigma,
638                }),
639            ),
640        };
641        out.insert(id.clone(), model);
642    }
643    out
644}
645
646#[derive(Debug)]
647pub enum PredictorModel {
648    Ewma(EwmaYModel),
649    Ar1(Ar1YModel),
650    Holt(HoltYModel),
651    Kalman(KalmanYModel),
652    LinearRls(LinearRlsYModel),
653    TsmomRls(TsmomRlsYModel),
654    MeanRevOu(MeanRevOuYModel),
655    VolScaledMom(VolScaledMomYModel),
656    VarRatioAdapt(VarRatioAdaptYModel),
657    MicroRevAr(MicroRevArYModel),
658    SelfCalibMom(SelfCalibMomYModel),
659    FeatureRls(FeatureRlsYModel),
660    CrossAssetMacroRls(CrossAssetMacroRlsYModel),
661}
662
663impl PredictorModel {
664    pub fn observe_price(&mut self, instrument: &str, price: f64) {
665        match self {
666            Self::Ewma(m) => m.observe_price(instrument, price),
667            Self::Ar1(m) => m.observe_price(instrument, price),
668            Self::Holt(m) => m.observe_price(instrument, price),
669            Self::Kalman(m) => m.observe_price(instrument, price),
670            Self::LinearRls(m) => m.observe_price(instrument, price),
671            Self::TsmomRls(m) => m.observe_price(instrument, price),
672            Self::MeanRevOu(m) => m.observe_price(instrument, price),
673            Self::VolScaledMom(m) => m.observe_price(instrument, price),
674            Self::VarRatioAdapt(m) => m.observe_price(instrument, price),
675            Self::MicroRevAr(m) => m.observe_price(instrument, price),
676            Self::SelfCalibMom(m) => m.observe_price(instrument, price),
677            Self::FeatureRls(m) => m.observe_price(instrument, price),
678            Self::CrossAssetMacroRls(m) => m.observe_price(instrument, price),
679        }
680    }
681
682    pub fn observe_signal_price(
683        &mut self,
684        instrument: &str,
685        source_tag: &str,
686        signal: &Signal,
687        price: f64,
688    ) {
689        match self {
690            Self::Ewma(m) => m.observe_signal_price(instrument, source_tag, signal, price),
691            Self::Ar1(m) => m.observe_signal_price(instrument, source_tag, signal, price),
692            Self::Holt(m) => m.observe_signal_price(instrument, source_tag, signal, price),
693            Self::Kalman(m) => m.observe_signal_price(instrument, source_tag, signal, price),
694            Self::LinearRls(m) => m.observe_signal_price(instrument, source_tag, signal, price),
695            Self::TsmomRls(m) => m.observe_signal_price(instrument, source_tag, signal, price),
696            Self::MeanRevOu(m) => m.observe_signal_price(instrument, source_tag, signal, price),
697            Self::VolScaledMom(m) => m.observe_signal_price(instrument, source_tag, signal, price),
698            Self::VarRatioAdapt(m) => m.observe_signal_price(instrument, source_tag, signal, price),
699            Self::MicroRevAr(m) => m.observe_signal_price(instrument, source_tag, signal, price),
700            Self::SelfCalibMom(m) => m.observe_signal_price(instrument, source_tag, signal, price),
701            Self::FeatureRls(m) => m.observe_signal_price(instrument, source_tag, signal, price),
702            Self::CrossAssetMacroRls(m) => {
703                m.observe_signal_price(instrument, source_tag, signal, price)
704            }
705        }
706    }
707
708    pub fn estimate_base(
709        &self,
710        instrument: &str,
711        fallback_mu: f64,
712        fallback_sigma: f64,
713    ) -> YNormal {
714        match self {
715            Self::Ewma(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
716            Self::Ar1(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
717            Self::Holt(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
718            Self::Kalman(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
719            Self::LinearRls(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
720            Self::TsmomRls(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
721            Self::MeanRevOu(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
722            Self::VolScaledMom(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
723            Self::VarRatioAdapt(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
724            Self::MicroRevAr(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
725            Self::SelfCalibMom(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
726            Self::FeatureRls(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
727            Self::CrossAssetMacroRls(m) => m.estimate_base(instrument, fallback_mu, fallback_sigma),
728        }
729    }
730
731    pub fn estimate_for_signal(
732        &self,
733        instrument: &str,
734        source_tag: &str,
735        signal: &Signal,
736        fallback_mu: f64,
737        fallback_sigma: f64,
738    ) -> YNormal {
739        match self {
740            Self::Ewma(m) => {
741                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
742            }
743            Self::Ar1(m) => {
744                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
745            }
746            Self::Holt(m) => {
747                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
748            }
749            Self::Kalman(m) => {
750                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
751            }
752            Self::LinearRls(m) => {
753                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
754            }
755            Self::TsmomRls(m) => {
756                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
757            }
758            Self::MeanRevOu(m) => {
759                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
760            }
761            Self::VolScaledMom(m) => {
762                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
763            }
764            Self::VarRatioAdapt(m) => {
765                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
766            }
767            Self::MicroRevAr(m) => {
768                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
769            }
770            Self::SelfCalibMom(m) => {
771                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
772            }
773            Self::FeatureRls(m) => {
774                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
775            }
776            Self::CrossAssetMacroRls(m) => {
777                m.estimate_for_signal(instrument, source_tag, signal, fallback_mu, fallback_sigma)
778            }
779        }
780    }
781}
782
783#[derive(Debug, Clone, Copy)]
784pub struct Ar1YModelConfig {
785    pub alpha_mean: f64,
786    pub alpha_var: f64,
787    pub min_sigma: f64,
788    pub phi_clip: f64,
789}
790
791impl Default for Ar1YModelConfig {
792    fn default() -> Self {
793        Self {
794            alpha_mean: 0.08,
795            alpha_var: 0.08,
796            min_sigma: 0.001,
797            phi_clip: 0.98,
798        }
799    }
800}
801
802#[derive(Debug, Clone, Copy, Default)]
803struct Ar1State {
804    last_price: Option<f64>,
805    last_return: Option<f64>,
806    mu: f64,
807    var: f64,
808    cov1: f64,
809    samples: u64,
810}
811
812#[derive(Debug, Default)]
813pub struct Ar1YModel {
814    cfg: Ar1YModelConfig,
815    by_instrument: HashMap<String, Ar1State>,
816    by_scope_side: HashMap<String, Ar1State>,
817}
818
819impl Ar1YModel {
820    pub fn new(cfg: Ar1YModelConfig) -> Self {
821        Self {
822            cfg,
823            by_instrument: HashMap::new(),
824            by_scope_side: HashMap::new(),
825        }
826    }
827
828    pub fn observe_price(&mut self, instrument: &str, price: f64) {
829        Self::update_state(
830            self.by_instrument
831                .entry(instrument.to_string())
832                .or_default(),
833            price,
834            self.cfg,
835        );
836    }
837
838    pub fn observe_signal_price(
839        &mut self,
840        instrument: &str,
841        source_tag: &str,
842        signal: &Signal,
843        price: f64,
844    ) {
845        let key = scoped_side_key(instrument, source_tag, signal);
846        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
847    }
848
849    pub fn estimate_base(
850        &self,
851        instrument: &str,
852        fallback_mu: f64,
853        fallback_sigma: f64,
854    ) -> YNormal {
855        let Some(st) = self.by_instrument.get(instrument) else {
856            return YNormal {
857                mu: fallback_mu,
858                sigma: fallback_sigma.max(self.cfg.min_sigma),
859            };
860        };
861        let (mu, sigma) = ar1_forecast(st, self.cfg, fallback_mu, fallback_sigma);
862        YNormal { mu, sigma }
863    }
864
865    pub fn estimate_for_signal(
866        &self,
867        instrument: &str,
868        source_tag: &str,
869        signal: &Signal,
870        fallback_mu: f64,
871        fallback_sigma: f64,
872    ) -> YNormal {
873        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
874        let key = scoped_side_key(instrument, source_tag, signal);
875        let Some(scoped) = self.by_scope_side.get(&key) else {
876            return base;
877        };
878        if scoped.samples == 0 {
879            return base;
880        }
881        let (scoped_mu, scoped_sigma) = ar1_forecast(scoped, self.cfg, base.mu, base.sigma);
882        let n = scoped.samples as f64;
883        let w = n / (n + 20.0);
884        let mu = w * scoped_mu + (1.0 - w) * base.mu;
885        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
886        YNormal { mu, sigma }
887    }
888
889    fn update_state(st: &mut Ar1State, price: f64, cfg: Ar1YModelConfig) {
890        if price <= f64::EPSILON {
891            return;
892        }
893        if let Some(prev) = st.last_price {
894            if prev > f64::EPSILON {
895                let r = (price / prev).ln();
896                let prev_mu = st.mu;
897                let a_mu = cfg.alpha_mean.clamp(0.0, 1.0);
898                let a_var = cfg.alpha_var.clamp(0.0, 1.0);
899                st.mu = if st.samples == 0 {
900                    r
901                } else {
902                    (1.0 - a_mu) * st.mu + a_mu * r
903                };
904                let centered = r - prev_mu;
905                let sample_var = centered * centered;
906                st.var = if st.samples == 0 {
907                    sample_var
908                } else {
909                    (1.0 - a_var) * st.var + a_var * sample_var
910                };
911                if let Some(prev_r) = st.last_return {
912                    let cov = (prev_r - prev_mu) * (r - prev_mu);
913                    st.cov1 = if st.samples <= 1 {
914                        cov
915                    } else {
916                        (1.0 - a_var) * st.cov1 + a_var * cov
917                    };
918                }
919                st.last_return = Some(r);
920                st.samples = st.samples.saturating_add(1);
921            }
922        }
923        st.last_price = Some(price);
924    }
925}
926
927fn ar1_forecast(
928    st: &Ar1State,
929    cfg: Ar1YModelConfig,
930    fallback_mu: f64,
931    fallback_sigma: f64,
932) -> (f64, f64) {
933    if st.samples == 0 {
934        return (fallback_mu, fallback_sigma.max(cfg.min_sigma));
935    }
936    let var = st.var.max(0.0);
937    let mut phi = if var > 1e-12 { st.cov1 / var } else { 0.0 };
938    phi = phi.clamp(-cfg.phi_clip, cfg.phi_clip);
939    let mu = if let Some(last_r) = st.last_return {
940        st.mu + phi * (last_r - st.mu)
941    } else {
942        st.mu
943    };
944    let eps_var = (1.0 - phi * phi).max(0.05) * var;
945    let sigma = eps_var.sqrt().max(cfg.min_sigma);
946    (mu, sigma)
947}
948
949#[derive(Debug, Clone, Copy)]
950pub struct HoltYModelConfig {
951    pub alpha_mean: f64,
952    pub beta_trend: f64,
953    pub alpha_var: f64,
954    pub min_sigma: f64,
955}
956
957impl Default for HoltYModelConfig {
958    fn default() -> Self {
959        Self {
960            alpha_mean: 0.08,
961            beta_trend: 0.08,
962            alpha_var: 0.08,
963            min_sigma: 0.001,
964        }
965    }
966}
967
968#[derive(Debug, Clone, Copy, Default)]
969struct HoltState {
970    last_price: Option<f64>,
971    level: f64,
972    trend: f64,
973    var: f64,
974    samples: u64,
975}
976
977#[derive(Debug, Default)]
978pub struct HoltYModel {
979    cfg: HoltYModelConfig,
980    by_instrument: HashMap<String, HoltState>,
981    by_scope_side: HashMap<String, HoltState>,
982}
983
984impl HoltYModel {
985    pub fn new(cfg: HoltYModelConfig) -> Self {
986        Self {
987            cfg,
988            by_instrument: HashMap::new(),
989            by_scope_side: HashMap::new(),
990        }
991    }
992
993    pub fn observe_price(&mut self, instrument: &str, price: f64) {
994        Self::update_state(
995            self.by_instrument
996                .entry(instrument.to_string())
997                .or_default(),
998            price,
999            self.cfg,
1000        );
1001    }
1002
1003    pub fn observe_signal_price(
1004        &mut self,
1005        instrument: &str,
1006        source_tag: &str,
1007        signal: &Signal,
1008        price: f64,
1009    ) {
1010        let key = scoped_side_key(instrument, source_tag, signal);
1011        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
1012    }
1013
1014    pub fn estimate_base(
1015        &self,
1016        instrument: &str,
1017        fallback_mu: f64,
1018        fallback_sigma: f64,
1019    ) -> YNormal {
1020        let Some(st) = self.by_instrument.get(instrument) else {
1021            return YNormal {
1022                mu: fallback_mu,
1023                sigma: fallback_sigma.max(self.cfg.min_sigma),
1024            };
1025        };
1026        let mu = if st.samples == 0 {
1027            fallback_mu
1028        } else {
1029            st.level + st.trend
1030        };
1031        let sigma = if st.samples == 0 {
1032            fallback_sigma.max(self.cfg.min_sigma)
1033        } else {
1034            st.var.sqrt().max(self.cfg.min_sigma)
1035        };
1036        YNormal { mu, sigma }
1037    }
1038
1039    pub fn estimate_for_signal(
1040        &self,
1041        instrument: &str,
1042        source_tag: &str,
1043        signal: &Signal,
1044        fallback_mu: f64,
1045        fallback_sigma: f64,
1046    ) -> YNormal {
1047        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
1048        let key = scoped_side_key(instrument, source_tag, signal);
1049        let Some(scoped) = self.by_scope_side.get(&key) else {
1050            return base;
1051        };
1052        if scoped.samples == 0 {
1053            return base;
1054        }
1055        let scoped_mu = scoped.level + scoped.trend;
1056        let scoped_sigma = scoped.var.sqrt().max(self.cfg.min_sigma);
1057        let n = scoped.samples as f64;
1058        let w = n / (n + 20.0);
1059        let mu = w * scoped_mu + (1.0 - w) * base.mu;
1060        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
1061        YNormal { mu, sigma }
1062    }
1063
1064    fn update_state(st: &mut HoltState, price: f64, cfg: HoltYModelConfig) {
1065        if price <= f64::EPSILON {
1066            return;
1067        }
1068        if let Some(prev) = st.last_price {
1069            if prev > f64::EPSILON {
1070                let r = (price / prev).ln();
1071                let a = cfg.alpha_mean.clamp(0.0, 1.0);
1072                let b = cfg.beta_trend.clamp(0.0, 1.0);
1073                let a_var = cfg.alpha_var.clamp(0.0, 1.0);
1074                if st.samples == 0 {
1075                    st.level = r;
1076                    st.trend = 0.0;
1077                    st.var = 0.0;
1078                } else {
1079                    let pred = st.level + st.trend;
1080                    let new_level = a * r + (1.0 - a) * pred;
1081                    let new_trend = b * (new_level - st.level) + (1.0 - b) * st.trend;
1082                    let err = r - pred;
1083                    st.var = (1.0 - a_var) * st.var + a_var * (err * err);
1084                    st.level = new_level;
1085                    st.trend = new_trend;
1086                }
1087                st.samples = st.samples.saturating_add(1);
1088            }
1089        }
1090        st.last_price = Some(price);
1091    }
1092}
1093
1094#[derive(Debug, Clone, Copy)]
1095pub struct KalmanYModelConfig {
1096    pub process_var: f64,
1097    pub measure_var: f64,
1098    pub min_sigma: f64,
1099}
1100
1101impl Default for KalmanYModelConfig {
1102    fn default() -> Self {
1103        Self {
1104            process_var: 1e-6,
1105            measure_var: 1e-4,
1106            min_sigma: 0.001,
1107        }
1108    }
1109}
1110
1111#[derive(Debug, Clone, Copy, Default)]
1112struct KalmanState {
1113    last_price: Option<f64>,
1114    x: f64,
1115    p: f64,
1116    samples: u64,
1117}
1118
1119#[derive(Debug, Default)]
1120pub struct KalmanYModel {
1121    cfg: KalmanYModelConfig,
1122    by_instrument: HashMap<String, KalmanState>,
1123    by_scope_side: HashMap<String, KalmanState>,
1124}
1125
1126impl KalmanYModel {
1127    pub fn new(cfg: KalmanYModelConfig) -> Self {
1128        Self {
1129            cfg,
1130            by_instrument: HashMap::new(),
1131            by_scope_side: HashMap::new(),
1132        }
1133    }
1134
1135    pub fn observe_price(&mut self, instrument: &str, price: f64) {
1136        Self::update_state(
1137            self.by_instrument
1138                .entry(instrument.to_string())
1139                .or_default(),
1140            price,
1141            self.cfg,
1142        );
1143    }
1144
1145    pub fn observe_signal_price(
1146        &mut self,
1147        instrument: &str,
1148        source_tag: &str,
1149        signal: &Signal,
1150        price: f64,
1151    ) {
1152        let key = scoped_side_key(instrument, source_tag, signal);
1153        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
1154    }
1155
1156    pub fn estimate_base(
1157        &self,
1158        instrument: &str,
1159        fallback_mu: f64,
1160        fallback_sigma: f64,
1161    ) -> YNormal {
1162        let Some(st) = self.by_instrument.get(instrument) else {
1163            return YNormal {
1164                mu: fallback_mu,
1165                sigma: fallback_sigma.max(self.cfg.min_sigma),
1166            };
1167        };
1168        if st.samples == 0 {
1169            return YNormal {
1170                mu: fallback_mu,
1171                sigma: fallback_sigma.max(self.cfg.min_sigma),
1172            };
1173        }
1174        let sigma = (st.p + self.cfg.measure_var.max(1e-12))
1175            .sqrt()
1176            .max(self.cfg.min_sigma);
1177        YNormal { mu: st.x, sigma }
1178    }
1179
1180    pub fn estimate_for_signal(
1181        &self,
1182        instrument: &str,
1183        source_tag: &str,
1184        signal: &Signal,
1185        fallback_mu: f64,
1186        fallback_sigma: f64,
1187    ) -> YNormal {
1188        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
1189        let key = scoped_side_key(instrument, source_tag, signal);
1190        let Some(scoped) = self.by_scope_side.get(&key) else {
1191            return base;
1192        };
1193        if scoped.samples == 0 {
1194            return base;
1195        }
1196        let scoped_sigma = (scoped.p + self.cfg.measure_var.max(1e-12))
1197            .sqrt()
1198            .max(self.cfg.min_sigma);
1199        let n = scoped.samples as f64;
1200        let w = n / (n + 20.0);
1201        let mu = w * scoped.x + (1.0 - w) * base.mu;
1202        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
1203        YNormal { mu, sigma }
1204    }
1205
1206    fn update_state(st: &mut KalmanState, price: f64, cfg: KalmanYModelConfig) {
1207        if price <= f64::EPSILON {
1208            return;
1209        }
1210        if let Some(prev) = st.last_price {
1211            if prev > f64::EPSILON {
1212                let z = (price / prev).ln();
1213                if st.samples == 0 {
1214                    st.x = z;
1215                    st.p = cfg.measure_var.max(1e-12);
1216                    st.samples = 1;
1217                } else {
1218                    let q = cfg.process_var.max(1e-12);
1219                    let r = cfg.measure_var.max(1e-12);
1220                    let x_pred = st.x;
1221                    let p_pred = st.p + q;
1222                    let k = p_pred / (p_pred + r);
1223                    st.x = x_pred + k * (z - x_pred);
1224                    st.p = (1.0 - k) * p_pred;
1225                    st.samples = st.samples.saturating_add(1);
1226                }
1227            }
1228        }
1229        st.last_price = Some(price);
1230    }
1231}
1232
1233const LINEAR_RLS_DIM: usize = 5;
1234
1235#[derive(Debug, Clone, Copy)]
1236pub struct LinearRlsYModelConfig {
1237    pub alpha_fast: f64,
1238    pub alpha_slow: f64,
1239    pub alpha_vol: f64,
1240    pub forgetting: f64,
1241    pub ridge: f64,
1242    pub min_sigma: f64,
1243}
1244
1245impl Default for LinearRlsYModelConfig {
1246    fn default() -> Self {
1247        Self {
1248            alpha_fast: 0.20,
1249            alpha_slow: 0.05,
1250            alpha_vol: 0.10,
1251            forgetting: 0.995,
1252            ridge: 1e-2,
1253            min_sigma: 0.001,
1254        }
1255    }
1256}
1257
1258#[derive(Debug, Clone, Copy)]
1259struct LinearRlsState {
1260    last_price: Option<f64>,
1261    prev_r: f64,
1262    ema_fast: f64,
1263    ema_slow: f64,
1264    vol2: f64,
1265    resid2: f64,
1266    has_stats: bool,
1267    last_x: [f64; LINEAR_RLS_DIM],
1268    has_last_x: bool,
1269    beta: [f64; LINEAR_RLS_DIM],
1270    p: [[f64; LINEAR_RLS_DIM]; LINEAR_RLS_DIM],
1271    samples: u64,
1272}
1273
1274impl Default for LinearRlsState {
1275    fn default() -> Self {
1276        Self {
1277            last_price: None,
1278            prev_r: 0.0,
1279            ema_fast: 0.0,
1280            ema_slow: 0.0,
1281            vol2: 0.0,
1282            resid2: 0.0,
1283            has_stats: false,
1284            last_x: [0.0; LINEAR_RLS_DIM],
1285            has_last_x: false,
1286            beta: [0.0; LINEAR_RLS_DIM],
1287            p: [[0.0; LINEAR_RLS_DIM]; LINEAR_RLS_DIM],
1288            samples: 0,
1289        }
1290    }
1291}
1292
1293#[derive(Debug, Default)]
1294pub struct LinearRlsYModel {
1295    cfg: LinearRlsYModelConfig,
1296    by_instrument: HashMap<String, LinearRlsState>,
1297    by_scope_side: HashMap<String, LinearRlsState>,
1298}
1299
1300impl LinearRlsYModel {
1301    pub fn new(cfg: LinearRlsYModelConfig) -> Self {
1302        Self {
1303            cfg,
1304            by_instrument: HashMap::new(),
1305            by_scope_side: HashMap::new(),
1306        }
1307    }
1308
1309    pub fn observe_price(&mut self, instrument: &str, price: f64) {
1310        Self::update_state(
1311            self.by_instrument
1312                .entry(instrument.to_string())
1313                .or_default(),
1314            price,
1315            self.cfg,
1316        );
1317    }
1318
1319    pub fn observe_signal_price(
1320        &mut self,
1321        instrument: &str,
1322        source_tag: &str,
1323        signal: &Signal,
1324        price: f64,
1325    ) {
1326        let key = scoped_side_key(instrument, source_tag, signal);
1327        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
1328    }
1329
1330    pub fn estimate_base(
1331        &self,
1332        instrument: &str,
1333        fallback_mu: f64,
1334        fallback_sigma: f64,
1335    ) -> YNormal {
1336        let Some(st) = self.by_instrument.get(instrument) else {
1337            return YNormal {
1338                mu: fallback_mu,
1339                sigma: fallback_sigma.max(self.cfg.min_sigma),
1340            };
1341        };
1342        estimate_linear_state(st, self.cfg, fallback_mu, fallback_sigma)
1343    }
1344
1345    pub fn estimate_for_signal(
1346        &self,
1347        instrument: &str,
1348        source_tag: &str,
1349        signal: &Signal,
1350        fallback_mu: f64,
1351        fallback_sigma: f64,
1352    ) -> YNormal {
1353        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
1354        let key = scoped_side_key(instrument, source_tag, signal);
1355        let Some(scoped) = self.by_scope_side.get(&key) else {
1356            return base;
1357        };
1358        if scoped.samples == 0 {
1359            return base;
1360        }
1361        let scoped_est = estimate_linear_state(scoped, self.cfg, base.mu, base.sigma);
1362        let n = scoped.samples as f64;
1363        let w = n / (n + 20.0);
1364        let mu = w * scoped_est.mu + (1.0 - w) * base.mu;
1365        let sigma = (w * scoped_est.sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
1366        YNormal { mu, sigma }
1367    }
1368
1369    fn update_state(st: &mut LinearRlsState, price: f64, cfg: LinearRlsYModelConfig) {
1370        if price <= f64::EPSILON {
1371            return;
1372        }
1373        if let Some(prev) = st.last_price {
1374            if prev > f64::EPSILON {
1375                let r = (price / prev).ln();
1376                if !st.has_stats {
1377                    st.prev_r = r;
1378                    st.ema_fast = r;
1379                    st.ema_slow = r;
1380                    st.vol2 = r * r;
1381                    st.resid2 = r * r;
1382                    st.last_x = linear_features(st.prev_r, st.ema_fast, st.ema_slow, st.vol2);
1383                    st.has_last_x = true;
1384                    st.has_stats = true;
1385                    st.samples = 1;
1386                    init_rls_covariance(&mut st.p, cfg.ridge);
1387                } else {
1388                    let x = if st.has_last_x {
1389                        st.last_x
1390                    } else {
1391                        linear_features(st.prev_r, st.ema_fast, st.ema_slow, st.vol2)
1392                    };
1393                    let y_hat = dot(&st.beta, &x);
1394                    let err = r - y_hat;
1395                    rls_update(&mut st.beta, &mut st.p, &x, r, cfg.forgetting, cfg.ridge);
1396
1397                    let a_vol = cfg.alpha_vol.clamp(0.0, 1.0);
1398                    st.resid2 = (1.0 - a_vol) * st.resid2 + a_vol * (err * err);
1399                    let a_fast = cfg.alpha_fast.clamp(0.0, 1.0);
1400                    let a_slow = cfg.alpha_slow.clamp(0.0, 1.0);
1401                    st.ema_fast = (1.0 - a_fast) * st.ema_fast + a_fast * r;
1402                    st.ema_slow = (1.0 - a_slow) * st.ema_slow + a_slow * r;
1403                    let centered = r - st.ema_slow;
1404                    st.vol2 = (1.0 - a_vol) * st.vol2 + a_vol * (centered * centered);
1405                    st.prev_r = r;
1406                    st.last_x = linear_features(st.prev_r, st.ema_fast, st.ema_slow, st.vol2);
1407                    st.has_last_x = true;
1408                    st.samples = st.samples.saturating_add(1);
1409                }
1410            }
1411        }
1412        st.last_price = Some(price);
1413    }
1414}
1415
1416fn estimate_linear_state(
1417    st: &LinearRlsState,
1418    cfg: LinearRlsYModelConfig,
1419    fallback_mu: f64,
1420    fallback_sigma: f64,
1421) -> YNormal {
1422    if !st.has_stats {
1423        return YNormal {
1424            mu: fallback_mu,
1425            sigma: fallback_sigma.max(cfg.min_sigma),
1426        };
1427    }
1428    let x = if st.has_last_x {
1429        st.last_x
1430    } else {
1431        linear_features(st.prev_r, st.ema_fast, st.ema_slow, st.vol2)
1432    };
1433    let pred = dot(&st.beta, &x);
1434    let n = st.samples as f64;
1435    let w = n / (n + 30.0);
1436    let mu = w * pred + (1.0 - w) * fallback_mu;
1437    let sigma_model = st.resid2.max(0.0).sqrt().max(cfg.min_sigma);
1438    let sigma =
1439        (w * sigma_model + (1.0 - w) * fallback_sigma.max(cfg.min_sigma)).max(cfg.min_sigma);
1440    YNormal { mu, sigma }
1441}
1442
1443fn linear_features(prev_r: f64, ema_fast: f64, ema_slow: f64, vol2: f64) -> [f64; LINEAR_RLS_DIM] {
1444    [
1445        1.0,
1446        prev_r,
1447        ema_fast - ema_slow,
1448        vol2.max(0.0).sqrt(),
1449        prev_r.signum(),
1450    ]
1451}
1452
1453fn dot(a: &[f64; LINEAR_RLS_DIM], b: &[f64; LINEAR_RLS_DIM]) -> f64 {
1454    let mut s = 0.0;
1455    for i in 0..LINEAR_RLS_DIM {
1456        s += a[i] * b[i];
1457    }
1458    s
1459}
1460
1461fn init_rls_covariance(p: &mut [[f64; LINEAR_RLS_DIM]; LINEAR_RLS_DIM], ridge: f64) {
1462    let v = 1.0 / ridge.max(1e-9);
1463    for (i, row) in p.iter_mut().enumerate().take(LINEAR_RLS_DIM) {
1464        for (j, cell) in row.iter_mut().enumerate().take(LINEAR_RLS_DIM) {
1465            *cell = if i == j { v } else { 0.0 };
1466        }
1467    }
1468}
1469
1470fn rls_update(
1471    beta: &mut [f64; LINEAR_RLS_DIM],
1472    p: &mut [[f64; LINEAR_RLS_DIM]; LINEAR_RLS_DIM],
1473    x: &[f64; LINEAR_RLS_DIM],
1474    y: f64,
1475    forgetting: f64,
1476    ridge: f64,
1477) {
1478    let lambda = forgetting.clamp(0.90, 0.9999);
1479    if p[0][0].abs() <= f64::EPSILON {
1480        init_rls_covariance(p, ridge);
1481    }
1482    let mut px = [0.0; LINEAR_RLS_DIM];
1483    for (i, px_i) in px.iter_mut().enumerate().take(LINEAR_RLS_DIM) {
1484        let mut v = 0.0;
1485        for (j, xj) in x.iter().enumerate().take(LINEAR_RLS_DIM) {
1486            v += p[i][j] * *xj;
1487        }
1488        *px_i = v;
1489    }
1490    let mut denom = lambda;
1491    for (i, x_i) in x.iter().enumerate().take(LINEAR_RLS_DIM) {
1492        denom += *x_i * px[i];
1493    }
1494    if !denom.is_finite() || denom.abs() <= 1e-12 {
1495        return;
1496    }
1497    let mut k = [0.0; LINEAR_RLS_DIM];
1498    for i in 0..LINEAR_RLS_DIM {
1499        k[i] = px[i] / denom;
1500    }
1501    let err = y - dot(beta, x);
1502    for i in 0..LINEAR_RLS_DIM {
1503        beta[i] += k[i] * err;
1504    }
1505
1506    let mut x_t_p = [0.0; LINEAR_RLS_DIM];
1507    for (j, xtpj) in x_t_p.iter_mut().enumerate().take(LINEAR_RLS_DIM) {
1508        let mut v = 0.0;
1509        for (i, x_i) in x.iter().enumerate().take(LINEAR_RLS_DIM) {
1510            v += *x_i * p[i][j];
1511        }
1512        *xtpj = v;
1513    }
1514    let mut next_p = [[0.0; LINEAR_RLS_DIM]; LINEAR_RLS_DIM];
1515    for i in 0..LINEAR_RLS_DIM {
1516        for (j, xtpj) in x_t_p.iter().enumerate().take(LINEAR_RLS_DIM) {
1517            next_p[i][j] = (p[i][j] - k[i] * *xtpj) / lambda;
1518        }
1519    }
1520    *p = next_p;
1521}
1522
1523const TSMOM_RET_BUF: usize = 24;
1524
1525#[derive(Debug, Clone, Copy)]
1526struct TsmomRlsState {
1527    last_price: Option<f64>,
1528    returns: [f64; TSMOM_RET_BUF],
1529    ret_count: usize,
1530    ret_idx: usize,
1531    resid2: f64,
1532    has_stats: bool,
1533    last_x: [f64; LINEAR_RLS_DIM],
1534    has_last_x: bool,
1535    beta: [f64; LINEAR_RLS_DIM],
1536    p: [[f64; LINEAR_RLS_DIM]; LINEAR_RLS_DIM],
1537    samples: u64,
1538}
1539
1540impl Default for TsmomRlsState {
1541    fn default() -> Self {
1542        Self {
1543            last_price: None,
1544            returns: [0.0; TSMOM_RET_BUF],
1545            ret_count: 0,
1546            ret_idx: 0,
1547            resid2: 0.0,
1548            has_stats: false,
1549            last_x: [0.0; LINEAR_RLS_DIM],
1550            has_last_x: false,
1551            beta: [0.0; LINEAR_RLS_DIM],
1552            p: [[0.0; LINEAR_RLS_DIM]; LINEAR_RLS_DIM],
1553            samples: 0,
1554        }
1555    }
1556}
1557
1558#[derive(Debug, Default)]
1559pub struct TsmomRlsYModel {
1560    cfg: LinearRlsYModelConfig,
1561    by_instrument: HashMap<String, TsmomRlsState>,
1562    by_scope_side: HashMap<String, TsmomRlsState>,
1563}
1564
1565impl TsmomRlsYModel {
1566    pub fn new(cfg: LinearRlsYModelConfig) -> Self {
1567        Self {
1568            cfg,
1569            by_instrument: HashMap::new(),
1570            by_scope_side: HashMap::new(),
1571        }
1572    }
1573
1574    pub fn observe_price(&mut self, instrument: &str, price: f64) {
1575        Self::update_state(
1576            self.by_instrument
1577                .entry(instrument.to_string())
1578                .or_default(),
1579            price,
1580            self.cfg,
1581        );
1582    }
1583
1584    pub fn observe_signal_price(
1585        &mut self,
1586        instrument: &str,
1587        source_tag: &str,
1588        signal: &Signal,
1589        price: f64,
1590    ) {
1591        let key = scoped_side_key(instrument, source_tag, signal);
1592        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
1593    }
1594
1595    pub fn estimate_base(
1596        &self,
1597        instrument: &str,
1598        fallback_mu: f64,
1599        fallback_sigma: f64,
1600    ) -> YNormal {
1601        let Some(st) = self.by_instrument.get(instrument) else {
1602            return YNormal {
1603                mu: fallback_mu,
1604                sigma: fallback_sigma.max(self.cfg.min_sigma),
1605            };
1606        };
1607        estimate_tsmom_state(st, self.cfg, fallback_mu, fallback_sigma)
1608    }
1609
1610    pub fn estimate_for_signal(
1611        &self,
1612        instrument: &str,
1613        source_tag: &str,
1614        signal: &Signal,
1615        fallback_mu: f64,
1616        fallback_sigma: f64,
1617    ) -> YNormal {
1618        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
1619        let key = scoped_side_key(instrument, source_tag, signal);
1620        let Some(scoped) = self.by_scope_side.get(&key) else {
1621            return base;
1622        };
1623        if scoped.samples == 0 {
1624            return base;
1625        }
1626        let scoped_est = estimate_tsmom_state(scoped, self.cfg, base.mu, base.sigma);
1627        let n = scoped.samples as f64;
1628        let w = n / (n + 20.0);
1629        let mu = w * scoped_est.mu + (1.0 - w) * base.mu;
1630        let sigma = (w * scoped_est.sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
1631        YNormal { mu, sigma }
1632    }
1633
1634    fn update_state(st: &mut TsmomRlsState, price: f64, cfg: LinearRlsYModelConfig) {
1635        if price <= f64::EPSILON {
1636            return;
1637        }
1638        if let Some(prev) = st.last_price {
1639            if prev > f64::EPSILON {
1640                let r = (price / prev).ln();
1641                if st.has_last_x {
1642                    let y_hat = dot(&st.beta, &st.last_x);
1643                    let err = r - y_hat;
1644                    rls_update(
1645                        &mut st.beta,
1646                        &mut st.p,
1647                        &st.last_x,
1648                        r,
1649                        cfg.forgetting,
1650                        cfg.ridge,
1651                    );
1652                    let a = cfg.alpha_vol.clamp(0.0, 1.0);
1653                    st.resid2 = if st.has_stats {
1654                        (1.0 - a) * st.resid2 + a * (err * err)
1655                    } else {
1656                        err * err
1657                    };
1658                    st.has_stats = true;
1659                    st.samples = st.samples.saturating_add(1);
1660                } else {
1661                    init_rls_covariance(&mut st.p, cfg.ridge);
1662                }
1663
1664                push_return(st, r);
1665                let x = tsmom_features(st);
1666                st.last_x = x;
1667                st.has_last_x = true;
1668                if st.samples == 0 {
1669                    st.samples = 1;
1670                }
1671            }
1672        }
1673        st.last_price = Some(price);
1674    }
1675}
1676
1677fn push_return(st: &mut TsmomRlsState, r: f64) {
1678    st.returns[st.ret_idx] = r;
1679    st.ret_idx = (st.ret_idx + 1) % TSMOM_RET_BUF;
1680    st.ret_count = (st.ret_count + 1).min(TSMOM_RET_BUF);
1681}
1682
1683fn recent_return(st: &TsmomRlsState, k_back: usize) -> f64 {
1684    if st.ret_count == 0 || k_back >= st.ret_count {
1685        return 0.0;
1686    }
1687    let pos = (st.ret_idx + TSMOM_RET_BUF - 1 - k_back) % TSMOM_RET_BUF;
1688    st.returns[pos]
1689}
1690
1691fn mean_last(st: &TsmomRlsState, n: usize) -> f64 {
1692    let m = n.min(st.ret_count);
1693    if m == 0 {
1694        return 0.0;
1695    }
1696    let mut s = 0.0;
1697    for k in 0..m {
1698        s += recent_return(st, k);
1699    }
1700    s / m as f64
1701}
1702
1703fn tsmom_features(st: &TsmomRlsState) -> [f64; LINEAR_RLS_DIM] {
1704    let m1 = mean_last(st, 1);
1705    let m3 = mean_last(st, 3);
1706    let m6 = mean_last(st, 6);
1707    let m12 = mean_last(st, 12);
1708    [1.0, m1, m3, m6, m12]
1709}
1710
1711fn estimate_tsmom_state(
1712    st: &TsmomRlsState,
1713    cfg: LinearRlsYModelConfig,
1714    fallback_mu: f64,
1715    fallback_sigma: f64,
1716) -> YNormal {
1717    if !st.has_last_x {
1718        return YNormal {
1719            mu: fallback_mu,
1720            sigma: fallback_sigma.max(cfg.min_sigma),
1721        };
1722    }
1723    let pred = dot(&st.beta, &st.last_x);
1724    let n = st.samples as f64;
1725    let w = n / (n + 30.0);
1726    let mu = w * pred + (1.0 - w) * fallback_mu;
1727    let sigma_model = st.resid2.max(0.0).sqrt().max(cfg.min_sigma);
1728    let sigma =
1729        (w * sigma_model + (1.0 - w) * fallback_sigma.max(cfg.min_sigma)).max(cfg.min_sigma);
1730    YNormal { mu, sigma }
1731}
1732
1733#[derive(Debug, Clone, Copy)]
1734pub struct MeanRevOuYModelConfig {
1735    pub alpha_level: f64,
1736    pub alpha_var: f64,
1737    pub kappa: f64,
1738    pub z_clip: f64,
1739    pub min_sigma: f64,
1740}
1741
1742impl Default for MeanRevOuYModelConfig {
1743    fn default() -> Self {
1744        Self {
1745            alpha_level: 0.002,
1746            alpha_var: 0.05,
1747            kappa: 0.02,
1748            z_clip: 3.0,
1749            min_sigma: 0.001,
1750        }
1751    }
1752}
1753
1754#[derive(Debug, Clone, Copy, Default)]
1755struct MeanRevOuState {
1756    last_price: Option<f64>,
1757    log_ema: f64,
1758    var: f64,
1759    samples: u64,
1760}
1761
1762#[derive(Debug, Default)]
1763pub struct MeanRevOuYModel {
1764    cfg: MeanRevOuYModelConfig,
1765    by_instrument: HashMap<String, MeanRevOuState>,
1766    by_scope_side: HashMap<String, MeanRevOuState>,
1767}
1768
1769impl MeanRevOuYModel {
1770    pub fn new(cfg: MeanRevOuYModelConfig) -> Self {
1771        Self {
1772            cfg,
1773            by_instrument: HashMap::new(),
1774            by_scope_side: HashMap::new(),
1775        }
1776    }
1777
1778    pub fn observe_price(&mut self, instrument: &str, price: f64) {
1779        Self::update_state(
1780            self.by_instrument
1781                .entry(instrument.to_string())
1782                .or_default(),
1783            price,
1784            self.cfg,
1785        );
1786    }
1787
1788    pub fn observe_signal_price(
1789        &mut self,
1790        instrument: &str,
1791        source_tag: &str,
1792        signal: &Signal,
1793        price: f64,
1794    ) {
1795        let key = scoped_side_key(instrument, source_tag, signal);
1796        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
1797    }
1798
1799    pub fn estimate_base(
1800        &self,
1801        instrument: &str,
1802        fallback_mu: f64,
1803        fallback_sigma: f64,
1804    ) -> YNormal {
1805        let Some(st) = self.by_instrument.get(instrument) else {
1806            return YNormal {
1807                mu: fallback_mu,
1808                sigma: fallback_sigma.max(self.cfg.min_sigma),
1809            };
1810        };
1811        let (mu, sigma) = mean_revert_forecast(st, self.cfg, fallback_mu, fallback_sigma);
1812        YNormal { mu, sigma }
1813    }
1814
1815    pub fn estimate_for_signal(
1816        &self,
1817        instrument: &str,
1818        source_tag: &str,
1819        signal: &Signal,
1820        fallback_mu: f64,
1821        fallback_sigma: f64,
1822    ) -> YNormal {
1823        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
1824        let key = scoped_side_key(instrument, source_tag, signal);
1825        let Some(scoped) = self.by_scope_side.get(&key) else {
1826            return base;
1827        };
1828        if scoped.samples == 0 {
1829            return base;
1830        }
1831        let (scoped_mu, scoped_sigma) = mean_revert_forecast(scoped, self.cfg, base.mu, base.sigma);
1832        let n = scoped.samples as f64;
1833        let w = n / (n + 20.0);
1834        let mu = w * scoped_mu + (1.0 - w) * base.mu;
1835        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
1836        YNormal { mu, sigma }
1837    }
1838
1839    fn update_state(st: &mut MeanRevOuState, price: f64, cfg: MeanRevOuYModelConfig) {
1840        if price <= f64::EPSILON {
1841            return;
1842        }
1843        if let Some(prev) = st.last_price {
1844            if prev > f64::EPSILON {
1845                let r = (price / prev).ln();
1846                let a_level = cfg.alpha_level.clamp(0.0, 1.0);
1847                let a_var = cfg.alpha_var.clamp(0.0, 1.0);
1848                if st.samples == 0 {
1849                    st.log_ema = price.ln();
1850                    st.var = r * r;
1851                } else {
1852                    st.log_ema = (1.0 - a_level) * st.log_ema + a_level * price.ln();
1853                    st.var = (1.0 - a_var) * st.var + a_var * (r * r);
1854                }
1855                st.samples = st.samples.saturating_add(1);
1856            }
1857        }
1858        st.last_price = Some(price);
1859    }
1860}
1861
1862fn mean_revert_forecast(
1863    st: &MeanRevOuState,
1864    cfg: MeanRevOuYModelConfig,
1865    fallback_mu: f64,
1866    fallback_sigma: f64,
1867) -> (f64, f64) {
1868    if st.samples == 0 {
1869        return (fallback_mu, fallback_sigma.max(cfg.min_sigma));
1870    }
1871    let current_log_price = match st.last_price {
1872        Some(p) if p > f64::EPSILON => p.ln(),
1873        _ => return (fallback_mu, fallback_sigma.max(cfg.min_sigma)),
1874    };
1875    let displacement = current_log_price - st.log_ema;
1876    let sigma = st.var.max(0.0).sqrt().max(cfg.min_sigma);
1877    let z_score = if sigma > 1e-9 {
1878        (displacement / sigma).clamp(-cfg.z_clip, cfg.z_clip)
1879    } else {
1880        0.0
1881    };
1882    let mu_raw = -cfg.kappa * z_score * sigma;
1883    let n = st.samples as f64;
1884    let w = n / (n + 100.0);
1885    let mu = w * mu_raw + (1.0 - w) * fallback_mu;
1886    (mu, sigma)
1887}
1888
1889// ---------------------------------------------------------------------------
1890// VolScaledMom: Volatility-Normalized Time-Series Momentum
1891// Based on Moskowitz, Ooi, Pedersen (2012) "Time Series Momentum"
1892// Signal = (ema_fast - ema_slow) / vol   (Sharpe-ratio-like)
1893// ---------------------------------------------------------------------------
1894
1895#[derive(Debug, Clone, Copy)]
1896pub struct VolScaledMomYModelConfig {
1897    pub alpha_fast: f64,
1898    pub alpha_slow: f64,
1899    pub alpha_vol: f64,
1900    pub kappa: f64,
1901    pub signal_clip: f64,
1902    pub min_sigma: f64,
1903}
1904
1905impl Default for VolScaledMomYModelConfig {
1906    fn default() -> Self {
1907        Self {
1908            alpha_fast: 0.10,
1909            alpha_slow: 0.02,
1910            alpha_vol: 0.05,
1911            kappa: 0.015,
1912            signal_clip: 3.0,
1913            min_sigma: 0.001,
1914        }
1915    }
1916}
1917
1918#[derive(Debug, Clone, Copy, Default)]
1919struct VolScaledMomState {
1920    last_price: Option<f64>,
1921    ema_fast: f64,
1922    ema_slow: f64,
1923    ema_vol: f64,
1924    samples: u64,
1925}
1926
1927#[derive(Debug, Default)]
1928pub struct VolScaledMomYModel {
1929    cfg: VolScaledMomYModelConfig,
1930    by_instrument: HashMap<String, VolScaledMomState>,
1931    by_scope_side: HashMap<String, VolScaledMomState>,
1932}
1933
1934impl VolScaledMomYModel {
1935    pub fn new(cfg: VolScaledMomYModelConfig) -> Self {
1936        Self {
1937            cfg,
1938            by_instrument: HashMap::new(),
1939            by_scope_side: HashMap::new(),
1940        }
1941    }
1942
1943    pub fn observe_price(&mut self, instrument: &str, price: f64) {
1944        Self::update_state(
1945            self.by_instrument
1946                .entry(instrument.to_string())
1947                .or_default(),
1948            price,
1949            self.cfg,
1950        );
1951    }
1952
1953    pub fn observe_signal_price(
1954        &mut self,
1955        instrument: &str,
1956        source_tag: &str,
1957        signal: &Signal,
1958        price: f64,
1959    ) {
1960        let key = scoped_side_key(instrument, source_tag, signal);
1961        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
1962    }
1963
1964    pub fn estimate_base(
1965        &self,
1966        instrument: &str,
1967        fallback_mu: f64,
1968        fallback_sigma: f64,
1969    ) -> YNormal {
1970        let Some(st) = self.by_instrument.get(instrument) else {
1971            return YNormal {
1972                mu: fallback_mu,
1973                sigma: fallback_sigma.max(self.cfg.min_sigma),
1974            };
1975        };
1976        let (mu, sigma) = vol_scaled_mom_forecast(st, self.cfg, fallback_mu, fallback_sigma);
1977        YNormal { mu, sigma }
1978    }
1979
1980    pub fn estimate_for_signal(
1981        &self,
1982        instrument: &str,
1983        source_tag: &str,
1984        signal: &Signal,
1985        fallback_mu: f64,
1986        fallback_sigma: f64,
1987    ) -> YNormal {
1988        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
1989        let key = scoped_side_key(instrument, source_tag, signal);
1990        let Some(scoped) = self.by_scope_side.get(&key) else {
1991            return base;
1992        };
1993        if scoped.samples == 0 {
1994            return base;
1995        }
1996        let (scoped_mu, scoped_sigma) =
1997            vol_scaled_mom_forecast(scoped, self.cfg, base.mu, base.sigma);
1998        let n = scoped.samples as f64;
1999        let w = n / (n + 20.0);
2000        let mu = w * scoped_mu + (1.0 - w) * base.mu;
2001        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
2002        YNormal { mu, sigma }
2003    }
2004
2005    fn update_state(st: &mut VolScaledMomState, price: f64, cfg: VolScaledMomYModelConfig) {
2006        if price <= f64::EPSILON {
2007            return;
2008        }
2009        if let Some(prev) = st.last_price {
2010            if prev > f64::EPSILON {
2011                let r = (price / prev).ln();
2012                let a_f = cfg.alpha_fast.clamp(0.0, 1.0);
2013                let a_s = cfg.alpha_slow.clamp(0.0, 1.0);
2014                let a_v = cfg.alpha_vol.clamp(0.0, 1.0);
2015                if st.samples == 0 {
2016                    st.ema_fast = r;
2017                    st.ema_slow = r;
2018                    st.ema_vol = r.abs();
2019                } else {
2020                    st.ema_fast = (1.0 - a_f) * st.ema_fast + a_f * r;
2021                    st.ema_slow = (1.0 - a_s) * st.ema_slow + a_s * r;
2022                    st.ema_vol = (1.0 - a_v) * st.ema_vol + a_v * r.abs();
2023                }
2024                st.samples = st.samples.saturating_add(1);
2025            }
2026        }
2027        st.last_price = Some(price);
2028    }
2029}
2030
2031fn vol_scaled_mom_forecast(
2032    st: &VolScaledMomState,
2033    cfg: VolScaledMomYModelConfig,
2034    fallback_mu: f64,
2035    fallback_sigma: f64,
2036) -> (f64, f64) {
2037    if st.samples < 2 {
2038        return (fallback_mu, fallback_sigma.max(cfg.min_sigma));
2039    }
2040    let vol = st.ema_vol.max(cfg.min_sigma);
2041    let momentum = st.ema_fast - st.ema_slow;
2042    let signal = (momentum / vol).clamp(-cfg.signal_clip, cfg.signal_clip);
2043    let mu_raw = cfg.kappa * signal * vol;
2044    let n = st.samples as f64;
2045    let w = n / (n + 100.0);
2046    let mu = w * mu_raw + (1.0 - w) * fallback_mu;
2047    // sigma from vol (ema of |r| ≈ sqrt(2/π) * sigma for normal)
2048    let sigma = (vol * 1.25).max(cfg.min_sigma); // approx sqrt(π/2) correction
2049    (mu, sigma)
2050}
2051
2052// ---------------------------------------------------------------------------
2053// VarRatioAdapt: Variance-Ratio Regime-Adaptive Predictor
2054// Based on Lo & MacKinlay (1988) "Stock Market Prices Do Not Follow Random Walks"
2055// VR = fast_var / slow_var; VR>1 = trending, VR<1 = mean-reverting
2056// ---------------------------------------------------------------------------
2057
2058#[derive(Debug, Clone, Copy)]
2059pub struct VarRatioAdaptYModelConfig {
2060    pub alpha_fast_var: f64,
2061    pub alpha_slow_var: f64,
2062    pub alpha_trend: f64,
2063    pub kappa: f64,
2064    pub regime_clip: f64,
2065    pub min_sigma: f64,
2066}
2067
2068impl Default for VarRatioAdaptYModelConfig {
2069    fn default() -> Self {
2070        Self {
2071            alpha_fast_var: 0.15,
2072            alpha_slow_var: 0.02,
2073            alpha_trend: 0.06,
2074            kappa: 0.015,
2075            regime_clip: 1.0,
2076            min_sigma: 0.001,
2077        }
2078    }
2079}
2080
2081#[derive(Debug, Clone, Copy, Default)]
2082struct VarRatioAdaptState {
2083    last_price: Option<f64>,
2084    var_fast: f64,
2085    var_slow: f64,
2086    ema_trend: f64,
2087    samples: u64,
2088}
2089
2090#[derive(Debug, Default)]
2091pub struct VarRatioAdaptYModel {
2092    cfg: VarRatioAdaptYModelConfig,
2093    by_instrument: HashMap<String, VarRatioAdaptState>,
2094    by_scope_side: HashMap<String, VarRatioAdaptState>,
2095}
2096
2097impl VarRatioAdaptYModel {
2098    pub fn new(cfg: VarRatioAdaptYModelConfig) -> Self {
2099        Self {
2100            cfg,
2101            by_instrument: HashMap::new(),
2102            by_scope_side: HashMap::new(),
2103        }
2104    }
2105
2106    pub fn observe_price(&mut self, instrument: &str, price: f64) {
2107        Self::update_state(
2108            self.by_instrument
2109                .entry(instrument.to_string())
2110                .or_default(),
2111            price,
2112            self.cfg,
2113        );
2114    }
2115
2116    pub fn observe_signal_price(
2117        &mut self,
2118        instrument: &str,
2119        source_tag: &str,
2120        signal: &Signal,
2121        price: f64,
2122    ) {
2123        let key = scoped_side_key(instrument, source_tag, signal);
2124        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
2125    }
2126
2127    pub fn estimate_base(
2128        &self,
2129        instrument: &str,
2130        fallback_mu: f64,
2131        fallback_sigma: f64,
2132    ) -> YNormal {
2133        let Some(st) = self.by_instrument.get(instrument) else {
2134            return YNormal {
2135                mu: fallback_mu,
2136                sigma: fallback_sigma.max(self.cfg.min_sigma),
2137            };
2138        };
2139        let (mu, sigma) = var_ratio_forecast(st, self.cfg, fallback_mu, fallback_sigma);
2140        YNormal { mu, sigma }
2141    }
2142
2143    pub fn estimate_for_signal(
2144        &self,
2145        instrument: &str,
2146        source_tag: &str,
2147        signal: &Signal,
2148        fallback_mu: f64,
2149        fallback_sigma: f64,
2150    ) -> YNormal {
2151        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
2152        let key = scoped_side_key(instrument, source_tag, signal);
2153        let Some(scoped) = self.by_scope_side.get(&key) else {
2154            return base;
2155        };
2156        if scoped.samples == 0 {
2157            return base;
2158        }
2159        let (scoped_mu, scoped_sigma) = var_ratio_forecast(scoped, self.cfg, base.mu, base.sigma);
2160        let n = scoped.samples as f64;
2161        let w = n / (n + 20.0);
2162        let mu = w * scoped_mu + (1.0 - w) * base.mu;
2163        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
2164        YNormal { mu, sigma }
2165    }
2166
2167    fn update_state(st: &mut VarRatioAdaptState, price: f64, cfg: VarRatioAdaptYModelConfig) {
2168        if price <= f64::EPSILON {
2169            return;
2170        }
2171        if let Some(prev) = st.last_price {
2172            if prev > f64::EPSILON {
2173                let r = (price / prev).ln();
2174                let r2 = r * r;
2175                let a_f = cfg.alpha_fast_var.clamp(0.0, 1.0);
2176                let a_s = cfg.alpha_slow_var.clamp(0.0, 1.0);
2177                let a_t = cfg.alpha_trend.clamp(0.0, 1.0);
2178                if st.samples == 0 {
2179                    st.var_fast = r2;
2180                    st.var_slow = r2;
2181                    st.ema_trend = r;
2182                } else {
2183                    st.var_fast = (1.0 - a_f) * st.var_fast + a_f * r2;
2184                    st.var_slow = (1.0 - a_s) * st.var_slow + a_s * r2;
2185                    st.ema_trend = (1.0 - a_t) * st.ema_trend + a_t * r;
2186                }
2187                st.samples = st.samples.saturating_add(1);
2188            }
2189        }
2190        st.last_price = Some(price);
2191    }
2192}
2193
2194fn var_ratio_forecast(
2195    st: &VarRatioAdaptState,
2196    cfg: VarRatioAdaptYModelConfig,
2197    fallback_mu: f64,
2198    fallback_sigma: f64,
2199) -> (f64, f64) {
2200    if st.samples < 2 {
2201        return (fallback_mu, fallback_sigma.max(cfg.min_sigma));
2202    }
2203    let sigma_slow = st.var_slow.max(0.0).sqrt().max(cfg.min_sigma);
2204    if st.var_slow < 1e-18 {
2205        return (fallback_mu, sigma_slow);
2206    }
2207    // Variance ratio: >1 = trending (positive autocorrelation), <1 = reverting
2208    let vr = st.var_fast / st.var_slow;
2209    // Regime strength: how far from random walk (VR=1)
2210    let regime = (vr - 1.0).clamp(-cfg.regime_clip, cfg.regime_clip);
2211    // Direction from recent trend EMA
2212    let direction = st.ema_trend.signum();
2213    // Prediction: regime * direction * kappa * sigma
2214    // Trending (regime>0) + upward direction → predict positive (continuation)
2215    // Reverting (regime<0) + upward direction → predict negative (reversion)
2216    let mu_raw = cfg.kappa * regime * direction * sigma_slow;
2217    let n = st.samples as f64;
2218    let w = n / (n + 100.0);
2219    let mu = w * mu_raw + (1.0 - w) * fallback_mu;
2220    (mu, sigma_slow)
2221}
2222
2223// ---------------------------------------------------------------------------
2224// MicroRevAr: Microstructure Reversal AR(1)
2225// Based on Roll (1984) bid-ask bounce model.
2226// Only predicts when lag-1 autocovariance is negative (bounce detected).
2227// Predicts zero otherwise — avoids adding noise when no signal exists.
2228// ---------------------------------------------------------------------------
2229
2230#[derive(Debug, Clone, Copy)]
2231pub struct MicroRevArYModelConfig {
2232    pub alpha: f64,
2233    pub phi_max: f64,
2234    pub min_sigma: f64,
2235}
2236
2237impl Default for MicroRevArYModelConfig {
2238    fn default() -> Self {
2239        Self {
2240            alpha: 0.04,
2241            phi_max: 0.15,
2242            min_sigma: 0.001,
2243        }
2244    }
2245}
2246
2247#[derive(Debug, Clone, Copy, Default)]
2248struct MicroRevArState {
2249    last_price: Option<f64>,
2250    prev_return: f64,
2251    gamma1: f64,
2252    var_r: f64,
2253    samples: u64,
2254}
2255
2256#[derive(Debug, Default)]
2257pub struct MicroRevArYModel {
2258    cfg: MicroRevArYModelConfig,
2259    by_instrument: HashMap<String, MicroRevArState>,
2260    by_scope_side: HashMap<String, MicroRevArState>,
2261}
2262
2263impl MicroRevArYModel {
2264    pub fn new(cfg: MicroRevArYModelConfig) -> Self {
2265        Self {
2266            cfg,
2267            by_instrument: HashMap::new(),
2268            by_scope_side: HashMap::new(),
2269        }
2270    }
2271
2272    pub fn observe_price(&mut self, instrument: &str, price: f64) {
2273        Self::update_state(
2274            self.by_instrument
2275                .entry(instrument.to_string())
2276                .or_default(),
2277            price,
2278            self.cfg,
2279        );
2280    }
2281
2282    pub fn observe_signal_price(
2283        &mut self,
2284        instrument: &str,
2285        source_tag: &str,
2286        signal: &Signal,
2287        price: f64,
2288    ) {
2289        let key = scoped_side_key(instrument, source_tag, signal);
2290        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
2291    }
2292
2293    pub fn estimate_base(
2294        &self,
2295        instrument: &str,
2296        fallback_mu: f64,
2297        fallback_sigma: f64,
2298    ) -> YNormal {
2299        let Some(st) = self.by_instrument.get(instrument) else {
2300            return YNormal {
2301                mu: fallback_mu,
2302                sigma: fallback_sigma.max(self.cfg.min_sigma),
2303            };
2304        };
2305        let (mu, sigma) = micro_rev_forecast(st, self.cfg, fallback_mu, fallback_sigma);
2306        YNormal { mu, sigma }
2307    }
2308
2309    pub fn estimate_for_signal(
2310        &self,
2311        instrument: &str,
2312        source_tag: &str,
2313        signal: &Signal,
2314        fallback_mu: f64,
2315        fallback_sigma: f64,
2316    ) -> YNormal {
2317        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
2318        let key = scoped_side_key(instrument, source_tag, signal);
2319        let Some(scoped) = self.by_scope_side.get(&key) else {
2320            return base;
2321        };
2322        if scoped.samples == 0 {
2323            return base;
2324        }
2325        let (scoped_mu, scoped_sigma) = micro_rev_forecast(scoped, self.cfg, base.mu, base.sigma);
2326        let n = scoped.samples as f64;
2327        let w = n / (n + 20.0);
2328        let mu = w * scoped_mu + (1.0 - w) * base.mu;
2329        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
2330        YNormal { mu, sigma }
2331    }
2332
2333    fn update_state(st: &mut MicroRevArState, price: f64, cfg: MicroRevArYModelConfig) {
2334        if price <= f64::EPSILON {
2335            return;
2336        }
2337        if let Some(prev) = st.last_price {
2338            if prev > f64::EPSILON {
2339                let r = (price / prev).ln();
2340                let a = cfg.alpha.clamp(0.0, 1.0);
2341                if st.samples == 0 {
2342                    st.gamma1 = 0.0;
2343                    st.var_r = r * r;
2344                } else {
2345                    // gamma1 = EWMA of r_t * r_{t-1} (lag-1 autocovariance)
2346                    st.gamma1 = (1.0 - a) * st.gamma1 + a * (r * st.prev_return);
2347                    st.var_r = (1.0 - a) * st.var_r + a * (r * r);
2348                }
2349                st.prev_return = r;
2350                st.samples = st.samples.saturating_add(1);
2351            }
2352        }
2353        st.last_price = Some(price);
2354    }
2355}
2356
2357fn micro_rev_forecast(
2358    st: &MicroRevArState,
2359    cfg: MicroRevArYModelConfig,
2360    fallback_mu: f64,
2361    fallback_sigma: f64,
2362) -> (f64, f64) {
2363    let sigma = st.var_r.max(0.0).sqrt().max(cfg.min_sigma);
2364    if st.samples < 3 {
2365        return (fallback_mu, fallback_sigma.max(cfg.min_sigma));
2366    }
2367    // Only predict when autocovariance is negative (bid-ask bounce / reversal)
2368    // When gamma1 >= 0, there is momentum, not reversal — predict zero
2369    if st.gamma1 >= 0.0 || st.var_r < 1e-18 {
2370        return (0.0, sigma);
2371    }
2372    // phi = gamma1 / var_r, always negative here
2373    let phi = (st.gamma1 / st.var_r).clamp(-cfg.phi_max, 0.0);
2374    let mu_raw = phi * st.prev_return;
2375    // Very heavy shrinkage: n/(n+200)
2376    let n = st.samples as f64;
2377    let w = n / (n + 200.0);
2378    let mu = w * mu_raw;
2379    (mu, sigma)
2380}
2381
2382// ---------------------------------------------------------------------------
2383// SelfCalibMom: Self-Calibrating Momentum
2384// Uses online feedback to learn the optimal prediction magnitude.
2385// Tracks cross-moment (Cov(y,ŷ)) and pred-variance (Var(ŷ)) to compute
2386// the optimal shrinkage: alpha* = Cov(y,ŷ)/Var(ŷ), clamped to [0,1].
2387// This is mathematically guaranteed to converge to R² = ρ² ≥ 0.
2388// ---------------------------------------------------------------------------
2389
2390#[derive(Debug, Clone, Copy)]
2391pub struct SelfCalibMomYModelConfig {
2392    pub alpha_fast: f64,
2393    pub alpha_slow: f64,
2394    pub alpha_var: f64,
2395    pub alpha_calib: f64,
2396    pub min_sigma: f64,
2397}
2398
2399impl Default for SelfCalibMomYModelConfig {
2400    fn default() -> Self {
2401        Self {
2402            alpha_fast: 0.12,
2403            alpha_slow: 0.03,
2404            alpha_var: 0.05,
2405            alpha_calib: 0.03,
2406            min_sigma: 0.001,
2407        }
2408    }
2409}
2410
2411#[derive(Debug, Clone, Copy, Default)]
2412struct SelfCalibMomState {
2413    last_price: Option<f64>,
2414    ema_fast: f64,
2415    ema_slow: f64,
2416    var_r: f64,
2417    prev_raw_mu: f64,
2418    cross: f64,
2419    pred_sq: f64,
2420    samples: u64,
2421}
2422
2423#[derive(Debug, Default)]
2424pub struct SelfCalibMomYModel {
2425    cfg: SelfCalibMomYModelConfig,
2426    by_instrument: HashMap<String, SelfCalibMomState>,
2427    by_scope_side: HashMap<String, SelfCalibMomState>,
2428}
2429
2430impl SelfCalibMomYModel {
2431    pub fn new(cfg: SelfCalibMomYModelConfig) -> Self {
2432        Self {
2433            cfg,
2434            by_instrument: HashMap::new(),
2435            by_scope_side: HashMap::new(),
2436        }
2437    }
2438
2439    pub fn observe_price(&mut self, instrument: &str, price: f64) {
2440        Self::update_state(
2441            self.by_instrument
2442                .entry(instrument.to_string())
2443                .or_default(),
2444            price,
2445            self.cfg,
2446        );
2447    }
2448
2449    pub fn observe_signal_price(
2450        &mut self,
2451        instrument: &str,
2452        source_tag: &str,
2453        signal: &Signal,
2454        price: f64,
2455    ) {
2456        let key = scoped_side_key(instrument, source_tag, signal);
2457        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
2458    }
2459
2460    pub fn estimate_base(
2461        &self,
2462        instrument: &str,
2463        fallback_mu: f64,
2464        fallback_sigma: f64,
2465    ) -> YNormal {
2466        let Some(st) = self.by_instrument.get(instrument) else {
2467            return YNormal {
2468                mu: fallback_mu,
2469                sigma: fallback_sigma.max(self.cfg.min_sigma),
2470            };
2471        };
2472        let (mu, sigma) = self_calib_forecast(st, self.cfg, fallback_mu, fallback_sigma);
2473        YNormal { mu, sigma }
2474    }
2475
2476    pub fn estimate_for_signal(
2477        &self,
2478        instrument: &str,
2479        source_tag: &str,
2480        signal: &Signal,
2481        fallback_mu: f64,
2482        fallback_sigma: f64,
2483    ) -> YNormal {
2484        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
2485        let key = scoped_side_key(instrument, source_tag, signal);
2486        let Some(scoped) = self.by_scope_side.get(&key) else {
2487            return base;
2488        };
2489        if scoped.samples == 0 {
2490            return base;
2491        }
2492        let (scoped_mu, scoped_sigma) = self_calib_forecast(scoped, self.cfg, base.mu, base.sigma);
2493        let n = scoped.samples as f64;
2494        let w = n / (n + 20.0);
2495        let mu = w * scoped_mu + (1.0 - w) * base.mu;
2496        let sigma = (w * scoped_sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
2497        YNormal { mu, sigma }
2498    }
2499
2500    fn update_state(st: &mut SelfCalibMomState, price: f64, cfg: SelfCalibMomYModelConfig) {
2501        if price <= f64::EPSILON {
2502            return;
2503        }
2504        if let Some(prev) = st.last_price {
2505            if prev > f64::EPSILON {
2506                let r = (price / prev).ln();
2507                let a_f = cfg.alpha_fast.clamp(0.0, 1.0);
2508                let a_s = cfg.alpha_slow.clamp(0.0, 1.0);
2509                let a_v = cfg.alpha_var.clamp(0.0, 1.0);
2510                let a_c = cfg.alpha_calib.clamp(0.0, 1.0);
2511
2512                // Calibration feedback: actual return vs previous raw prediction
2513                if st.samples > 0 {
2514                    st.cross = (1.0 - a_c) * st.cross + a_c * (r * st.prev_raw_mu);
2515                    st.pred_sq = (1.0 - a_c) * st.pred_sq + a_c * (st.prev_raw_mu * st.prev_raw_mu);
2516                }
2517
2518                // Update signal components
2519                if st.samples == 0 {
2520                    st.ema_fast = r;
2521                    st.ema_slow = r;
2522                    st.var_r = r * r;
2523                } else {
2524                    st.ema_fast = (1.0 - a_f) * st.ema_fast + a_f * r;
2525                    st.ema_slow = (1.0 - a_s) * st.ema_slow + a_s * r;
2526                    st.var_r = (1.0 - a_v) * st.var_r + a_v * (r * r);
2527                }
2528
2529                // Compute raw prediction for next step's calibration
2530                let vol = st.var_r.max(0.0).sqrt().max(cfg.min_sigma);
2531                let momentum = st.ema_fast - st.ema_slow;
2532                let signal = if vol > 1e-12 { momentum / vol } else { 0.0 };
2533                // Base raw prediction: signal * vol * 0.1 (deliberate 10x shrinkage)
2534                st.prev_raw_mu = signal * vol * 0.1;
2535
2536                st.samples = st.samples.saturating_add(1);
2537            }
2538        }
2539        st.last_price = Some(price);
2540    }
2541}
2542
2543fn self_calib_forecast(
2544    st: &SelfCalibMomState,
2545    cfg: SelfCalibMomYModelConfig,
2546    fallback_mu: f64,
2547    fallback_sigma: f64,
2548) -> (f64, f64) {
2549    let sigma = st.var_r.max(0.0).sqrt().max(cfg.min_sigma);
2550    if st.samples < 5 {
2551        return (fallback_mu, fallback_sigma.max(cfg.min_sigma));
2552    }
2553    // Self-calibrated shrinkage: alpha* = Cov(y, ŷ_raw) / Var(ŷ_raw)
2554    // Clamped to [0, 1]: negative means anti-correlated → predict zero
2555    let alpha_opt = if st.pred_sq > 1e-18 {
2556        (st.cross / st.pred_sq).clamp(0.0, 1.0)
2557    } else {
2558        0.0
2559    };
2560    let mu_calibrated = alpha_opt * st.prev_raw_mu;
2561    // Additional sample-count shrinkage
2562    let n = st.samples as f64;
2563    let w = n / (n + 200.0);
2564    let mu = w * mu_calibrated + (1.0 - w) * fallback_mu;
2565    (mu, sigma)
2566}
2567
2568// ---------------------------------------------------------------------------
2569// FeatureRls: Feature-Rich RLS with Novel Microstructure Features
2570//
2571// Features (7-dim):
2572//   0: intercept (1.0)
2573//   1: return acceleration  (r_t - r_{t-1}) / σ   — momentum curvature
2574//   2: realized skewness    EWMA(r³) / σ³          — return asymmetry
2575//   3: run-length signal    tanh(run_count/3) * sign — exhaustion
2576//   4: vol acceleration     (var_fast - var_slow) / var_slow — regime shift
2577//   5: normalized autocov   γ₁ / σ²                — reversal/momentum regime
2578//   6: return extremity     |r_prev| / σ            — extreme move detection
2579//
2580// Key difference from LinearRls: entirely different features + heavier
2581// regularization (ridge=0.05, forgetting=0.998) + prediction clipping.
2582// ---------------------------------------------------------------------------
2583
2584const FEAT_RLS_DIM: usize = 7;
2585
2586#[derive(Debug, Clone, Copy)]
2587pub struct FeatureRlsYModelConfig {
2588    pub alpha_fast: f64,
2589    pub alpha_slow: f64,
2590    pub alpha_var: f64,
2591    pub forgetting: f64,
2592    pub ridge: f64,
2593    pub pred_clip: f64,
2594    pub min_sigma: f64,
2595}
2596
2597impl Default for FeatureRlsYModelConfig {
2598    fn default() -> Self {
2599        Self {
2600            alpha_fast: 0.12,
2601            alpha_slow: 0.02,
2602            alpha_var: 0.04,
2603            forgetting: 0.998,
2604            ridge: 0.05,
2605            pred_clip: 3.0,
2606            min_sigma: 0.001,
2607        }
2608    }
2609}
2610
2611#[derive(Debug, Clone, Copy)]
2612struct FeatureRlsState {
2613    last_price: Option<f64>,
2614    prev_r: f64,
2615    prev_prev_r: f64,
2616    ema_r3: f64,
2617    var_fast: f64,
2618    var_slow: f64,
2619    gamma1: f64,
2620    run_count: i32,
2621    resid2: f64,
2622    has_stats: bool,
2623    last_x: [f64; FEAT_RLS_DIM],
2624    has_last_x: bool,
2625    beta: [f64; FEAT_RLS_DIM],
2626    p: [[f64; FEAT_RLS_DIM]; FEAT_RLS_DIM],
2627    samples: u64,
2628}
2629
2630impl Default for FeatureRlsState {
2631    fn default() -> Self {
2632        Self {
2633            last_price: None,
2634            prev_r: 0.0,
2635            prev_prev_r: 0.0,
2636            ema_r3: 0.0,
2637            var_fast: 0.0,
2638            var_slow: 0.0,
2639            gamma1: 0.0,
2640            run_count: 0,
2641            resid2: 0.0,
2642            has_stats: false,
2643            last_x: [0.0; FEAT_RLS_DIM],
2644            has_last_x: false,
2645            beta: [0.0; FEAT_RLS_DIM],
2646            p: [[0.0; FEAT_RLS_DIM]; FEAT_RLS_DIM],
2647            samples: 0,
2648        }
2649    }
2650}
2651
2652#[derive(Debug, Default)]
2653pub struct FeatureRlsYModel {
2654    cfg: FeatureRlsYModelConfig,
2655    by_instrument: HashMap<String, FeatureRlsState>,
2656    by_scope_side: HashMap<String, FeatureRlsState>,
2657}
2658
2659impl FeatureRlsYModel {
2660    pub fn new(cfg: FeatureRlsYModelConfig) -> Self {
2661        Self {
2662            cfg,
2663            by_instrument: HashMap::new(),
2664            by_scope_side: HashMap::new(),
2665        }
2666    }
2667
2668    pub fn observe_price(&mut self, instrument: &str, price: f64) {
2669        Self::update_state(
2670            self.by_instrument
2671                .entry(instrument.to_string())
2672                .or_default(),
2673            price,
2674            self.cfg,
2675        );
2676    }
2677
2678    pub fn observe_signal_price(
2679        &mut self,
2680        instrument: &str,
2681        source_tag: &str,
2682        signal: &Signal,
2683        price: f64,
2684    ) {
2685        let key = scoped_side_key(instrument, source_tag, signal);
2686        Self::update_state(self.by_scope_side.entry(key).or_default(), price, self.cfg);
2687    }
2688
2689    pub fn estimate_base(
2690        &self,
2691        instrument: &str,
2692        fallback_mu: f64,
2693        fallback_sigma: f64,
2694    ) -> YNormal {
2695        let Some(st) = self.by_instrument.get(instrument) else {
2696            return YNormal {
2697                mu: fallback_mu,
2698                sigma: fallback_sigma.max(self.cfg.min_sigma),
2699            };
2700        };
2701        feat_rls_estimate(st, self.cfg, fallback_mu, fallback_sigma)
2702    }
2703
2704    pub fn estimate_for_signal(
2705        &self,
2706        instrument: &str,
2707        source_tag: &str,
2708        signal: &Signal,
2709        fallback_mu: f64,
2710        fallback_sigma: f64,
2711    ) -> YNormal {
2712        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
2713        let key = scoped_side_key(instrument, source_tag, signal);
2714        let Some(scoped) = self.by_scope_side.get(&key) else {
2715            return base;
2716        };
2717        if scoped.samples == 0 {
2718            return base;
2719        }
2720        let scoped_est = feat_rls_estimate(scoped, self.cfg, base.mu, base.sigma);
2721        let n = scoped.samples as f64;
2722        let w = n / (n + 20.0);
2723        let mu = w * scoped_est.mu + (1.0 - w) * base.mu;
2724        let sigma = (w * scoped_est.sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
2725        YNormal { mu, sigma }
2726    }
2727
2728    fn update_state(st: &mut FeatureRlsState, price: f64, cfg: FeatureRlsYModelConfig) {
2729        if price <= f64::EPSILON {
2730            return;
2731        }
2732        if let Some(prev) = st.last_price {
2733            if prev > f64::EPSILON {
2734                let r = (price / prev).ln();
2735                let a_f = cfg.alpha_fast.clamp(0.0, 1.0);
2736                let a_s = cfg.alpha_slow.clamp(0.0, 1.0);
2737                let a_v = cfg.alpha_var.clamp(0.0, 1.0);
2738
2739                if !st.has_stats {
2740                    st.prev_r = r;
2741                    st.prev_prev_r = 0.0;
2742                    st.ema_r3 = r * r * r;
2743                    st.var_fast = r * r;
2744                    st.var_slow = r * r;
2745                    st.gamma1 = 0.0;
2746                    st.run_count = if r >= 0.0 { 1 } else { -1 };
2747                    st.resid2 = r * r;
2748                    st.has_stats = true;
2749                    st.samples = 1;
2750                    feat_rls_init_p(&mut st.p, cfg.ridge);
2751                    st.last_x = feat_rls_features(st, cfg.min_sigma);
2752                    st.has_last_x = true;
2753                } else {
2754                    // RLS update with previous features
2755                    let x = if st.has_last_x {
2756                        st.last_x
2757                    } else {
2758                        feat_rls_features(st, cfg.min_sigma)
2759                    };
2760                    let y_hat = feat_rls_dot(&st.beta, &x);
2761                    let err = r - y_hat;
2762                    feat_rls_update(&mut st.beta, &mut st.p, &x, r, cfg.forgetting, cfg.ridge);
2763                    st.resid2 = (1.0 - a_v) * st.resid2 + a_v * (err * err);
2764
2765                    // Update feature state
2766                    st.ema_r3 = (1.0 - a_v) * st.ema_r3 + a_v * (r * r * r);
2767                    st.var_fast = (1.0 - a_f) * st.var_fast + a_f * (r * r);
2768                    st.var_slow = (1.0 - a_s) * st.var_slow + a_s * (r * r);
2769                    st.gamma1 = (1.0 - a_v) * st.gamma1 + a_v * (r * st.prev_r);
2770
2771                    // Run length tracking
2772                    if r >= 0.0 {
2773                        st.run_count = if st.run_count > 0 {
2774                            st.run_count.saturating_add(1)
2775                        } else {
2776                            1
2777                        };
2778                    } else {
2779                        st.run_count = if st.run_count < 0 {
2780                            st.run_count.saturating_sub(1)
2781                        } else {
2782                            -1
2783                        };
2784                    }
2785
2786                    st.prev_prev_r = st.prev_r;
2787                    st.prev_r = r;
2788                    st.last_x = feat_rls_features(st, cfg.min_sigma);
2789                    st.has_last_x = true;
2790                    st.samples = st.samples.saturating_add(1);
2791                }
2792            }
2793        }
2794        st.last_price = Some(price);
2795    }
2796}
2797
2798fn feat_rls_features(st: &FeatureRlsState, min_sigma: f64) -> [f64; FEAT_RLS_DIM] {
2799    let sigma = st.var_slow.max(0.0).sqrt().max(min_sigma);
2800    let sigma2 = st.var_slow.max(1e-18);
2801    let sigma3 = sigma * sigma2;
2802
2803    // Feature 1: return acceleration (2nd derivative), normalized
2804    let accel = if sigma > 1e-12 {
2805        ((st.prev_r - st.prev_prev_r) / sigma).clamp(-5.0, 5.0)
2806    } else {
2807        0.0
2808    };
2809
2810    // Feature 2: realized skewness proxy
2811    let skew = if sigma3 > 1e-18 {
2812        (st.ema_r3 / sigma3).clamp(-5.0, 5.0)
2813    } else {
2814        0.0
2815    };
2816
2817    // Feature 3: run-length signal (tanh-normalized)
2818    let run_norm = (st.run_count as f64 / 3.0).tanh();
2819
2820    // Feature 4: volatility acceleration
2821    let vol_accel = if st.var_slow > 1e-18 {
2822        ((st.var_fast - st.var_slow) / st.var_slow).clamp(-3.0, 3.0)
2823    } else {
2824        0.0
2825    };
2826
2827    // Feature 5: normalized autocovariance (regime indicator)
2828    let autocov = if sigma2 > 1e-18 {
2829        (st.gamma1 / sigma2).clamp(-1.0, 1.0)
2830    } else {
2831        0.0
2832    };
2833
2834    // Feature 6: return extremity (|prev_r| / sigma)
2835    let extremity = if sigma > 1e-12 {
2836        (st.prev_r.abs() / sigma).clamp(0.0, 5.0)
2837    } else {
2838        0.0
2839    };
2840
2841    [1.0, accel, skew, run_norm, vol_accel, autocov, extremity]
2842}
2843
2844fn feat_rls_estimate(
2845    st: &FeatureRlsState,
2846    cfg: FeatureRlsYModelConfig,
2847    fallback_mu: f64,
2848    fallback_sigma: f64,
2849) -> YNormal {
2850    if !st.has_stats || st.samples < 3 {
2851        return YNormal {
2852            mu: fallback_mu,
2853            sigma: fallback_sigma.max(cfg.min_sigma),
2854        };
2855    }
2856    let x = if st.has_last_x {
2857        st.last_x
2858    } else {
2859        feat_rls_features(st, cfg.min_sigma)
2860    };
2861    let pred = feat_rls_dot(&st.beta, &x);
2862    let sigma_model = st.resid2.max(0.0).sqrt().max(cfg.min_sigma);
2863    // Clip prediction to ±pred_clip * sigma (prevent extreme outputs)
2864    let pred_clipped = pred.clamp(-cfg.pred_clip * sigma_model, cfg.pred_clip * sigma_model);
2865    // Heavy sample-count shrinkage: n/(n+150)
2866    let n = st.samples as f64;
2867    let w = n / (n + 150.0);
2868    let mu = w * pred_clipped + (1.0 - w) * fallback_mu;
2869    let sigma =
2870        (w * sigma_model + (1.0 - w) * fallback_sigma.max(cfg.min_sigma)).max(cfg.min_sigma);
2871    YNormal { mu, sigma }
2872}
2873
2874fn feat_rls_dot(a: &[f64; FEAT_RLS_DIM], b: &[f64; FEAT_RLS_DIM]) -> f64 {
2875    let mut s = 0.0;
2876    for i in 0..FEAT_RLS_DIM {
2877        s += a[i] * b[i];
2878    }
2879    s
2880}
2881
2882fn feat_rls_init_p(p: &mut [[f64; FEAT_RLS_DIM]; FEAT_RLS_DIM], ridge: f64) {
2883    let v = 1.0 / ridge.max(1e-9);
2884    for i in 0..FEAT_RLS_DIM {
2885        for j in 0..FEAT_RLS_DIM {
2886            p[i][j] = if i == j { v } else { 0.0 };
2887        }
2888    }
2889}
2890
2891fn feat_rls_update(
2892    beta: &mut [f64; FEAT_RLS_DIM],
2893    p: &mut [[f64; FEAT_RLS_DIM]; FEAT_RLS_DIM],
2894    x: &[f64; FEAT_RLS_DIM],
2895    y: f64,
2896    forgetting: f64,
2897    ridge: f64,
2898) {
2899    let lambda = forgetting.clamp(0.90, 0.9999);
2900    if p[0][0].abs() <= f64::EPSILON {
2901        feat_rls_init_p(p, ridge);
2902    }
2903    // P*x
2904    let mut px = [0.0; FEAT_RLS_DIM];
2905    for i in 0..FEAT_RLS_DIM {
2906        let mut v = 0.0;
2907        for j in 0..FEAT_RLS_DIM {
2908            v += p[i][j] * x[j];
2909        }
2910        px[i] = v;
2911    }
2912    // denom = lambda + x'*P*x
2913    let mut denom = lambda;
2914    for i in 0..FEAT_RLS_DIM {
2915        denom += x[i] * px[i];
2916    }
2917    if !denom.is_finite() || denom.abs() <= 1e-12 {
2918        return;
2919    }
2920    // Kalman gain k = P*x / denom
2921    let mut k = [0.0; FEAT_RLS_DIM];
2922    for i in 0..FEAT_RLS_DIM {
2923        k[i] = px[i] / denom;
2924    }
2925    // Update beta
2926    let err = y - feat_rls_dot(beta, x);
2927    for i in 0..FEAT_RLS_DIM {
2928        beta[i] += k[i] * err;
2929    }
2930    // Update P = (P - k * x' * P) / lambda
2931    let mut x_t_p = [0.0; FEAT_RLS_DIM];
2932    for j in 0..FEAT_RLS_DIM {
2933        let mut v = 0.0;
2934        for i in 0..FEAT_RLS_DIM {
2935            v += x[i] * p[i][j];
2936        }
2937        x_t_p[j] = v;
2938    }
2939    let mut next_p = [[0.0; FEAT_RLS_DIM]; FEAT_RLS_DIM];
2940    for i in 0..FEAT_RLS_DIM {
2941        for j in 0..FEAT_RLS_DIM {
2942            next_p[i][j] = (p[i][j] - k[i] * x_t_p[j]) / lambda;
2943        }
2944    }
2945    *p = next_p;
2946}
2947
2948// ---------------------------------------------------------------------------
2949// CrossAssetMacroRls: cross-asset linear predictor with macro factors.
2950// Factors are inferred from symbol names:
2951// - S&P proxy: contains SPX/SP500/US500/SPY
2952// - Gold proxy: contains XAU/GOLD/GC
2953// - Oil proxy: contains WTI/BRENT/CL
2954// ---------------------------------------------------------------------------
2955
2956const XASSET_DIM: usize = 5;
2957
2958#[derive(Debug, Clone, Copy)]
2959pub struct CrossAssetMacroRlsYModelConfig {
2960    pub alpha_factor: f64,
2961    pub alpha_resid: f64,
2962    pub forgetting: f64,
2963    pub ridge: f64,
2964    pub pred_clip: f64,
2965    pub min_sigma: f64,
2966}
2967
2968impl Default for CrossAssetMacroRlsYModelConfig {
2969    fn default() -> Self {
2970        Self {
2971            alpha_factor: 0.10,
2972            alpha_resid: 0.08,
2973            forgetting: 0.998,
2974            ridge: 0.05,
2975            pred_clip: 2.5,
2976            min_sigma: 0.001,
2977        }
2978    }
2979}
2980
2981#[derive(Debug, Clone, Copy)]
2982struct MacroFactorState {
2983    last_prices: [Option<f64>; 3],
2984    rets_ewma: [f64; 3],
2985    seen: [bool; 3],
2986}
2987
2988impl Default for MacroFactorState {
2989    fn default() -> Self {
2990        Self {
2991            last_prices: [None, None, None],
2992            rets_ewma: [0.0; 3],
2993            seen: [false; 3],
2994        }
2995    }
2996}
2997
2998#[derive(Debug, Clone, Copy)]
2999struct CrossAssetMacroState {
3000    last_price: Option<f64>,
3001    resid2: f64,
3002    has_stats: bool,
3003    last_x: [f64; XASSET_DIM],
3004    has_last_x: bool,
3005    beta: [f64; XASSET_DIM],
3006    p: [[f64; XASSET_DIM]; XASSET_DIM],
3007    samples: u64,
3008}
3009
3010impl Default for CrossAssetMacroState {
3011    fn default() -> Self {
3012        Self {
3013            last_price: None,
3014            resid2: 0.0,
3015            has_stats: false,
3016            last_x: [0.0; XASSET_DIM],
3017            has_last_x: false,
3018            beta: [0.0; XASSET_DIM],
3019            p: [[0.0; XASSET_DIM]; XASSET_DIM],
3020            samples: 0,
3021        }
3022    }
3023}
3024
3025#[derive(Debug, Default)]
3026pub struct CrossAssetMacroRlsYModel {
3027    cfg: CrossAssetMacroRlsYModelConfig,
3028    factor_state: MacroFactorState,
3029    by_instrument: HashMap<String, CrossAssetMacroState>,
3030    by_scope_side: HashMap<String, CrossAssetMacroState>,
3031}
3032
3033impl CrossAssetMacroRlsYModel {
3034    pub fn new(cfg: CrossAssetMacroRlsYModelConfig) -> Self {
3035        Self {
3036            cfg,
3037            factor_state: MacroFactorState::default(),
3038            by_instrument: HashMap::new(),
3039            by_scope_side: HashMap::new(),
3040        }
3041    }
3042
3043    pub fn observe_price(&mut self, instrument: &str, price: f64) {
3044        self.observe_factor_if_applicable(instrument, price);
3045        let snapshot = self.factor_state;
3046        Self::update_target_state(
3047            self.by_instrument
3048                .entry(instrument.to_string())
3049                .or_default(),
3050            snapshot,
3051            price,
3052            self.cfg,
3053        );
3054    }
3055
3056    pub fn observe_signal_price(
3057        &mut self,
3058        instrument: &str,
3059        source_tag: &str,
3060        signal: &Signal,
3061        price: f64,
3062    ) {
3063        self.observe_factor_if_applicable(instrument, price);
3064        let snapshot = self.factor_state;
3065        let key = scoped_side_key(instrument, source_tag, signal);
3066        Self::update_target_state(
3067            self.by_scope_side.entry(key).or_default(),
3068            snapshot,
3069            price,
3070            self.cfg,
3071        );
3072    }
3073
3074    pub fn estimate_base(
3075        &self,
3076        instrument: &str,
3077        fallback_mu: f64,
3078        fallback_sigma: f64,
3079    ) -> YNormal {
3080        let Some(st) = self.by_instrument.get(instrument) else {
3081            return YNormal {
3082                mu: fallback_mu,
3083                sigma: fallback_sigma.max(self.cfg.min_sigma),
3084            };
3085        };
3086        xasset_estimate(st, self.cfg, fallback_mu, fallback_sigma)
3087    }
3088
3089    pub fn estimate_for_signal(
3090        &self,
3091        instrument: &str,
3092        source_tag: &str,
3093        signal: &Signal,
3094        fallback_mu: f64,
3095        fallback_sigma: f64,
3096    ) -> YNormal {
3097        let base = self.estimate_base(instrument, fallback_mu, fallback_sigma);
3098        let key = scoped_side_key(instrument, source_tag, signal);
3099        let Some(scoped) = self.by_scope_side.get(&key) else {
3100            return base;
3101        };
3102        if scoped.samples == 0 {
3103            return base;
3104        }
3105        let scoped_est = xasset_estimate(scoped, self.cfg, base.mu, base.sigma);
3106        let n = scoped.samples as f64;
3107        let w = n / (n + 20.0);
3108        let mu = w * scoped_est.mu + (1.0 - w) * base.mu;
3109        let sigma = (w * scoped_est.sigma + (1.0 - w) * base.sigma).max(self.cfg.min_sigma);
3110        YNormal { mu, sigma }
3111    }
3112
3113    fn observe_factor_if_applicable(&mut self, instrument: &str, price: f64) {
3114        if price <= f64::EPSILON {
3115            return;
3116        }
3117        let canonical = canonical_asset_symbol(instrument);
3118        let Some(idx) = macro_factor_index(&canonical) else {
3119            return;
3120        };
3121        if let Some(prev) = self.factor_state.last_prices[idx] {
3122            if prev > f64::EPSILON {
3123                let r = (price / prev).ln();
3124                let a = self.cfg.alpha_factor.clamp(0.0, 1.0);
3125                self.factor_state.rets_ewma[idx] = if self.factor_state.seen[idx] {
3126                    (1.0 - a) * self.factor_state.rets_ewma[idx] + a * r
3127                } else {
3128                    r
3129                };
3130                self.factor_state.seen[idx] = true;
3131            }
3132        }
3133        self.factor_state.last_prices[idx] = Some(price);
3134    }
3135
3136    fn update_target_state(
3137        st: &mut CrossAssetMacroState,
3138        factors: MacroFactorState,
3139        price: f64,
3140        cfg: CrossAssetMacroRlsYModelConfig,
3141    ) {
3142        if price <= f64::EPSILON {
3143            return;
3144        }
3145        if let Some(prev) = st.last_price {
3146            if prev > f64::EPSILON {
3147                let r = (price / prev).ln();
3148                if st.has_last_x {
3149                    let y_hat = xasset_dot(&st.beta, &st.last_x);
3150                    let err = r - y_hat;
3151                    xasset_rls_update(
3152                        &mut st.beta,
3153                        &mut st.p,
3154                        &st.last_x,
3155                        r,
3156                        cfg.forgetting,
3157                        cfg.ridge,
3158                    );
3159                    let a = cfg.alpha_resid.clamp(0.0, 1.0);
3160                    st.resid2 = if st.has_stats {
3161                        (1.0 - a) * st.resid2 + a * (err * err)
3162                    } else {
3163                        err * err
3164                    };
3165                    st.has_stats = true;
3166                    st.samples = st.samples.saturating_add(1);
3167                } else {
3168                    xasset_init_p(&mut st.p, cfg.ridge);
3169                }
3170                st.last_x = xasset_features(factors);
3171                st.has_last_x = true;
3172                if st.samples == 0 {
3173                    st.samples = 1;
3174                }
3175            }
3176        }
3177        st.last_price = Some(price);
3178    }
3179}
3180
3181fn canonical_asset_symbol(instrument: &str) -> String {
3182    let upper = instrument.trim().to_ascii_uppercase();
3183    upper
3184        .replace(" (FUT)", "")
3185        .replace("#FUT", "")
3186        .replace(" ", "")
3187}
3188
3189fn macro_factor_index(sym: &str) -> Option<usize> {
3190    if sym.contains("SPX") || sym.contains("SP500") || sym.contains("US500") || sym.contains("SPY")
3191    {
3192        return Some(0);
3193    }
3194    if sym.contains("XAU") || sym.contains("GOLD") || sym.contains("GC") {
3195        return Some(1);
3196    }
3197    if sym.contains("WTI") || sym.contains("BRENT") || sym.contains("CL") || sym.contains("OIL") {
3198        return Some(2);
3199    }
3200    None
3201}
3202
3203fn xasset_features(f: MacroFactorState) -> [f64; XASSET_DIM] {
3204    let sp = if f.seen[0] { f.rets_ewma[0] } else { 0.0 };
3205    let gold = if f.seen[1] { f.rets_ewma[1] } else { 0.0 };
3206    let oil = if f.seen[2] { f.rets_ewma[2] } else { 0.0 };
3207    let mean = (sp + gold + oil) / 3.0;
3208    let disp = (((sp - mean).powi(2) + (gold - mean).powi(2) + (oil - mean).powi(2)) / 3.0).sqrt();
3209    [1.0, sp, gold, oil, disp]
3210}
3211
3212fn xasset_estimate(
3213    st: &CrossAssetMacroState,
3214    cfg: CrossAssetMacroRlsYModelConfig,
3215    fallback_mu: f64,
3216    fallback_sigma: f64,
3217) -> YNormal {
3218    if !st.has_last_x {
3219        return YNormal {
3220            mu: fallback_mu,
3221            sigma: fallback_sigma.max(cfg.min_sigma),
3222        };
3223    }
3224    let pred = xasset_dot(&st.beta, &st.last_x);
3225    let sigma_model = st.resid2.max(0.0).sqrt().max(cfg.min_sigma);
3226    let pred_clipped = pred.clamp(-cfg.pred_clip * sigma_model, cfg.pred_clip * sigma_model);
3227    let n = st.samples as f64;
3228    let w = n / (n + 100.0);
3229    let mu = w * pred_clipped + (1.0 - w) * fallback_mu;
3230    let sigma =
3231        (w * sigma_model + (1.0 - w) * fallback_sigma.max(cfg.min_sigma)).max(cfg.min_sigma);
3232    YNormal { mu, sigma }
3233}
3234
3235fn xasset_dot(a: &[f64; XASSET_DIM], b: &[f64; XASSET_DIM]) -> f64 {
3236    let mut s = 0.0;
3237    for i in 0..XASSET_DIM {
3238        s += a[i] * b[i];
3239    }
3240    s
3241}
3242
3243fn xasset_init_p(p: &mut [[f64; XASSET_DIM]; XASSET_DIM], ridge: f64) {
3244    let v = 1.0 / ridge.max(1e-9);
3245    for (i, row) in p.iter_mut().enumerate().take(XASSET_DIM) {
3246        for (j, cell) in row.iter_mut().enumerate().take(XASSET_DIM) {
3247            *cell = if i == j { v } else { 0.0 };
3248        }
3249    }
3250}
3251
3252fn xasset_rls_update(
3253    beta: &mut [f64; XASSET_DIM],
3254    p: &mut [[f64; XASSET_DIM]; XASSET_DIM],
3255    x: &[f64; XASSET_DIM],
3256    y: f64,
3257    forgetting: f64,
3258    ridge: f64,
3259) {
3260    let lambda = forgetting.clamp(0.90, 0.9999);
3261    if p[0][0].abs() <= f64::EPSILON {
3262        xasset_init_p(p, ridge);
3263    }
3264    let mut px = [0.0; XASSET_DIM];
3265    for (i, px_i) in px.iter_mut().enumerate().take(XASSET_DIM) {
3266        let mut v = 0.0;
3267        for (j, xj) in x.iter().enumerate().take(XASSET_DIM) {
3268            v += p[i][j] * *xj;
3269        }
3270        *px_i = v;
3271    }
3272    let mut denom = lambda;
3273    for (i, x_i) in x.iter().enumerate().take(XASSET_DIM) {
3274        denom += *x_i * px[i];
3275    }
3276    if !denom.is_finite() || denom.abs() <= 1e-12 {
3277        return;
3278    }
3279    let mut k = [0.0; XASSET_DIM];
3280    for i in 0..XASSET_DIM {
3281        k[i] = px[i] / denom;
3282    }
3283    let err = y - xasset_dot(beta, x);
3284    for i in 0..XASSET_DIM {
3285        beta[i] += k[i] * err;
3286    }
3287    let mut x_t_p = [0.0; XASSET_DIM];
3288    for (j, xtpj) in x_t_p.iter_mut().enumerate().take(XASSET_DIM) {
3289        let mut v = 0.0;
3290        for (i, x_i) in x.iter().enumerate().take(XASSET_DIM) {
3291            v += *x_i * p[i][j];
3292        }
3293        *xtpj = v;
3294    }
3295    let mut next_p = [[0.0; XASSET_DIM]; XASSET_DIM];
3296    for i in 0..XASSET_DIM {
3297        for (j, xtpj) in x_t_p.iter().enumerate().take(XASSET_DIM) {
3298            next_p[i][j] = (p[i][j] - k[i] * *xtpj) / lambda;
3299        }
3300    }
3301    *p = next_p;
3302}
3303
3304fn scoped_side_key(instrument: &str, source_tag: &str, signal: &Signal) -> String {
3305    let side = match signal {
3306        Signal::Buy => "buy",
3307        Signal::Sell => "sell",
3308        Signal::Hold => "hold",
3309    };
3310    format!(
3311        "{}::{}::{}",
3312        instrument.trim().to_ascii_uppercase(),
3313        source_tag.trim().to_ascii_lowercase(),
3314        side
3315    )
3316}
3317
3318#[derive(Debug, Clone, Copy)]
3319pub struct PendingPrediction {
3320    pub due_ms: u64,
3321    pub base_price: f64,
3322    pub mu: f64,
3323    pub norm_scale: f64,
3324}
3325
3326#[derive(Debug, Clone)]
3327pub struct OnlinePredictorMetrics {
3328    window: usize,
3329    pairs: VecDeque<(f64, f64)>,
3330}
3331
3332impl Default for OnlinePredictorMetrics {
3333    fn default() -> Self {
3334        Self {
3335            window: PREDICTOR_METRIC_WINDOW,
3336            pairs: VecDeque::with_capacity(PREDICTOR_METRIC_WINDOW),
3337        }
3338    }
3339}
3340
3341impl OnlinePredictorMetrics {
3342    pub fn with_window(window: usize) -> Self {
3343        Self {
3344            window: window.max(2),
3345            pairs: VecDeque::with_capacity(window.max(2)),
3346        }
3347    }
3348
3349    pub fn observe(&mut self, y_real: f64, y_pred: f64) {
3350        if !y_real.is_finite() || !y_pred.is_finite() {
3351            return;
3352        }
3353        self.pairs.push_back((y_real, y_pred));
3354        if self.pairs.len() > self.window {
3355            let _ = self.pairs.pop_front();
3356        }
3357    }
3358
3359    pub fn sample_count(&self) -> u64 {
3360        self.pairs.len() as u64
3361    }
3362
3363    pub fn mae(&self) -> Option<f64> {
3364        let n = self.pairs.len();
3365        if n == 0 {
3366            return None;
3367        }
3368        let sum_abs = self
3369            .pairs
3370            .iter()
3371            .map(|(y, yhat)| (y - yhat).abs())
3372            .sum::<f64>();
3373        Some(sum_abs / n as f64)
3374    }
3375
3376    pub fn hit_rate(&self) -> Option<f64> {
3377        let n = self.pairs.len();
3378        if n == 0 {
3379            return None;
3380        }
3381        let hit = self.pairs.iter().filter(|(y, yhat)| y * yhat > 0.0).count() as f64;
3382        Some(hit / n as f64)
3383    }
3384
3385    pub fn r2(&self) -> Option<f64> {
3386        let n = self.pairs.len();
3387        if n < PREDICTOR_R2_MIN_SAMPLES {
3388            return None;
3389        }
3390        let mean_y = self.pairs.iter().map(|(y, _)| *y).sum::<f64>() / n as f64;
3391        let mut sse = 0.0;
3392        let mut sst = 0.0;
3393        for (y, yhat) in &self.pairs {
3394            let err = y - yhat;
3395            sse += err * err;
3396            let d = y - mean_y;
3397            sst += d * d;
3398        }
3399        if sst <= 1e-18 {
3400            return Some(0.0);
3401        }
3402        Some(1.0 - (sse / sst))
3403    }
3404}
3405
3406pub fn stride_closes(closes: &[f64], stride: usize) -> Vec<f64> {
3407    if stride <= 1 {
3408        return closes.to_vec();
3409    }
3410    closes.iter().step_by(stride).copied().collect()
3411}
3412
3413pub fn backfill_predictor_metrics_from_closes(
3414    closes: &[f64],
3415    alpha_mean: f64,
3416    window: usize,
3417) -> OnlinePredictorMetrics {
3418    let mut out = OnlinePredictorMetrics::with_window(window);
3419    let mut prev: Option<f64> = None;
3420    let mut has_mu = false;
3421    let mut mu = 0.0;
3422    let a = alpha_mean.clamp(0.0, 1.0);
3423    for p in closes {
3424        if *p <= f64::EPSILON {
3425            continue;
3426        }
3427        if let Some(pp) = prev {
3428            if pp > f64::EPSILON {
3429                let r = (p / pp).ln();
3430                let pred = if has_mu { mu } else { 0.0 };
3431                out.observe(r, pred);
3432                if !has_mu {
3433                    mu = r;
3434                    has_mu = true;
3435                } else {
3436                    mu = (1.0 - a) * mu + a * r;
3437                }
3438            }
3439        }
3440        prev = Some(*p);
3441    }
3442    out
3443}
3444
3445pub fn backfill_predictor_metrics_from_closes_volnorm(
3446    closes: &[f64],
3447    alpha_mean: f64,
3448    alpha_var: f64,
3449    min_sigma: f64,
3450    window: usize,
3451) -> OnlinePredictorMetrics {
3452    let mut out = OnlinePredictorMetrics::with_window(window);
3453    let mut prev: Option<f64> = None;
3454    let mut has_mu = false;
3455    let mut mu: f64 = 0.0;
3456    let mut var: f64 = 0.0;
3457    let mut has_var = false;
3458    let a_mu = alpha_mean.clamp(0.0, 1.0);
3459    let a_var = alpha_var.clamp(0.0, 1.0);
3460    let sigma_floor = min_sigma.max(1e-8);
3461
3462    for p in closes {
3463        if *p <= f64::EPSILON {
3464            continue;
3465        }
3466        if let Some(pp) = prev {
3467            if pp > f64::EPSILON {
3468                let r = (p / pp).ln();
3469                let pred = if has_mu { mu } else { 0.0 };
3470                let sigma = if has_var {
3471                    var.max(0.0).sqrt().max(sigma_floor)
3472                } else {
3473                    sigma_floor
3474                };
3475                out.observe(r / sigma, pred / sigma);
3476
3477                if !has_mu {
3478                    mu = r;
3479                    has_mu = true;
3480                    var = r * r;
3481                    has_var = true;
3482                } else {
3483                    let prev_mu = mu;
3484                    mu = (1.0 - a_mu) * mu + a_mu * r;
3485                    let centered = r - prev_mu;
3486                    let sample_var = centered * centered;
3487                    if !has_var {
3488                        var = sample_var;
3489                        has_var = true;
3490                    } else {
3491                        var = (1.0 - a_var) * var + a_var * sample_var;
3492                    }
3493                }
3494            }
3495        }
3496        prev = Some(*p);
3497    }
3498    out
3499}
3500
3501pub fn predictor_metrics_scope_key(
3502    symbol: &str,
3503    market: MarketKind,
3504    predictor: &str,
3505    horizon: &str,
3506) -> String {
3507    let market_label = if market == MarketKind::Futures {
3508        "futures"
3509    } else {
3510        "spot"
3511    };
3512    format!(
3513        "{}::{}::{}::{}",
3514        symbol.trim().to_ascii_uppercase(),
3515        market_label,
3516        predictor.trim().to_ascii_lowercase(),
3517        horizon.trim().to_ascii_lowercase(),
3518    )
3519}
3520
3521pub fn parse_predictor_metrics_scope_key(key: &str) -> Option<(String, String, String, String)> {
3522    let mut it = key.splitn(4, "::");
3523    let symbol = it.next()?.to_string();
3524    let market = it.next()?.to_string();
3525    let predictor = it.next()?.to_string();
3526    let horizon = it.next()?.to_string();
3527    Some((symbol, market, predictor, horizon))
3528}