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