1use super::ecld::ecld_pdf;
15use crate::traits::Next;
16use serde::{Deserialize, Serialize};
17
18const LOG_FLOOR: f64 = 1e-300;
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct GaussianHmmParams {
23 pub n_states: usize,
24 pub delta: Vec<f64>,
26 pub gamma: Vec<Vec<f64>>,
28 pub means: Vec<f64>,
30 pub stds: Vec<f64>,
32 #[serde(default = "default_lambdas_for_states")]
34 pub lambdas: Vec<f64>,
35}
36
37fn default_lambdas_for_states() -> Vec<f64> {
38 Vec::new()
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43pub enum EmissionFamily {
44 #[default]
45 Gaussian,
46 Lambda,
48}
49
50#[derive(Debug, Clone)]
52pub struct GaussianHmmFitConfig {
53 pub n_states: usize,
54 pub max_iter: usize,
55 pub tol: f64,
56 pub emission_family: EmissionFamily,
57 pub fit_lambdas: bool,
59}
60
61impl Default for GaussianHmmFitConfig {
62 fn default() -> Self {
63 Self {
64 n_states: 2,
65 max_iter: 100,
66 tol: 1e-6,
67 emission_family: EmissionFamily::Gaussian,
68 fit_lambdas: false,
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct GaussianHmmFitResult {
76 pub params: GaussianHmmParams,
77 pub log_likelihood: f64,
78 pub aic: f64,
79 pub bic: f64,
80 pub iterations: usize,
81}
82
83#[derive(Debug, Clone)]
85pub struct GaussianHmmDecode {
86 pub smooth_probs: Vec<Vec<f64>>,
88 pub forward_filter: Vec<Vec<f64>>,
90 pub viterbi_path: Vec<usize>,
92 pub mllk: f64,
94}
95
96#[derive(Debug, Clone)]
98pub struct GaussianHmmFilter {
99 params: GaussianHmmParams,
100 forward: Vec<f64>,
101 initialized: bool,
102}
103
104#[derive(Debug, thiserror::Error)]
105pub enum GaussianHmmError {
106 #[error("invalid HMM parameters: {0}")]
107 InvalidParams(String),
108 #[error("need at least {min} observations, got {got}")]
109 InsufficientData { min: usize, got: usize },
110 #[error("EM did not converge within {max_iter} iterations")]
111 EmNotConverged { max_iter: usize },
112}
113
114impl GaussianHmmParams {
115 pub fn new(
116 delta: Vec<f64>,
117 gamma: Vec<Vec<f64>>,
118 means: Vec<f64>,
119 stds: Vec<f64>,
120 ) -> Result<Self, GaussianHmmError> {
121 let n_states = delta.len();
122 let lambdas = vec![1.0; n_states];
123 Self::new_with_lambdas(delta, gamma, means, stds, lambdas)
124 }
125
126 pub fn new_with_lambdas(
127 delta: Vec<f64>,
128 gamma: Vec<Vec<f64>>,
129 means: Vec<f64>,
130 stds: Vec<f64>,
131 lambdas: Vec<f64>,
132 ) -> Result<Self, GaussianHmmError> {
133 let n_states = delta.len();
134 let params = Self {
135 n_states,
136 delta,
137 gamma,
138 means,
139 stds,
140 lambdas,
141 };
142 params.validate()?;
143 Ok(params)
144 }
145
146 pub fn validate(&self) -> Result<(), GaussianHmmError> {
147 let m = self.n_states;
148 if m == 0 {
149 return Err(GaussianHmmError::InvalidParams(
150 "n_states must be > 0".into(),
151 ));
152 }
153 if self.gamma.len() != m
154 || self.means.len() != m
155 || self.stds.len() != m
156 || self.delta.len() != m
157 || (self.lambdas.len() != m && !self.lambdas.is_empty())
158 {
159 return Err(GaussianHmmError::InvalidParams(
160 "parameter vector lengths must match n_states".into(),
161 ));
162 }
163 if self.lambdas.is_empty() {
164 } else {
166 for (i, &lam) in self.lambdas.iter().enumerate() {
167 if !lam.is_finite() || lam <= 0.0 {
168 return Err(GaussianHmmError::InvalidParams(format!(
169 "lambdas[{i}] must be positive and finite"
170 )));
171 }
172 }
173 }
174 let delta_sum: f64 = self.delta.iter().sum();
175 if (delta_sum - 1.0).abs() > 1e-8 {
176 return Err(GaussianHmmError::InvalidParams(format!(
177 "delta must sum to 1, got {delta_sum}"
178 )));
179 }
180 for (i, row) in self.gamma.iter().enumerate() {
181 if row.len() != m {
182 return Err(GaussianHmmError::InvalidParams(format!(
183 "gamma row {i} length mismatch"
184 )));
185 }
186 let row_sum: f64 = row.iter().sum();
187 if (row_sum - 1.0).abs() > 1e-8 {
188 return Err(GaussianHmmError::InvalidParams(format!(
189 "gamma row {i} must sum to 1, got {row_sum}"
190 )));
191 }
192 }
193 for (i, &s) in self.stds.iter().enumerate() {
194 if !s.is_finite() || s <= 0.0 {
195 return Err(GaussianHmmError::InvalidParams(format!(
196 "stds[{i}] must be positive and finite"
197 )));
198 }
199 }
200 Ok(())
201 }
202
203 fn lambda_at(&self, state: usize) -> f64 {
204 self.lambdas.get(state).copied().unwrap_or(1.0)
205 }
206
207 pub fn emission_pdf(&self, state: usize, x: f64) -> f64 {
208 ecld_pdf(
209 x,
210 self.means[state],
211 self.stds[state],
212 self.lambda_at(state),
213 )
214 }
215
216 pub fn emission_log_pdf(&self, state: usize, x: f64) -> f64 {
217 self.emission_pdf(state, x).ln()
218 }
219
220 pub fn mllk(&self, observations: &[f64]) -> Result<f64, GaussianHmmError> {
222 if observations.is_empty() {
223 return Err(GaussianHmmError::InsufficientData { min: 1, got: 0 });
224 }
225 Ok(scaled_forward(self, observations).mllk)
226 }
227
228 pub fn decode(&self, observations: &[f64]) -> Result<GaussianHmmDecode, GaussianHmmError> {
229 if observations.is_empty() {
230 return Err(GaussianHmmError::InsufficientData { min: 1, got: 0 });
231 }
232 let fwd = scaled_forward(self, observations);
233 let smooth = forward_backward_smooth(self, observations);
234 let forward_filter = fwd.filter;
235 let viterbi_path = viterbi_decode(self, observations);
236 Ok(GaussianHmmDecode {
237 smooth_probs: smooth,
238 forward_filter,
239 viterbi_path,
240 mllk: fwd.mllk,
241 })
242 }
243
244 pub fn aic(&self, observations: &[f64]) -> Result<f64, GaussianHmmError> {
245 self.aic_with_options(observations, false)
246 }
247
248 pub fn bic(&self, observations: &[f64]) -> Result<f64, GaussianHmmError> {
249 self.bic_with_options(observations, false)
250 }
251
252 pub fn aic_with_options(
253 &self,
254 observations: &[f64],
255 fit_lambdas: bool,
256 ) -> Result<f64, GaussianHmmError> {
257 let k = self.free_parameter_count(fit_lambdas) as f64;
258 let ll = -self.mllk(observations)?;
259 Ok(2.0 * k - 2.0 * ll)
260 }
261
262 pub fn bic_with_options(
263 &self,
264 observations: &[f64],
265 fit_lambdas: bool,
266 ) -> Result<f64, GaussianHmmError> {
267 let n = observations.len() as f64;
268 let k = self.free_parameter_count(fit_lambdas) as f64;
269 let ll = -self.mllk(observations)?;
270 Ok(k * n.ln() - 2.0 * ll)
271 }
272
273 fn free_parameter_count(&self, fit_lambdas: bool) -> usize {
274 let m = self.n_states;
275 let mut k = (m * m - 1) + (m - 1) + 2 * m;
276 if fit_lambdas {
277 k += m;
278 }
279 k
280 }
281
282 pub fn filter(&self) -> GaussianHmmFilter {
283 GaussianHmmFilter::new(self.clone())
284 }
285}
286
287impl GaussianHmmFilter {
288 pub fn new(params: GaussianHmmParams) -> Self {
289 let m = params.n_states;
290 Self {
291 params,
292 forward: vec![0.0; m],
293 initialized: false,
294 }
295 }
296
297 pub fn state_probabilities(&self) -> Vec<f64> {
299 if self.initialized {
300 self.forward.clone()
301 } else {
302 self.params.delta.clone()
303 }
304 }
305}
306
307impl Next<f64> for GaussianHmmFilter {
308 type Output = Vec<f64>;
309
310 fn next(&mut self, x: f64) -> Self::Output {
311 let m = self.params.n_states;
312 if !self.initialized {
313 let mut probs = vec![0.0; m];
314 let mut sum = 0.0;
315 for i in 0..m {
316 probs[i] = self.params.delta[i] * self.params.emission_pdf(i, x);
317 sum += probs[i];
318 }
319 if sum > 0.0 {
320 for p in &mut probs {
321 *p /= sum;
322 }
323 }
324 self.forward = probs;
325 self.initialized = true;
326 return self.forward.clone();
327 }
328
329 let mut next = vec![0.0; m];
330 let mut sum = 0.0;
331 for j in 0..m {
332 let mut acc = 0.0;
333 for i in 0..m {
334 acc += self.forward[i] * self.params.gamma[i][j];
335 }
336 next[j] = acc * self.params.emission_pdf(j, x);
337 sum += next[j];
338 }
339 if sum > 0.0 {
340 for p in &mut next {
341 *p /= sum;
342 }
343 }
344 self.forward = next;
345 self.forward.clone()
346 }
347}
348
349pub fn fit_em(
351 observations: &[f64],
352 config: &GaussianHmmFitConfig,
353) -> Result<GaussianHmmFitResult, GaussianHmmError> {
354 let n = observations.len();
355 if n < config.n_states + 1 {
356 return Err(GaussianHmmError::InsufficientData {
357 min: config.n_states + 1,
358 got: n,
359 });
360 }
361 let m = config.n_states;
362 let mut params = init_params_em(observations, m, config)?;
363 let mut prev_mllk = f64::INFINITY;
364 let mut iterations = 0usize;
365 let fit_lambdas = config.emission_family == EmissionFamily::Lambda && config.fit_lambdas;
366
367 for iter in 0..config.max_iter {
368 iterations = iter + 1;
369 let (gamma_t, xi_t) = e_step(¶ms, observations)?;
370 params = m_step(¶ms, observations, &gamma_t, &xi_t, config)?;
371 let mllk = params.mllk(observations)?;
372 if (prev_mllk - mllk).abs() < config.tol {
373 let ll = -mllk;
374 return Ok(GaussianHmmFitResult {
375 aic: params.aic_with_options(observations, fit_lambdas)?,
376 bic: params.bic_with_options(observations, fit_lambdas)?,
377 iterations,
378 log_likelihood: ll,
379 params,
380 });
381 }
382 prev_mllk = mllk;
383 }
384
385 let mllk = params.mllk(observations)?;
386 Ok(GaussianHmmFitResult {
387 log_likelihood: -mllk,
388 aic: params.aic_with_options(observations, fit_lambdas)?,
389 bic: params.bic_with_options(observations, fit_lambdas)?,
390 iterations,
391 params,
392 })
393}
394
395struct ScaledForward {
396 filter: Vec<Vec<f64>>,
397 mllk: f64,
398}
399
400fn scaled_forward(params: &GaussianHmmParams, x: &[f64]) -> ScaledForward {
401 let m = params.n_states;
402 let n = x.len();
403 let mut filter = vec![vec![0.0; n]; m];
404 let mut phi = vec![0.0; m];
405
406 for i in 0..m {
407 phi[i] = params.delta[i] * params.emission_pdf(i, x[0]);
408 }
409 let sum0: f64 = phi.iter().sum();
410 let mut log_scale = sum0.ln();
411 for i in 0..m {
412 filter[i][0] = phi[i] / sum0;
413 }
414
415 for t in 1..n {
416 let mut next = vec![0.0; m];
417 for j in 0..m {
418 let mut acc = 0.0;
419 for i in 0..m {
420 acc += filter[i][t - 1] * params.gamma[i][j];
421 }
422 next[j] = acc * params.emission_pdf(j, x[t]);
423 }
424 let sum_t: f64 = next.iter().sum();
425 log_scale += sum_t.ln();
426 for j in 0..m {
427 filter[j][t] = next[j] / sum_t;
428 }
429 }
430
431 ScaledForward {
432 filter,
433 mllk: -log_scale,
434 }
435}
436
437fn forward_backward_smooth(params: &GaussianHmmParams, x: &[f64]) -> Vec<Vec<f64>> {
438 let m = params.n_states;
439 let n = x.len();
440 let fwd = scaled_forward(params, x);
441 let mut beta = vec![vec![0.0; n]; m];
442 for i in 0..m {
443 beta[i][n - 1] = 1.0;
444 }
445
446 for t in (0..n - 1).rev() {
447 let mut next = vec![0.0; m];
448 for i in 0..m {
449 let mut acc = 0.0;
450 for j in 0..m {
451 acc += params.gamma[i][j] * params.emission_pdf(j, x[t + 1]) * beta[j][t + 1];
452 }
453 next[i] = acc;
454 }
455 let sum_t: f64 = next.iter().sum();
456 for i in 0..m {
457 beta[i][t] = if sum_t > 0.0 { next[i] / sum_t } else { 0.0 };
458 }
459 }
460
461 let mut smooth = vec![vec![0.0; n]; m];
462 for t in 0..n {
463 let mut raw = vec![0.0; m];
464 let mut sum = 0.0;
465 for i in 0..m {
466 raw[i] = fwd.filter[i][t] * beta[i][t];
467 sum += raw[i];
468 }
469 for i in 0..m {
470 smooth[i][t] = if sum > 0.0 { raw[i] / sum } else { 0.0 };
471 }
472 }
473 smooth
474}
475
476fn viterbi_decode(params: &GaussianHmmParams, x: &[f64]) -> Vec<usize> {
477 let m = params.n_states;
478 let n = x.len();
479 let mut delta = vec![vec![0.0; n]; m];
480 let mut psi = vec![vec![0usize; n]; m];
481
482 for i in 0..m {
483 delta[i][0] = (params.delta[i] * params.emission_pdf(i, x[0]) + LOG_FLOOR).ln();
484 }
485
486 for t in 1..n {
487 for j in 0..m {
488 let mut best_log = f64::NEG_INFINITY;
489 let mut best_i = 0usize;
490 for i in 0..m {
491 let v = delta[i][t - 1]
492 + (params.gamma[i][j] + LOG_FLOOR).ln()
493 + params.emission_log_pdf(j, x[t]);
494 if v > best_log {
495 best_log = v;
496 best_i = i;
497 }
498 }
499 delta[j][t] = best_log;
500 psi[j][t] = best_i;
501 }
502 }
503
504 let mut path = vec![0usize; n];
505 path[n - 1] = (0..m)
506 .max_by(|&a, &b| {
507 delta[a][n - 1]
508 .partial_cmp(&delta[b][n - 1])
509 .unwrap_or(std::cmp::Ordering::Equal)
510 })
511 .unwrap_or(0);
512
513 for t in (0..n - 1).rev() {
514 path[t] = psi[path[t + 1]][t + 1];
515 }
516 path
517}
518
519fn init_params_em(
520 x: &[f64],
521 m: usize,
522 config: &GaussianHmmFitConfig,
523) -> Result<GaussianHmmParams, GaussianHmmError> {
524 let mean_all: f64 = x.iter().sum::<f64>() / x.len() as f64;
525 let var_all: f64 = x.iter().map(|v| (v - mean_all).powi(2)).sum::<f64>() / x.len() as f64;
526 let base_std = var_all.sqrt().max(1e-4);
527
528 let mut means = Vec::with_capacity(m);
529 let mut stds = Vec::with_capacity(m);
530 for k in 0..m {
531 let offset = (k as f64 - (m as f64 - 1.0) / 2.0) * base_std * 0.5;
532 means.push(mean_all + offset);
533 stds.push(base_std);
534 }
535
536 let mut gamma = vec![vec![0.0; m]; m];
537 let stay = 0.9;
538 let off = (1.0 - stay) / (m as f64 - 1.0).max(1.0);
539 for i in 0..m {
540 for j in 0..m {
541 gamma[i][j] = if i == j { stay } else { off };
542 }
543 }
544
545 let delta = vec![1.0 / m as f64; m];
546 let lambdas = match config.emission_family {
547 EmissionFamily::Gaussian => vec![1.0; m],
548 EmissionFamily::Lambda => vec![1.0; m],
549 };
550 GaussianHmmParams::new_with_lambdas(delta, gamma, means, stds, lambdas)
551}
552
553fn e_step(
554 params: &GaussianHmmParams,
555 x: &[f64],
556) -> Result<(Vec<Vec<f64>>, Vec<Vec<Vec<f64>>>), GaussianHmmError> {
557 let smooth = forward_backward_smooth(params, x);
558 let m = params.n_states;
559 let n = x.len();
560 let mut xi: Vec<Vec<Vec<f64>>> = (0..n - 1)
561 .map(|_| (0..m).map(|_| vec![0.0; m]).collect())
562 .collect();
563
564 for t in 0..n - 1 {
565 let mut denom = 0.0;
566 let mut numer = vec![vec![0.0; m]; m];
567 for i in 0..m {
568 for j in 0..m {
569 numer[i][j] = smooth[i][t] * params.gamma[i][j] * params.emission_pdf(j, x[t + 1]);
570 denom += numer[i][j];
571 }
572 }
573 for i in 0..m {
574 for j in 0..m {
575 xi[t][i][j] = if denom > 0.0 {
576 numer[i][j] / denom
577 } else {
578 0.0
579 };
580 }
581 }
582 }
583 Ok((smooth, xi))
584}
585
586fn m_step(
587 prev: &GaussianHmmParams,
588 x: &[f64],
589 gamma_t: &[Vec<f64>],
590 xi: &[Vec<Vec<f64>>],
591 config: &GaussianHmmFitConfig,
592) -> Result<GaussianHmmParams, GaussianHmmError> {
593 let m = gamma_t.len();
594 let n = x.len();
595
596 let mut delta = vec![0.0; m];
597 for i in 0..m {
598 delta[i] = gamma_t[i][0];
599 }
600 let d_sum: f64 = delta.iter().sum();
601 if d_sum > 0.0 {
602 for d in &mut delta {
603 *d /= d_sum;
604 }
605 }
606
607 let mut gamma = vec![vec![0.0; m]; m];
608 for i in 0..m {
609 let mut row_sum = 0.0;
610 for j in 0..m {
611 let mut acc = 0.0;
612 for t in 0..xi.len() {
613 acc += xi[t][i][j];
614 }
615 gamma[i][j] = acc;
616 row_sum += acc;
617 }
618 if row_sum > 0.0 {
619 for j in 0..m {
620 gamma[i][j] /= row_sum;
621 }
622 } else {
623 for j in 0..m {
624 gamma[i][j] = if i == j { 1.0 } else { 0.0 };
625 }
626 }
627 }
628
629 let mut means = vec![0.0; m];
630 let mut stds = vec![0.0; m];
631 let mut lambdas = prev.lambdas.clone();
632 if lambdas.len() != m {
633 lambdas = vec![1.0; m];
634 }
635
636 for j in 0..m {
637 let mut w_sum = 0.0;
638 let mut mean_acc = 0.0;
639 for t in 0..n {
640 let w = gamma_t[j][t];
641 w_sum += w;
642 mean_acc += w * x[t];
643 }
644 means[j] = if w_sum > 0.0 { mean_acc / w_sum } else { 0.0 };
645
646 let mut var_acc = 0.0;
647 for t in 0..n {
648 let w = gamma_t[j][t];
649 var_acc += w * (x[t] - means[j]).powi(2);
650 }
651 stds[j] = if w_sum > 0.0 {
652 (var_acc / w_sum).sqrt().max(1e-6)
653 } else {
654 1e-3
655 };
656
657 if config.emission_family == EmissionFamily::Lambda && config.fit_lambdas {
658 let mut sigma = stds[j];
660 let mut lambda = lambdas[j];
661 for _ in 0..3 {
662 lambda = profile_lambda_for_state(x, &gamma_t[j], means[j], sigma, lambda);
663 sigma = profile_sigma_for_state(x, &gamma_t[j], means[j], lambda, sigma);
664 }
665 lambdas[j] = lambda;
666 stds[j] = sigma;
667 }
668 }
669
670 GaussianHmmParams::new_with_lambdas(delta, gamma, means, stds, lambdas)
671}
672
673fn weighted_neg_log_likelihood(
674 x: &[f64],
675 weights: &[f64],
676 mu: f64,
677 sigma: f64,
678 lambda: f64,
679) -> f64 {
680 if sigma <= 1e-8 || !(1.0..=8.0).contains(&lambda) {
681 return f64::INFINITY;
682 }
683 let mut nll = 0.0;
684 for (&obs, &w) in x.iter().zip(weights.iter()) {
685 if w > 0.0 {
686 let p = ecld_pdf(obs, mu, sigma, lambda);
687 if p > 0.0 {
688 nll -= w * p.ln();
689 } else {
690 return f64::INFINITY;
691 }
692 }
693 }
694 nll
695}
696
697fn profile_lambda_for_state(x: &[f64], weights: &[f64], mu: f64, sigma: f64, _initial: f64) -> f64 {
699 let objective =
700 |log_lam: f64| weighted_neg_log_likelihood(x, weights, mu, sigma, log_lam.exp());
701 golden_section_minimize_log(objective, 1.0f64.ln(), 5.0f64.ln())
702 .exp()
703 .clamp(1.0, 5.0)
704}
705
706fn profile_sigma_for_state(x: &[f64], weights: &[f64], mu: f64, lambda: f64, initial: f64) -> f64 {
707 let lo = 1e-5f64.ln();
708 let hi = (initial.max(1e-4) * 4.0).max(0.05).ln();
709 let objective =
710 |log_sigma: f64| weighted_neg_log_likelihood(x, weights, mu, log_sigma.exp(), lambda);
711 golden_section_minimize_log(objective, lo, hi)
712 .exp()
713 .max(1e-6)
714}
715
716fn golden_section_minimize_log<F>(f: F, mut a: f64, mut b: f64) -> f64
717where
718 F: Fn(f64) -> f64,
719{
720 const PHI: f64 = 1.618_033_988_749_895;
721 let tol = 1e-4;
722 let mut c = b - (b - a) / PHI;
723 let mut d = a + (b - a) / PHI;
724 let mut fc = f(c);
725 let mut fd = f(d);
726 for _ in 0..80 {
727 if (b - a).abs() < tol {
728 break;
729 }
730 if fc < fd {
731 b = d;
732 d = c;
733 fd = fc;
734 c = b - (b - a) / PHI;
735 fc = f(c);
736 } else {
737 a = c;
738 c = d;
739 fc = fd;
740 d = a + (b - a) / PHI;
741 fd = f(d);
742 }
743 }
744 if fc < fd { c } else { d }
745}
746
747use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
750
751pub const GAUSSIAN_HMM_METADATA: IndicatorMetadata = IndicatorMetadata {
752 name: "gaussian_hmm",
753 description: "Fittable HMM with Gaussian or lambda (ecld) emissions for generic univariate series (e.g. log-returns).",
754 usage: "Fit with EM on an observation vector (Gaussian or lambda emission family), decode smoothed state \
755 probabilities and a Viterbi path, or stream causal forward-filter probabilities for live regime labeling.",
756 keywords: &[
757 "regime", "hmm", "gaussian", "lambda", "ecld", "em", "mle", "viterbi", "ldhmm",
758 ],
759 ehlers_summary: "Homogeneous first-order HMM with Gaussian or symmetric lambda (ecld) state emissions and \
760 Baum–Welch EM fitting. Gaussian mode aligns with ldhmm at λ=1; lambda mode matches ldhmm \
761 leptokurtic daily-return modeling (SSRN 2979516).",
762 params: &[
763 ParamDef {
764 name: "n_states",
765 default: "2",
766 description: "Number of latent states (m ≥ 2).",
767 },
768 ParamDef {
769 name: "max_iter",
770 default: "100",
771 description: "Maximum EM iterations.",
772 },
773 ParamDef {
774 name: "tol",
775 default: "1e-6",
776 description: "EM convergence tolerance on MLLK improvement.",
777 },
778 ParamDef {
779 name: "emission_family",
780 default: "Gaussian",
781 description: "Emission density: Gaussian (λ=1) or Lambda (ecld per-state λ).",
782 },
783 ParamDef {
784 name: "fit_lambdas",
785 default: "false",
786 description: "When emission_family=Lambda, estimate per-state λ in the M-step.",
787 },
788 ],
789 formula_source: "references/ldhmm/ldhmm-cran-reference.pdf; references/ldhmm/ssrn-2979516.pdf; Hamilton (1989); Zucchini et al. (2016)",
790 formula_latex: "",
791 gold_standard_file: "hmm_gaussian_2state.json",
792 category: "Regime",
793};
794
795pub const LAMBDA_HMM_METADATA: IndicatorMetadata = IndicatorMetadata {
796 name: "lambda_hmm",
797 description: "Lambda-distribution (ecld) emission HMM for leptokurtic returns — ldhmm parity mode.",
798 usage: "Same API as gaussian_hmm with emission_family=Lambda; per-state (μ, σ, λ) and optional λ MLE.",
799 keywords: &["regime", "hmm", "lambda", "ecld", "ldhmm", "leptokurtic"],
800 ehlers_summary: "Symmetric exponential-power emissions (β=2/λ) per ldhmm ecld.*; λ=1 reduces to Gaussian.",
801 params: &[
802 ParamDef {
803 name: "n_states",
804 default: "2",
805 description: "Number of latent states.",
806 },
807 ParamDef {
808 name: "max_iter",
809 default: "100",
810 description: "Maximum EM iterations.",
811 },
812 ParamDef {
813 name: "fit_lambdas",
814 default: "true",
815 description: "Estimate per-state λ in the M-step.",
816 },
817 ],
818 formula_source: "references/ldhmm/ssrn-2979516.pdf; references/ldhmm/ldhmm-cran-reference.pdf",
819 formula_latex: "",
820 gold_standard_file: "hmm_lambda_2state.json",
821 category: "Regime",
822};