Skip to main content

sandbox_quant/predictor/
mod.rs

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