The Learning Library
Contents

Module 18 — Capstone: HMM vs Jump Model vs Threshold Router

Part IV · Finance Regime Practice · Status: Draft v0.1 Scope: Full project card, three-way OOS horse race, falsification · Evidence status: hypothesis-under-test · Prerequisites: Modules 15, 16, 17

Overview

You now run the whole pipeline once, end to end, on the shared synthetic series. One dataset, one frozen split, one cost model, three routers, one verdict written before you are tempted to edit it.

This module is the template you adapt to real data later. Every choice a real deployment needs lives in the project card below, filled in prose you can copy. Nothing here claims an edge on any market.

How It Works

The capstone is a horse race with a referee. The referee is the frozen test block from Module 17 plus the cost model from Module 16. The three horses are the HMM-plus-dwell router, the one-parameter threshold router, and a numpy jump-style persistence-penalized router.

You work in numbered steps so each block stays checkable. Data and split come first, routers second, accounting last. The verdict rule is written before any test number is computed.

flowchart TD
    A[Shared seeded series] --> B[Frozen purged split]
    B --> C[HMM plus dwell router]
    B --> D[Threshold router]
    B --> E[Jump-style router]
    C --> F[Same cost accounting]
    D --> F
    E --> F
    F --> G[One verdict paragraph]

How to read this: one series enters, three routers run under one referee, and a single paragraph leaves. Any step that runs twice with different settings is a defect.

The Three-Way Horse Race

You build the race in four numbered steps. Each code block is self-contained and short enough to read on one page. All placeholder rates are printed where they are used.

Step 1 — Data and frozen split

You regenerate the shared series and freeze the Module 17 boundaries. Nothing below this block moves the split.

import numpy as np
from hmmlearn.hmm import GaussianHMM

rng = np.random.default_rng(42)
n_bars = 2000
true_state = np.zeros(n_bars, dtype=int)
stay_prob = np.array([0.985, 0.94])
for bar in range(1, n_bars):
    prev = true_state[bar - 1]
    flip = rng.random() > stay_prob[prev]
    true_state[bar] = 1 - prev if flip else prev
state_vol = np.array([0.005, 0.02])
returns = rng.normal(0.0, state_vol[true_state])
train_end, valid_end = 1000, 1500
test_start = 1540  # purge plus embargo already applied

Step 2 — The three routers

You fit the HMM on pre-test bars only, then route causally with the frozen 3-bar dwell. The threshold cut comes from train bars only. The jump-style router keeps its penalty frozen.

obs = returns.reshape(-1, 1)
pre_test = obs[:valid_end]
model = GaussianHMM(n_components=2, covariance_type="diag",
                    n_iter=200, random_state=42)
model.fit(pre_test)
hot_label = int(np.argmax(model.covars_.ravel()))
prob_hot = np.zeros(n_bars)
for end in range(20, n_bars):
    prob_hot[end] = model.predict_proba(obs[: end + 1])[-1,
                                                         hot_label]
above = (prob_hot > 0.5).astype(int)
run_len = np.zeros(n_bars, dtype=int)
for bar in range(1, n_bars):
    run_len[bar] = run_len[bar - 1] + 1 if above[bar] else 0
hmm_pos = np.zeros(n_bars, dtype=int)
hmm_pos[1:] = (run_len[:-1] < 3).astype(int)
lookback, cut_q = 60, 0.8  # both frozen before validation
real_vol = np.zeros(n_bars)
for bar in range(lookback, n_bars):
    window = returns[bar - lookback:bar]
    real_vol[bar] = float(np.std(window))
cut = float(np.quantile(real_vol[lookback:train_end], cut_q))
thresh_pos = np.zeros(n_bars, dtype=int)
thresh_pos[1:] = (real_vol[:-1] < cut).astype(int)
centers = np.array([0.005, 0.02])
penalty = 0.0005  # frozen jump penalty per switch
jump_state = np.zeros(n_bars, dtype=int)
for bar in range(1, n_bars):
    dist = np.abs(real_vol[bar] - centers)
    stay = jump_state[bar - 1]
    score = dist + penalty * (np.arange(2) != stay)
    jump_state[bar] = int(np.argmin(score))
jump_pos = (jump_state != 1).astype(int)

Step 3 — One cost referee

You score all three routers on the identical test block with the identical placeholder rate. The rate travels with the number everywhere it is quoted.

half_spread = 0.0005  # PLACEHOLDER: venue rate here
venue_fee = 0.0002  # PLACEHOLDER: venue fee here
rate = half_spread + venue_fee
test = np.arange(test_start, n_bars)
results = {}
for name, pos in [("hmm_dwell", hmm_pos),
                  ("threshold", thresh_pos),
                  ("jump_style", jump_pos)]:
    seg_pos = pos[test]
    seg_ret = returns[test]
    switches = int(np.sum(np.abs(np.diff(pos, prepend=0))[test]))
    gross = float(np.sum(seg_pos * seg_ret))
    net = gross - switches * rate
    results[name] = (switches, round(gross, 4), round(net, 4))
print(results)

The worked reading is illustrative, not a finding: expect each router’s test net to land within a band of roughly plus or minus 0.05 of its gross on the seeded series at the placeholder rate. Your build will differ, so you report your own table and treat the band as shape only.

Acceptance Gates Before You Run

You write the pass and fail rules before the test number exists. That ordering is what separates a capstone from a backtest-shaped story.

First gate is the cost stress. You rerun the accounting with double the placeholder spread and require the router ranking to survive. If doubling the spread crowns a different winner, the result is cost-fragile and you label it so.

