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("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            // Backward-compatible deserialization: default λ=1 per state.
163        } 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    /// Minus log-likelihood with scaling (ldhmm.mllk / Zucchini §3.2).
214    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    /// Causal state probabilities P(C_t | x_{1:t}).
291    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
342/// Fit a Gaussian HMM with Baum–Welch EM on a generic observation vector.
343pub 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(&params, observations)?;
363        params = m_step(&params, 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            // Alternate λ / σ profile updates (ldhmm-style numerical MLE for ecld scale).
650            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
688/// Weighted 1-D profile likelihood for λ (golden-section search on log λ).
689fn 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
751// --- IndicatorMetadata (quantwave-i9dn) ---
752
753use 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};