The Learning Library
Contents

Module 16 — From Detection to Decision: Routing, Costs, Turnover

Part IV · Finance Regime Practice · Status: Draft v0.1 Scope: State-gated routing, turnover accounting, dwell rule · Evidence status: hypothesis-under-test · Prerequisites: Modules 03, 07, 15

Overview

Module 15 left you with a filtered probability and a warning. The probability lags the true switch by several bars and flickers around the threshold, so routing on it naively means trading too much.

This module turns that belief into a decision. You build a state-gated exposure rule on the shared synthetic series, count every switch in turnover terms, and deduct an explicit cost model before you quote any number.

How It Works

The router is deliberately dumb. When the filtered probability says the calm low-vol state is most likely, you hold the long-only position. When it says the stressed high-vol state is most likely, you step to cash.

Fills happen at the next open, never at the close you decided on. Every number below is net of an explicit cost assumption, because a gross curve on this router is a method specimen, not a result.

flowchart TD
    A[Bar closes] --> B[Filtered prob update]
    B --> C{Calm state most likely}
    C --> D[Hold long into next open]
    C --> E[Step to cash next open]
    D --> F[Count turnover]
    E --> F
    F --> G[Deduct spread plus fee]
    G --> H[Report net return only]

How to read this: belief updates at the close, positions change at the next open, and costs are deducted before anything is reported. No arrow skips the cost box.

From Probability to Position

A probability is not a position until you write the mapping down. Your rule maps the most-likely filtered state to a target exposure of one or zero, evaluated at each close and filled at the next open.

You reuse the shared synthetic series from Module 15. The generator uses np.random.default_rng(42) with about 2000 daily bars and two Gaussian vol states near 0.5 and 2 percent. The long-only series compounds the synthetic returns when you are on and earns zero when you are off.

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])
obs = returns.reshape(-1, 1)
model = GaussianHMM(n_components=2, covariance_type="diag",
                    n_iter=200, random_state=42)
model.fit(obs)

You take the filtered path the same causal way Module 15 did, then map it to positions. State labels are re-anchored so label 1 always means the hotter fitted state. Positions shift by one bar so tonight’s belief trades tomorrow’s open.

hot_label = int(np.argmax(model.covars_.ravel()))
filt_state = np.zeros(n_bars, dtype=int)
for end in range(20, n_bars):
    prob_now = model.predict_proba(obs[: end + 1])[-1]
    filt_state[end] = int(np.argmax(prob_now))
target_on = (filt_state != hot_label).astype(int)
position = np.zeros(n_bars, dtype=int)
position[1:] = target_on[:-1]  # next-open fills only

The worked reading is illustrative, not a finding: expect the raw router to sit in cash for roughly 10 to 25 percent of bars on the seeded series. Your library version may shift this band, so treat it as shape, not a measured fact.

Counting Turnover and Costs

Every position change is a round trip through a cost. You count turnover as the absolute change in position each day, then multiply by a per-trade cost assumption you state in line with the number.

Your cost model has two named parts. The half-spread pays for crossing from mid to your fill, and the fee stands in for venue charges. Both rates below are explicit PLACEHOLDER assumptions you must replace with your venue’s current schedule before touching real data.

half_spread = 0.0005  # PLACEHOLDER: replace with venue spread
venue_fee = 0.0002  # PLACEHOLDER: replace with venue fee rate
cost_per_switch = half_spread + venue_fee
daily_turnover = np.abs(np.diff(position, prepend=0))
n_switches = int(np.sum(daily_turnover))
gross_pnl = np.sum(position * returns)
cost_bill = n_switches * cost_per_switch
net_pnl = gross_pnl - cost_bill
print("switches:", n_switches, "net pnl:", round(float(net_pnl), 4))

The worked reading is illustrative, not a finding: expect the raw router to flip roughly 80 to 160 times on the seeded series, so its cost bill at the placeholder rate lands near 0.06 to 0.11 in return units. That bill is the point, and every net number you quote must carry its assumption with it.

[!WARNING] Quoting the gross pnl without the cost bill is the classic cost-free gross curve. This curriculum treats any gross-only number as a flawed-method specimen, never as a result.

The Dwell Rule That Calms Flicker

Module 15 measured the disease: one-bar round trips around the 0.5 threshold. The treatment is a minimum-dwell rule with hysteresis. You act only after the filtered probability has sat on the new side of 0.5 for D consecutive bars, with D equal to 3 frozen before you measure.

The dwell rule trades lag for calm. You enter each regime a little later, but you skip most of the whipsaw flips that Module 15 flagged as the cost driver. Both effects must appear in the report, or you are cherry-picking the benefit.

