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