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