Second gate is seed stability. You regenerate the series with seeds 41 and 43 through the identical pipeline and require the verdict paragraph to stay true in words, not just in sign. A result that exists only under seed 42 is a seed artifact, and you report it as one.

Third gate is the rejection list, frozen now. You reject any edge claim if the HMM wins gross but loses net, if any router’s test used its own private split, or if any parameter moved after the test number appeared. Rejection here is a successful capstone with a negative answer.

[!IMPORTANT] Run gates in this order: frozen test verdict, cost-doubled stress, seed 41 and 43 checks. Do not reorder them to rescue a favorite router.

Gate Action Pass rule
Cost stress Double placeholder spread Ranking unchanged
Seed check Rerun seeds 41, 43 Verdict wording holds
Rejection list Apply frozen criteria No post-hoc edits

What Would Falsify This

A hypothesis without a falsifier is a slogan. Yours has four, and any one of them voids an edge reading of this capstone.

The edge vanishes with costs: every router wins gross and loses net at the stated placeholder rate. The edge exists only in-sample: validation picks a configuration the test block reverses. The result is seed-fragile: seeds 41 or 43 disagree with seed 42 on which router wins net. The complexity does not pay: the one-parameter threshold router matches or beats both state models net of costs with far fewer switches.

Any single falsifier firing means you write the negative verdict. You state which falsifier fired, you keep the table that shows it, and you do not retune your way out. That discipline is the deliverable your future real-data self will thank you for.

[!TIP] Copy this section verbatim into your real-data notebook. Falsifiers written after the fact protect no one.

Testing It Honestly

Your test block is bars 1540 to 2000, touched once for the verdict. Validation picked the single frozen configuration; the test reports it without edits. Every router faces the same bars, the same next-open fills, and the same placeholder rate.

You report switches, turnover rate, gross, cost bill, and net per router, with the rate printed in line. You add realized vol and drawdown depth as the defensive statistics, because Module 17 set the prior that regime overlays defend rather than enrich. A defensive win with a net tie is labeled exactly that, never upgraded into alpha.

If you failed a gate, this section says which one and what you conclude. A capstone that rejects its own hypothesis is a pass on method and a fail on edge, and you write both halves plainly.

Hands-On Project

Run the full race, apply the gates, and file the verdict. This is a method template on synthetic data, and it claims nothing about real markets.

Project card v0.1, filled end to end. Identity: synthetic seeded series, np.random.default_rng(42) with stability reruns at 41 and 43, about 2000 daily bars, two Gaussian vol states near 0.5 and 2 percent, generated 2026-09-17. Horizon and clock: daily decision bar evaluated at close, execution next open, test block bars 1540 to 2000, sideline allowed. Execution: hypothetical next-open mid minus half-spread, zero modeled latency, full fills only, no partials or rejections modeled. Costs: half-spread 0.0005 plus fee 0.0002 per switch, both PLACEHOLDER assumptions; stress reruns at double spread. Information: filtered probabilities only, HMM fit on pre-test bars, threshold cut from train only, jump penalty frozen, labels re-anchored by fitted vol. Risk: unit exposure cap, long-or-cash only, no leverage, no overnight carve-outs needed on daily bars, kill rule is any gate failure stops edge language. Validation: train 0 to 1000, validation 1040 to 1500, test 1540 to 2000, purge plus embargo 20 bars, one frozen configuration into test, trial ledger is the three-gate table above. Acceptance: HMM claims nothing beyond defensive behavior unless it wins net on the frozen test, survives doubled spread, and holds wording across seeds 41 to 43; otherwise file the falsifier that fired.

Extend-the-hypothesis checklist for real data. Swap the synthetic series for venue bars with that venue’s current spread and fee schedule. Replace the univariate Gaussian emission with a multivariate one only after the univariate race passes its gates. Add a paper-trading reconciliation leg where predicted fills and realized fills must agree within your stated tolerance before any capital claims.

Key Takeaways

  • One series, one split, one cost referee, three routers, one verdict paragraph.
  • Gates and falsifiers are written before the test number, never after.
  • Cost-doubled stress and seeds 41 and 43 decide whether a win is real enough to keep.
  • A fired falsifier is a negative finding you file, not a tuning prompt.
  • Defensive outcomes count as defensive only, never as alpha.
  • The project card is the template you carry to real data.
  • No number in this module describes any real market.

References

  • Nystrup et al., statistical jump models vs HMM, arXiv:2402.05272 — Edge evidence / adversarial: the comparison this capstone is built to reproduce honestly; method summarized, exact numbers not repeated; retrieved 2026-09-17; https://arxiv.org/abs/2402.05272
  • Bulla et al. 2011, HMM timing lineage — Edge evidence: motivates the defensive-not-profitable prior the verdict is judged against; retrieved 2026-09-17; https://doi.org/10.1007/s10614-010-9239-1
  • Pohle et al. 2017, state-count selection pitfalls, arXiv:1701.08673 — Mechanics: why the two-state choice and frozen penalty stay fixed through the race; retrieved 2026-09-17; https://arxiv.org/abs/1701.08673
  • hmmlearn GaussianHMM documentation — Mechanics: estimator, seeding, and predict_proba interface used in Step 2; retrieved 2026-09-17; https://hmmlearn.readthedocs.io/
  • Exclusion log: QuantInsti and Medium-style HMM trading tutorials were rejected as evidence (no out-of-sample or cost methodology; background only).