dwell_bars = 3  # frozen before measurement, never tuned after
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
dwell_hot = (run_len >= dwell_bars).astype(int)
dwell_pos = np.zeros(n_bars, dtype=int)
dwell_pos[1:] = (1 - dwell_hot[:-1])  # on unless dwell says hot
dwell_cost = float(np.sum(np.abs(np.diff(dwell_pos,
                                         prepend=0)))) * cost_per_switch
print("dwell switches:",
      int(np.sum(np.abs(np.diff(dwell_pos, prepend=0)))))

The worked reading is illustrative, not a finding: expect the dwell rule to cut switches to roughly one third to one half of the raw count on the seeded series, at the price of 2 extra bars of lag per switch. Your exact counts will differ by library build, so report your own numbers with the placeholder rate attached.

Router Switches (illustrative) Extra lag Net quoted with
Raw filtered 80 to 160 None Placeholder cost rate
Dwell D=3 One third to one half About 2 bars Same placeholder rate

[!TIP] Freeze D before you look at the cost bill. Trying D equal to 2, 3, 5 and keeping the cheapest is parameter cherry-picking, and it voids the test.

Testing It Honestly

Your test question is narrow: does the dwell rule lower the net-of-cost bill without destroying exposure timing, on the synthetic series, with everything frozen? You preregister the hypothesis before running: the dwell router makes fewer switches and keeps a net pnl no worse than the raw router at the stated placeholder rate.

You report four numbers, always as a pair of gross-plus-costs. Switch count and turnover rate come first, because they are assumption-free. Gross pnl and net pnl at the stated placeholder rate come second, with the rate printed in the same line. No gross-only curve appears as a result anywhere.

Failure is informative here. If the dwell router still flips on more than about 5 percent of bars, or if doubling the placeholder spread (your Module 18 stress) flips which router looks better, you report the routing idea as not robust on this series. A failed routing test is reported as failed, not tuned until it passes.

[!IMPORTANT] Freeze the threshold at 0.5, the dwell at 3, the refit schedule from Module 15, and both placeholder rates before you measure. Changing any of them after seeing the bill is cherry-picking.

Hands-On Project

Build both routers on the shared synthetic series and reconcile the cost bill by hand. Everything below is a method demonstration on synthetic data, with no claim about real markets.

Project card v0.1 for this experiment. Identity: synthetic seeded series, np.random.default_rng(42), about 2000 bars, two Gaussian vol states, generated 2026-09-17. Horizon and clock: daily decision bar, signal at close, fills next open, long-only with cash sideline. Execution: hypothetical next-open mid minus half-spread, zero latency modeled, no partial fills. Costs: half-spread 0.0005 plus fee 0.0002 per switch, both PLACEHOLDER assumptions stated in line. Information: filtered probabilities only, labels re-anchored by fitted vol, parameters frozen per refit block. Risk: unit exposure cap, no leverage, no shorting, sideline allowed any bar. Validation: single seeded sample, threshold 0.5 and dwell 3 frozen before measurement. Acceptance: report switches, turnover, gross and net at the stated rate; reject raw-state routing if dwell cuts switches by less than a third.

Your deliverable is a two-line exposure chart plus the four-number table for each router. Extend it once: double the placeholder spread and check whether the ranking of the two routers survives. That stress preview is the habit Module 17 formalizes.

Key Takeaways

  • A filtered probability becomes a position only through a written close-to-next-open mapping.
  • Turnover is the absolute daily position change, and every switch pays the stated cost.
  • Spread and fee rates here are PLACEHOLDER assumptions your venue schedule must replace.
  • Raw filtered routing flips too much because threshold flicker becomes turnover.
  • A frozen 3-bar dwell rule buys fewer switches at the price of about 2 bars of lag.
  • Every performance number is quoted net of costs with its assumption in line.
  • Nothing here is an edge claim; it is a cost-accounting method under test.

References

  • Nystrup et al., statistical jump models vs HMM for regime routing, arXiv:2402.05272 — Edge evidence / adversarial: motivates persistence control as the fix for HMM flicker; reports jump models ahead out-of-sample net of costs; retrieved 2026-09-17; https://arxiv.org/abs/2402.05272
  • Bulla et al. 2011 and related HMM timing lineage — Edge evidence: early descriptive timing results without modern cost-aware gates; read as motivation, not proof; retrieved 2026-09-17; https://doi.org/10.1007/s10614-010-9239-1
  • hmmlearn GaussianHMM user guide, predict_proba interface — Mechanics: defines the posterior interface this router filters causally; retrieved 2026-09-17; https://hmmlearn.readthedocs.io/
  • Hamilton 1989, regime-switching filter — Mechanics: causal filtering ancestor of the close-to-next-open discipline; retrieved 2026-09-17; https://www.jstor.org/stable/1912559
  • Exclusion log: QuantInsti and Medium-style HMM trading tutorials were rejected as evidence (no out-of-sample or cost methodology; background only).