Summary
pl.col(...).ta.hmm_bull_bear() returns a constant 1 for every bar when fed a price series, regardless of the underlying trend — an all-up series and an all-down series both classify as 100% state 1. The indicator only produces varying output when fed returns, and even then emits states labelled {1, 2} rather than the 0/1 (bear/bull) a caller would expect. This makes it unusable as a regime filter on price data without undocumented preprocessing.
Environment
- quantwave
0.7.0
- polars
1.40.1
- Python 3.12, macOS (arm64)
Minimal reproduction
import polars as pl, quantwave, numpy as np
np.random.seed(1)
def series(drift, n=400, vol=0.01, p0=100.0):
p = [p0]
for _ in range(n): p.append(p[-1] * (1 + np.random.normal(drift, vol)))
return p
def counts(vals):
v = np.asarray(vals, float); v = v[~np.isnan(v)]
u, c = np.unique(v, return_counts=True); return dict(zip(u.astype(int).tolist(), c.tolist()))
def hmm(x):
return (pl.DataFrame({"c": [float(v) for v in x]})
.select(pl.col("c").ta.hmm_bull_bear().alias("r")).to_series().to_numpy())
up, dn = series(0.004), series(-0.004)
print("PRICE uptrend ->", counts(hmm(up)))
print("PRICE downtrend->", counts(hmm(dn)))
print("RETURN uptrend ->", counts(hmm(np.diff(np.log(up)))))
print("RETURN downtrend->", counts(hmm(np.diff(np.log(dn)))))
Actual output
PRICE uptrend -> {1: 401}
PRICE downtrend-> {1: 401}
RETURN uptrend -> {1: 398, 2: 2}
RETURN downtrend-> {1: 361, 2: 39}
Same behaviour on real data: run against ~4,100 daily closes of the NIFTY 50 index, hmm_bull_bear returned 1 for all 4,106 bars.
Expected
- On a price series with a clear regime change, the classifier should switch states (bull vs bear), not return a single constant state.
- State labels should be documented and ideally normalised (e.g.
0 = bear, 1 = bull). The current {1, 2} labelling (only on returns input) is undocumented and easy to misread.
Observations / questions
- Input expectation is undocumented. It appears the indicator wants returns, not price levels — but
.ta accessors generally take price/close. If returns are required, that should be stated (and price input should probably error or be internally differenced rather than silently collapsing to a constant).
- State semantics. On returns input it emits
{1, 2}, with 2 appearing far more often in the downtrend (39 vs 2), suggesting 2 ≈ bear — but this isn't documented.
- Look-ahead. If the HMM is fit in batch over the whole series, the classification at bar t uses future data. Given the library's "bit-identical streaming & batch" guarantee, it would help to confirm
hmm_bull_bear is causal (streaming-safe) for backtesting use.
Impact
Discovered while dogfooding quantwave as the compute layer for a momentum backtest, using hmm_bull_bear as a market-regime gate on NIFTY 50. Because it returned a constant on price input, the gate never triggered defensively; when coerced via returns it fired on ~8% of days and degraded every backtest metric — consistent with an unreliable/constant signal rather than a working regime classifier.
Minor, likely-by-design (noting for docs)
stddev(N) uses population std (ddof=0); pandas .std() defaults to ddof=1. TA-Lib convention, but a documented note would prevent parity surprises.
roc(N) is scaled ×100 (TA-Lib convention); rocp(N) gives the raw price/price_N - 1 ratio.
Summary
pl.col(...).ta.hmm_bull_bear()returns a constant1for every bar when fed a price series, regardless of the underlying trend — an all-up series and an all-down series both classify as 100% state1. The indicator only produces varying output when fed returns, and even then emits states labelled{1, 2}rather than the0/1(bear/bull) a caller would expect. This makes it unusable as a regime filter on price data without undocumented preprocessing.Environment
0.7.01.40.1Minimal reproduction
Actual output
Same behaviour on real data: run against ~4,100 daily closes of the NIFTY 50 index,
hmm_bull_bearreturned1for all 4,106 bars.Expected
0= bear,1= bull). The current{1, 2}labelling (only on returns input) is undocumented and easy to misread.Observations / questions
.taaccessors generally take price/close. If returns are required, that should be stated (and price input should probably error or be internally differenced rather than silently collapsing to a constant).{1, 2}, with2appearing far more often in the downtrend (39 vs 2), suggesting2≈ bear — but this isn't documented.hmm_bull_bearis causal (streaming-safe) for backtesting use.Impact
Discovered while dogfooding quantwave as the compute layer for a momentum backtest, using
hmm_bull_bearas a market-regime gate on NIFTY 50. Because it returned a constant on price input, the gate never triggered defensively; when coerced via returns it fired on ~8% of days and degraded every backtest metric — consistent with an unreliable/constant signal rather than a working regime classifier.Minor, likely-by-design (noting for docs)
stddev(N)uses population std (ddof=0); pandas.std()defaults to ddof=1. TA-Lib convention, but a documented note would prevent parity surprises.roc(N)is scaled ×100 (TA-Lib convention);rocp(N)gives the rawprice/price_N - 1ratio.