Skip to main content

quantwave_core/regimes/
gaussian_hmm.rs

1//! Gaussian / lambda-emission Hidden Markov Model — batch fit, decode, and streaming filter.
2//!
3//! Generic HMM for univariate observation sequences (e.g. log-returns). Gaussian mode
4//! aligns with ldhmm at λ=1; lambda (ecld) emissions match ldhmm for leptokurtic returns.
5//!
6//! Sources:
7//! - Hamilton (1989) — regime switching
8//! - Zucchini et al. (2016) — forward-backward, EM, Viterbi
9//! - references/ldhmm/ldhmm-cran-reference.pdf — mllk, log_forward, viterbi
10//! - references/ldhmm/ssrn-2979516.pdf — lambda emissions
11//! - quantwave-core/tests/gold_standard/hmm_gaussian_2state.json — generic Gaussian fixture
12//! - quantwave-core/tests/gold_standard/hmm_lambda_2state.json — generic lambda fixture
13
14use super::ecld::ecld_pdf;
15use crate::traits::Next;
16use serde::{Deserialize, Serialize};
17
18const LOG_FLOOR: f64 = 1e-300;
19
20/// Stationary Gaussian HMM parameters for `m` latent states.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct GaussianHmmParams {
23    pub n_states: usize,
24    /// Initial / stationary distribution δ (sums to 1).
25    pub delta: Vec<f64>,
26    /// Transition matrix Γ[from][to].
27    pub gamma: Vec<Vec<f64>>,
28    /// Emission mean μ per state.
29    pub means: Vec<f64>,
30    /// Emission standard deviation σ per state (must be > 0).
31    pub stds: Vec<f64>,
32    /// Tail parameter λ per state (λ=1 → Gaussian; λ>1 → heavier tails).
33    #[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/// Emission density family for HMM fitting and decoding.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43pub enum EmissionFamily {
44    #[default]
45    Gaussian,
46    /// Symmetric lambda (ecld / generalized normal) per state.
47    Lambda,
48}
49
50/// Configuration for EM fitting.
51#[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    /// When true and `emission_family == Lambda`, per-state λ is updated in the M-step.
58    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/// Result of EM parameter estimation.
74#[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/// Batch decode output for a fixed parameter set.
84#[derive(Debug, Clone)]
85pub struct GaussianHmmDecode {
86    /// P(C_t = i | x^T) — local / smoothed state probabilities [state][time].
87    pub smooth_probs: Vec<Vec<f64>>,
88    /// Causal filter P(C_t = i | x_{1:t}) [state][time].
89    pub forward_filter: Vec<Vec<f64>>,
90    /// Global Viterbi path (0-indexed states).
91    pub viterbi_path: Vec<usize>,
92    /// Minus log-likelihood (MLLK).
93    pub mllk: f64,
94}
95
96/// Online forward-filter decoder (causal).
97#[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            // Backward-compatible deserialization: default λ=1 per state.
165        } 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    /// Minus log-likelihood with scaling (ldhmm.mllk / Zucchini §3.2).
221    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    /// Causal state probabilities P(C_t | x_{1:t}).
298    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
349/// Fit a Gaussian HMM with Baum–Welch EM on a generic observation vector.
350pub 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(&params, observations)?;
370        params = m_step(&params, 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            // Alternate λ / σ profile updates (ldhmm-style numerical MLE for ecld scale).
659            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
697/// Weighted 1-D profile likelihood for λ (golden-section search on log λ).
698fn 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
747// --- IndicatorMetadata (quantwave-i9dn) ---
748
749use 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};