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