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