utkarshpathak48 commited on
Commit
cecb83e
·
1 Parent(s): 5167e9a

feat: Markov + HMM market state analysis

Browse files
Files changed (5) hide show
  1. hmm.py +272 -0
  2. markov.py +390 -0
  3. scorer.py +79 -2
  4. signals.py +115 -14
  5. static/index.html +163 -3
hmm.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hidden Markov Model — hmm.py
2
+
3
+ Probabilistic state inference: maps observable market signals
4
+ (price returns, volume ratio, RSI) onto hidden states (BULL, BEAR, RANGING)
5
+ using pre-calibrated emission distributions.
6
+
7
+ No ML training required — emission parameters are hand-calibrated from
8
+ crypto market behaviour (conservative, not curve-fitted).
9
+
10
+ All computations use CLOSED candles only (no lookahead bias).
11
+
12
+ Functions:
13
+ compute_observables(df) → (ret, vol_ratio, rsi) on last closed candle
14
+ emission_prob(state, obs) → P(observables | hidden_state)
15
+ infer_state(df) → (state, confidence_0_to_1, state_probs, reasons)
16
+ hmm_analyze(src, symbol) → full HMM dict (cached 30 min)
17
+ """
18
+ from __future__ import annotations
19
+ import math, time
20
+ from markov import ALL_STATES, State, _mk_cache
21
+
22
+ # ─── Cache: share with markov.py TTL ─────────────────────────────────────────
23
+ _hmm_cache: dict = {}
24
+ HMM_TTL = 1800 # 30 minutes
25
+
26
+
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+ # EMISSION PARAMETERS
29
+ # Each state has a Gaussian(mean, std) for each observable:
30
+ # ret = 1-hour close-to-close return (percent)
31
+ # vol_ratio= recent 5-bar volume / 20-bar avg volume
32
+ # rsi = RSI(14) value 0–100
33
+ #
34
+ # These are calibrated for crypto perp markets on 1h timeframe.
35
+ # Bull: positive returns, elevated volume, RSI 50–70
36
+ # Bear: negative returns, elevated volume, RSI 30–50
37
+ # Ranging: near-zero returns, low volume, RSI near 50
38
+ # ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ EMISSION_PARAMS: dict[str, dict[str, tuple[float, float]]] = {
41
+ # (mean, std) — Gaussian parameters
42
+ "BULL": {
43
+ "ret": ( 0.35, 0.60), # +0.35% avg return per hour, std 0.60%
44
+ "vol_ratio": ( 1.25, 0.45), # Volume 25% above avg
45
+ "rsi": (62.0, 10.0), # RSI typically 55–75
46
+ },
47
+ "BEAR": {
48
+ "ret": (-0.35, 0.60), # negative returns
49
+ "vol_ratio": ( 1.20, 0.45), # elevated (panic selling)
50
+ "rsi": (38.0, 10.0), # RSI 25–50
51
+ },
52
+ "RANGING": {
53
+ "ret": ( 0.00, 0.30), # near-zero returns, tight range
54
+ "vol_ratio": ( 0.80, 0.30), # below-avg volume (no conviction)
55
+ "rsi": (50.0, 8.0), # RSI near 50
56
+ },
57
+ }
58
+
59
+ # Prior probabilities (uniform; adjust if you have regime priors)
60
+ STATE_PRIOR: dict[str, float] = {"BULL": 1/3, "BEAR": 1/3, "RANGING": 1/3}
61
+
62
+
63
+ # ─────────────────────────────────────────────────────────────────────────────
64
+ # HELPERS
65
+ # ─────────────────────────────────────────────────────────────────────────────
66
+
67
+ def _gaussian_pdf(x: float, mu: float, sigma: float) -> float:
68
+ """Gaussian probability density (unnormalized is fine — we normalize later)."""
69
+ if sigma <= 0:
70
+ return 1.0
71
+ z = (x - mu) / sigma
72
+ return math.exp(-0.5 * z * z) # omit 1/(sigma√2π) — cancels in normalization
73
+
74
+
75
+ def _ema_val(arr, span: int) -> float:
76
+ """Compute final EMA value from array."""
77
+ k = 2.0 / (span + 1)
78
+ val = arr[0]
79
+ for v in arr[1:]:
80
+ val = val * (1 - k) + v * k
81
+ return val
82
+
83
+
84
+ # ─────────────────────────────────────────────────────────────────────────────
85
+ # PUBLIC: compute_observables
86
+ # ─────────────────────────────────────────────────────────────────────────────
87
+
88
+ def compute_observables(df) -> tuple[float, float, float]:
89
+ """Extract (ret_pct, vol_ratio, rsi14) from last CLOSED candle.
90
+
91
+ Uses iloc[-2] as last confirmed closed candle (iloc[-1] = live/open candle).
92
+ Returns (ret_pct, vol_ratio, rsi14).
93
+ """
94
+ closes = df["close"].values
95
+ vols = df["volume"].values if "volume" in df.columns else [1.0] * len(df)
96
+
97
+ # Last CLOSED candle = index -2
98
+ if len(closes) < 16:
99
+ return 0.0, 1.0, 50.0
100
+
101
+ idx = len(closes) - 2 # last confirmed
102
+
103
+ # ── 1h return on last closed candle ─────────────────────���────────────────
104
+ prev_close = float(closes[idx - 1])
105
+ curr_close = float(closes[idx])
106
+ ret_pct = ((curr_close - prev_close) / prev_close * 100.0) if prev_close > 0 else 0.0
107
+
108
+ # ── Volume ratio ─────────────────────────────────────────────────────────
109
+ vol_window = vols[max(0, idx-19):idx+1] # up to 20 bars
110
+ if len(vol_window) >= 5:
111
+ vol_avg = sum(vol_window) / len(vol_window)
112
+ vol_recent = sum(vol_window[-5:]) / 5
113
+ vol_ratio = vol_recent / vol_avg if vol_avg > 0 else 1.0
114
+ else:
115
+ vol_ratio = 1.0
116
+
117
+ # ── RSI(14) on closed candles ─────────────────────────────────────────────
118
+ c = [float(v) for v in closes[:idx+1]]
119
+ rsi_len = 14
120
+ if len(c) >= rsi_len + 1:
121
+ deltas = [c[i] - c[i-1] for i in range(1, len(c))]
122
+ gains = [max(d, 0) for d in deltas[-rsi_len:]]
123
+ losses = [max(-d, 0) for d in deltas[-rsi_len:]]
124
+ ag = sum(gains) / rsi_len
125
+ al = sum(losses) / rsi_len
126
+ if al == 0:
127
+ rsi = 100.0
128
+ elif ag == 0:
129
+ rsi = 0.0
130
+ else:
131
+ rsi = 100.0 - 100.0 / (1.0 + ag / al)
132
+ else:
133
+ rsi = 50.0
134
+
135
+ return round(ret_pct, 4), round(vol_ratio, 4), round(rsi, 2)
136
+
137
+
138
+ # ─────────────────────────────────────────────────────────────────────────────
139
+ # PUBLIC: emission_prob
140
+ # ─────────────────────────────────────────────────────────────────────────────
141
+
142
+ def emission_prob(state: str, obs: tuple[float, float, float]) -> float:
143
+ """P(observables | state) — product of independent Gaussian PDFs.
144
+
145
+ obs = (ret_pct, vol_ratio, rsi14)
146
+ Returns unnormalized likelihood (positive float; higher = more likely).
147
+ """
148
+ params = EMISSION_PARAMS.get(state)
149
+ if not params:
150
+ return 1.0
151
+
152
+ ret_pct, vol_ratio, rsi = obs
153
+
154
+ p_ret = _gaussian_pdf(ret_pct, *params["ret"])
155
+ p_vol = _gaussian_pdf(vol_ratio, *params["vol_ratio"])
156
+ p_rsi = _gaussian_pdf(rsi, *params["rsi"])
157
+
158
+ # Product of independent likelihoods
159
+ return p_ret * p_vol * p_rsi
160
+
161
+
162
+ # ─────────────────────────────────────────────────────────────────────────────
163
+ # PUBLIC: infer_state — Bayesian update: posterior ∝ prior × likelihood
164
+ # ─────────────────────────────────────────────────────────────────────────────
165
+
166
+ def infer_state(df) -> tuple[State, float, dict[str, float], list[str]]:
167
+ """Infer hidden market state from observables via Bayes' theorem.
168
+
169
+ P(state | obs) ∝ P(obs | state) × P(state)
170
+
171
+ Returns:
172
+ (state, confidence_0_to_1, state_probabilities_dict, reasons)
173
+ """
174
+ obs = compute_observables(df)
175
+ ret_pct, vol_ratio, rsi = obs
176
+
177
+ reasons = [
178
+ f"1h return: {ret_pct:+.2f}%",
179
+ f"Volume ratio: {vol_ratio:.2f}× 20-bar avg",
180
+ f"RSI(14): {rsi:.1f}",
181
+ ]
182
+
183
+ # Compute posterior for each state
184
+ posteriors: dict[str, float] = {}
185
+ for state in ALL_STATES:
186
+ likelihood = emission_prob(state, obs)
187
+ prior = STATE_PRIOR.get(state, 1/3)
188
+ posteriors[state] = likelihood * prior
189
+
190
+ # Normalize
191
+ total = sum(posteriors.values())
192
+ if total <= 0:
193
+ probs = {s: 1/3 for s in ALL_STATES}
194
+ else:
195
+ probs = {s: round(posteriors[s] / total, 4) for s in ALL_STATES}
196
+
197
+ # Best state
198
+ best_state = max(probs, key=probs.get)
199
+ confidence = probs[best_state]
200
+
201
+ # Human-readable interpretation
202
+ conf_label = "high" if confidence >= 0.65 else "moderate" if confidence >= 0.45 else "low"
203
+ reasons.append(
204
+ f"HMM infers {best_state} with {confidence:.0%} confidence ({conf_label})"
205
+ )
206
+
207
+ # Add runner-up if close
208
+ sorted_states = sorted(probs, key=probs.get, reverse=True)
209
+ runner_up = sorted_states[1]
210
+ if probs[runner_up] >= 0.25:
211
+ reasons.append(f"Alternative: {runner_up} ({probs[runner_up]:.0%}) — mixed signal")
212
+
213
+ return best_state, round(confidence, 3), probs, reasons
214
+
215
+
216
+ # ─────────────────────────────────────────────────────────────────────────────
217
+ # PUBLIC: hmm_analyze — entry point, cached per symbol
218
+ # ─────────────────────────────────────────────────────────────────────────────
219
+
220
+ def hmm_analyze(src, symbol: str) -> dict:
221
+ """Full HMM analysis for one symbol — fetches 1h klines, runs infer_state.
222
+
223
+ Cached for HMM_TTL seconds per symbol.
224
+ Returns:
225
+ {
226
+ symbol, state, confidence,
227
+ state_probs: {BULL: float, BEAR: float, RANGING: float},
228
+ observables: {ret_pct, vol_ratio, rsi},
229
+ reasons, lookahead_safe, ts
230
+ }
231
+ """
232
+ cached = _hmm_cache.get(symbol)
233
+ if cached and time.time() - cached["ts"] < HMM_TTL:
234
+ return cached["result"]
235
+
236
+ try:
237
+ df = src.klines(symbol, "1h")
238
+ state, conf, probs, reasons = infer_state(df)
239
+ obs = compute_observables(df)
240
+
241
+ result = {
242
+ "symbol": symbol,
243
+ "state": state,
244
+ "confidence": conf,
245
+ "state_probs": probs,
246
+ "observables": {
247
+ "ret_pct": obs[0],
248
+ "vol_ratio": obs[1],
249
+ "rsi": obs[2],
250
+ },
251
+ "reasons": reasons,
252
+ "lookahead_safe": True,
253
+ "ts": time.time(),
254
+ }
255
+
256
+ _hmm_cache[symbol] = {"result": result, "ts": time.time()}
257
+ return result
258
+
259
+ except Exception as e:
260
+ err = {
261
+ "symbol": symbol,
262
+ "state": "RANGING",
263
+ "confidence": 0.33,
264
+ "state_probs": {s: 1/3 for s in ALL_STATES},
265
+ "observables": {"ret_pct": 0.0, "vol_ratio": 1.0, "rsi": 50.0},
266
+ "reasons": [f"Error: {str(e)[:80]}"],
267
+ "lookahead_safe": True,
268
+ "error": str(e)[:80],
269
+ "ts": time.time(),
270
+ }
271
+ _hmm_cache[symbol] = {"result": err, "ts": time.time()}
272
+ return err
markov.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Markov Chain Market State Analysis — markov.py
2
+
3
+ Classifies the current market into BULL / BEAR / RANGING using 3 objective
4
+ criteria (EMA alignment, RSI zone, volume ratio) on CLOSED candles only.
5
+ No lookahead bias: all computations use iloc[:-1] or iloc[-2] as 'current'.
6
+
7
+ Functions:
8
+ classify_state(df) → (state_str, confidence_0_to_1, reasons)
9
+ build_transition_matrix(df) → {from_state: {to_state: prob, ...}, ...}
10
+ forecast_states(state, matrix, days) → {day: {state: prob, ...}, ...}
11
+ persistence_score(matrix, state) → float 0.0–1.0
12
+ analyze_symbol(src, symbol) → full analysis dict
13
+
14
+ States: "BULL", "BEAR", "RANGING"
15
+ """
16
+ from __future__ import annotations
17
+ import math, time
18
+ from typing import Literal
19
+
20
+ # ─── State type ──────────────────────────────────────────────────────────────
21
+ State = Literal["BULL", "BEAR", "RANGING"]
22
+ ALL_STATES: list[State] = ["BULL", "BEAR", "RANGING"]
23
+
24
+ # ─── Cache: 30-min TTL per symbol ────────────────────────────────────────────
25
+ _mk_cache: dict = {}
26
+ MK_TTL = 1800 # 30 minutes — matrix is stable; no need to re-compute every scan
27
+
28
+
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+ # CORE: Classify a single candle/row into a state
31
+ # Uses only CLOSED data — no lookahead. Call on df.iloc[:-1] for live candle.
32
+ # ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ def _classify_row(close_series, vol_series, idx: int, lookback: int = 50) -> State:
35
+ """Classify state at index `idx` using data UP TO idx (inclusive).
36
+ Requires at least `lookback` rows before idx."""
37
+ start = max(0, idx - lookback + 1)
38
+ closes = close_series.iloc[start:idx + 1]
39
+ vols = vol_series.iloc[start:idx + 1]
40
+
41
+ if len(closes) < 20:
42
+ return "RANGING"
43
+
44
+ c = closes.values
45
+
46
+ # ── EMA alignment ────────────────────────────────────────────────────────
47
+ # Fast EMA(20) vs Slow EMA(50)
48
+ def _ema(arr, span):
49
+ k = 2.0 / (span + 1)
50
+ result = [arr[0]]
51
+ for v in arr[1:]:
52
+ result.append(result[-1] * (1 - k) + v * k)
53
+ return result
54
+
55
+ if len(c) >= 50:
56
+ e20 = _ema(c, 20)[-1]
57
+ e50 = _ema(c, 50)[-1]
58
+ ema_bull = e20 > e50 * 1.001 # 0.1% buffer to avoid noise
59
+ ema_bear = e20 < e50 * 0.999
60
+ elif len(c) >= 20:
61
+ e20 = _ema(c, 20)[-1]
62
+ e10 = _ema(c, 10)[-1]
63
+ ema_bull = e10 > e20 * 1.001
64
+ ema_bear = e10 < e20 * 0.999
65
+ else:
66
+ ema_bull = ema_bear = False
67
+
68
+ # ── RSI(14) zone ─────────────────────────────────────────────────────────
69
+ rsi_len = min(14, len(c) - 1)
70
+ if rsi_len >= 2:
71
+ deltas = [c[i] - c[i-1] for i in range(1, len(c))]
72
+ gains = [max(d, 0) for d in deltas[-rsi_len:]]
73
+ losses = [max(-d, 0) for d in deltas[-rsi_len:]]
74
+ avg_g = sum(gains) / rsi_len
75
+ avg_l = sum(losses) / rsi_len
76
+ if avg_l == 0:
77
+ rsi = 100.0
78
+ elif avg_g == 0:
79
+ rsi = 0.0
80
+ else:
81
+ rs = avg_g / avg_l
82
+ rsi = 100.0 - (100.0 / (1.0 + rs))
83
+ else:
84
+ rsi = 50.0
85
+
86
+ rsi_bull = rsi >= 55
87
+ rsi_bear = rsi <= 45
88
+
89
+ # ── Volume ratio (recent 5 vs 20-bar avg) ─────────────────────────────────
90
+ if len(vols) >= 20:
91
+ vol_avg = float(vols.iloc[-20:].mean())
92
+ vol_recent = float(vols.iloc[-5:].mean()) if len(vols) >= 5 else vol_avg
93
+ vol_ratio = vol_recent / vol_avg if vol_avg > 0 else 1.0
94
+ else:
95
+ vol_ratio = 1.0
96
+
97
+ # Volume above average strengthens the directional signal
98
+ vol_confirms = vol_ratio >= 1.10 # 10% above avg = confirming
99
+
100
+ # ── Vote: majority rule across 3 criteria ─────────────────────────────────
101
+ bull_votes = sum([ema_bull, rsi_bull, (vol_confirms and ema_bull)])
102
+ bear_votes = sum([ema_bear, rsi_bear, (vol_confirms and ema_bear)])
103
+
104
+ if bull_votes >= 2:
105
+ return "BULL"
106
+ elif bear_votes >= 2:
107
+ return "BEAR"
108
+ else:
109
+ return "RANGING"
110
+
111
+
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+ # PUBLIC: classify_state — current market state + confidence
114
+ # ─────────────────────────────────────────────────────────────────────────────
115
+
116
+ def classify_state(df) -> tuple[State, float, list[str]]:
117
+ """Classify current market state from a klines DataFrame.
118
+
119
+ Uses only closed candles (iloc[:-1] = last confirmed candle).
120
+ Returns (state, confidence_0_to_1, reasons).
121
+ """
122
+ reasons = []
123
+
124
+ if df is None or len(df) < 22:
125
+ return "RANGING", 0.40, ["Insufficient data"]
126
+
127
+ # Use last CLOSED candle = iloc[-2] for live; for building matrix use all
128
+ closes = df["close"]
129
+ vols = df["volume"] if "volume" in df.columns else df.get("vol", df["close"] * 0)
130
+
131
+ # ── EMA alignment on full history (closed candles only = iloc[:-1]) ──────
132
+ idx = len(closes) - 2 # last confirmed closed candle
133
+ c = closes.values[:idx+1]
134
+
135
+ def _ema_arr(arr, span):
136
+ k = 2.0 / (span + 1)
137
+ result = [arr[0]]
138
+ for v in arr[1:]:
139
+ result.append(result[-1] * (1 - k) + v * k)
140
+ return result
141
+
142
+ e20 = _ema_arr(c, 20)[-1] if len(c) >= 20 else c[-1]
143
+ e50 = _ema_arr(c, 50)[-1] if len(c) >= 50 else e20
144
+
145
+ ema_bull = e20 > e50 * 1.001
146
+ ema_bear = e20 < e50 * 0.999
147
+
148
+ if ema_bull:
149
+ reasons.append(f"EMA20 ({e20:.4g}) > EMA50 ({e50:.4g}) — bullish alignment")
150
+ elif ema_bear:
151
+ reasons.append(f"EMA20 ({e20:.4g}) < EMA50 ({e50:.4g}) — bearish alignment")
152
+ else:
153
+ reasons.append(f"EMA20 ≈ EMA50 — no trend")
154
+
155
+ # ── RSI(14) ──────────────────────────────────────────────────────────────
156
+ rsi_len = 14
157
+ deltas = [float(c[i]) - float(c[i-1]) for i in range(1, len(c))]
158
+ gains = [max(d, 0) for d in deltas[-rsi_len:]]
159
+ losses = [max(-d, 0) for d in deltas[-rsi_len:]]
160
+ avg_g = sum(gains) / rsi_len
161
+ avg_l = sum(losses) / rsi_len
162
+ if avg_l == 0:
163
+ rsi = 100.0
164
+ elif avg_g == 0:
165
+ rsi = 0.0
166
+ else:
167
+ rsi = 100.0 - 100.0 / (1.0 + avg_g / avg_l)
168
+
169
+ rsi_bull = rsi >= 55
170
+ rsi_bear = rsi <= 45
171
+ reasons.append(f"RSI14 = {rsi:.1f} ({'bullish' if rsi_bull else 'bearish' if rsi_bear else 'neutral'})")
172
+
173
+ # ── Volume ratio ─────────────────────────────────────────────────────────
174
+ vol_vals = vols.values[:idx+1]
175
+ if len(vol_vals) >= 20:
176
+ vol_avg = sum(vol_vals[-20:]) / 20
177
+ vol_recent = sum(vol_vals[-5:]) / 5 if len(vol_vals) >= 5 else vol_avg
178
+ vol_ratio = vol_recent / vol_avg if vol_avg > 0 else 1.0
179
+ else:
180
+ vol_ratio = 1.0
181
+ vol_up = vol_ratio >= 1.10
182
+ reasons.append(f"Volume ratio {vol_ratio:.2f}× 20-bar avg ({'elevated' if vol_up else 'normal/low'})")
183
+
184
+ # ── State + confidence ────────────────────────────────────────────────────
185
+ bull_score = sum([ema_bull, rsi_bull, (vol_up and ema_bull)])
186
+ bear_score = sum([ema_bear, rsi_bear, (vol_up and ema_bear)])
187
+
188
+ if bull_score >= 2:
189
+ state = "BULL"
190
+ # Confidence: how strongly all signals agree
191
+ confidence = 0.50 + 0.15 * bull_score # 2→0.80, 3→0.95
192
+ elif bear_score >= 2:
193
+ state = "BEAR"
194
+ confidence = 0.50 + 0.15 * bear_score
195
+ else:
196
+ state = "RANGING"
197
+ # Lower confidence when signals are mixed, higher when cleanly flat
198
+ mix = abs(bull_score - bear_score)
199
+ confidence = 0.55 if mix == 0 else 0.48
200
+
201
+ confidence = round(min(0.95, max(0.35, confidence)), 2)
202
+ return state, confidence, reasons
203
+
204
+
205
+ # ─────────────────────────────────────────────────────────────────────────────
206
+ # PUBLIC: build_transition_matrix — empirical from 500 candles
207
+ # ─────────────────────────────────────────────────────────────────────────────
208
+
209
+ def build_transition_matrix(df, lookback: int = 500) -> dict[str, dict[str, float]]:
210
+ """Walk through last `lookback` closed candles, classify each, count transitions.
211
+
212
+ Returns:
213
+ {
214
+ "BULL": {"BULL": 0.78, "BEAR": 0.08, "RANGING": 0.14},
215
+ "BEAR": {"BULL": 0.09, "BEAR": 0.76, "RANGING": 0.15},
216
+ "RANGING": {"BULL": 0.28, "BEAR": 0.27, "RANGING": 0.45},
217
+ }
218
+ """
219
+ closes = df["close"]
220
+ vols = df["volume"] if "volume" in df.columns else df.get("vol", df["close"] * 0)
221
+
222
+ n = len(closes)
223
+ # We need at least 50 rows to meaningfully classify; cap lookback
224
+ start = max(50, n - lookback)
225
+ end = n - 1 # exclude live (open) candle
226
+
227
+ # Classify each candle
228
+ labels: list[State] = []
229
+ for i in range(start, end):
230
+ labels.append(_classify_row(closes, vols, i))
231
+
232
+ # Count transitions
233
+ counts: dict[str, dict[str, int]] = {
234
+ s: {t: 0 for t in ALL_STATES} for s in ALL_STATES
235
+ }
236
+ for i in range(len(labels) - 1):
237
+ counts[labels[i]][labels[i+1]] += 1
238
+
239
+ # Normalize rows
240
+ matrix: dict[str, dict[str, float]] = {}
241
+ for state in ALL_STATES:
242
+ total = sum(counts[state].values())
243
+ if total == 0:
244
+ # No observations: uniform prior
245
+ matrix[state] = {s: 1/3 for s in ALL_STATES}
246
+ else:
247
+ matrix[state] = {s: round(counts[state][s] / total, 4) for s in ALL_STATES}
248
+
249
+ return matrix
250
+
251
+
252
+ # ─────────────────────────────────────────────────────────────────────────────
253
+ # PUBLIC: forecast_states — matrix exponentiation for multi-day forecast
254
+ # ─────────────────────────────────────────────────────────────────────────────
255
+
256
+ def _mat_multiply(A: dict, B: dict) -> dict:
257
+ """Multiply two 3×3 dicts-of-dicts."""
258
+ result = {s: {t: 0.0 for t in ALL_STATES} for s in ALL_STATES}
259
+ for i in ALL_STATES:
260
+ for k in ALL_STATES:
261
+ for j in ALL_STATES:
262
+ result[i][j] += A[i][k] * B[k][j]
263
+ return result
264
+
265
+
266
+ def _mat_pow(M: dict, n: int) -> dict:
267
+ """Raise transition matrix M to the nth power."""
268
+ if n <= 0:
269
+ # Identity
270
+ return {s: {t: (1.0 if s == t else 0.0) for t in ALL_STATES} for s in ALL_STATES}
271
+ if n == 1:
272
+ return {s: dict(M[s]) for s in ALL_STATES}
273
+ half = _mat_pow(M, n // 2)
274
+ result = _mat_multiply(half, half)
275
+ if n % 2 == 1:
276
+ result = _mat_multiply(result, M)
277
+ return result
278
+
279
+
280
+ def forecast_states(
281
+ current_state: State,
282
+ matrix: dict[str, dict[str, float]],
283
+ days: list[int] = None,
284
+ candles_per_day: int = 24, # 1h candles → 24 per day
285
+ ) -> dict[int, dict[str, float]]:
286
+ """Forecast probability distribution over states for each horizon.
287
+
288
+ Args:
289
+ current_state: Current state ("BULL", "BEAR", "RANGING")
290
+ matrix: Transition matrix from build_transition_matrix()
291
+ days: List of forecast horizons in days [1, 3, 7]
292
+ candles_per_day: Number of 1h candles per day (24 for 1h bars)
293
+
294
+ Returns:
295
+ {1: {"BULL": 0.74, "BEAR": 0.14, "RANGING": 0.12},
296
+ 3: {"BULL": 0.58, ...},
297
+ 7: {"BULL": 0.45, ...}}
298
+ """
299
+ if days is None:
300
+ days = [1, 3, 7]
301
+
302
+ result = {}
303
+ for d in days:
304
+ steps = d * candles_per_day
305
+ Mn = _mat_pow(matrix, steps)
306
+ row = Mn[current_state]
307
+ result[d] = {s: round(row[s], 3) for s in ALL_STATES}
308
+
309
+ return result
310
+
311
+
312
+ # ─────────────────────────────────────────────────────────────────────────────
313
+ # PUBLIC: persistence_score — how sticky is the current state?
314
+ # ─────────────────────────────────────────────────────────────────────────────
315
+
316
+ def persistence_score(matrix: dict[str, dict[str, float]], state: State) -> float:
317
+ """Self-transition probability = P(state → same state).
318
+
319
+ Returns 0.0–1.0. High = state tends to persist.
320
+ """
321
+ return round(matrix.get(state, {}).get(state, 0.5), 4)
322
+
323
+
324
+ # ─────────────────────────────────────────────────────────────────────────────
325
+ # PUBLIC: analyze_symbol — full Markov analysis for one symbol
326
+ # ─────────────────────────────────────────────────────────────────────────────
327
+
328
+ def analyze_symbol(src, symbol: str) -> dict:
329
+ """Fetch 1h klines and run full Markov analysis.
330
+
331
+ Cached for MK_TTL seconds per symbol.
332
+ Returns:
333
+ {
334
+ symbol, state, confidence, reasons,
335
+ matrix, forecast, persistence,
336
+ candles_used, ts
337
+ }
338
+ """
339
+ cached = _mk_cache.get(symbol)
340
+ if cached and time.time() - cached["ts"] < MK_TTL:
341
+ return cached["result"]
342
+
343
+ try:
344
+ df = src.klines(symbol, "1h")
345
+
346
+ state, conf, reasons = classify_state(df)
347
+ matrix = build_transition_matrix(df, lookback=500)
348
+ forecast = forecast_states(state, matrix, days=[1, 3, 7])
349
+ persist = persistence_score(matrix, state)
350
+
351
+ result = {
352
+ "symbol": symbol,
353
+ "state": state,
354
+ "confidence": conf,
355
+ "reasons": reasons,
356
+ "matrix": matrix,
357
+ "forecast": {
358
+ "1d": forecast[1],
359
+ "3d": forecast[3],
360
+ "7d": forecast[7],
361
+ },
362
+ "persistence": persist,
363
+ "candles_used": min(len(df), 500),
364
+ "lookahead_safe": True, # badge for frontend: closed candles only
365
+ "ts": time.time(),
366
+ }
367
+
368
+ _mk_cache[symbol] = {"result": result, "ts": time.time()}
369
+ return result
370
+
371
+ except Exception as e:
372
+ err = {
373
+ "symbol": symbol,
374
+ "state": "RANGING",
375
+ "confidence": 0.40,
376
+ "reasons": [f"Error: {str(e)[:80]}"],
377
+ "matrix": {s: {t: 1/3 for t in ALL_STATES} for s in ALL_STATES},
378
+ "forecast": {
379
+ "1d": {s: 1/3 for s in ALL_STATES},
380
+ "3d": {s: 1/3 for s in ALL_STATES},
381
+ "7d": {s: 1/3 for s in ALL_STATES},
382
+ },
383
+ "persistence": 0.50,
384
+ "candles_used": 0,
385
+ "lookahead_safe": True,
386
+ "error": str(e)[:80],
387
+ "ts": time.time(),
388
+ }
389
+ _mk_cache[symbol] = {"result": err, "ts": time.time()}
390
+ return err
scorer.py CHANGED
@@ -11,6 +11,14 @@ from exchange import pick_source, INTERVAL_MS
11
  from catalyst import score_catalyst as _catalyst_score
12
  import time
13
 
 
 
 
 
 
 
 
 
14
 
15
  TIMEFRAMES = ["1h", "15m", "5m"]
16
  KLINE_LIMIT = 500
@@ -417,6 +425,11 @@ def _estimate_duration(tf_data: dict, levels: dict) -> dict:
417
  }
418
 
419
 
 
 
 
 
 
420
  def _narrative(symbol, direction, confidence, struct_notes, vol_notes,
421
  pos_notes, tf_data) -> str:
422
  m15 = tf_data.get("15m", {})
@@ -499,12 +512,55 @@ def score_symbol(src, symbol: str,
499
  atr_pct, sizing)
500
  duration = _estimate_duration(tf_data, levels)
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  return {
503
  "symbol": symbol,
504
  "tv_symbol": src.tv_symbol(symbol),
505
  "direction": direction,
506
- "confidence": confidence,
507
- "narrative": _narrative(symbol, direction, confidence,
 
508
  n_struct, n_vol, n_pos, tf_data),
509
  "close": close,
510
  "levels": levels,
@@ -520,6 +576,27 @@ def score_symbol(src, symbol: str,
520
  "leverage": leverage,
521
  # ── Duration estimate ──
522
  "duration": duration,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  "evidence": {
524
  "structure": {"score": round(s_struct * 10, 1), "notes": n_struct},
525
  "volume": {"score": round(s_vol * 10, 1), "notes": n_vol},
 
11
  from catalyst import score_catalyst as _catalyst_score
12
  import time
13
 
14
+ # Markov + HMM — imported lazily to avoid startup errors if not yet installed
15
+ try:
16
+ from markov import analyze_symbol as _markov_analyze
17
+ from hmm import hmm_analyze as _hmm_analyze
18
+ _MARKOV_AVAILABLE = True
19
+ except ImportError:
20
+ _MARKOV_AVAILABLE = False
21
+
22
 
23
  TIMEFRAMES = ["1h", "15m", "5m"]
24
  KLINE_LIMIT = 500
 
425
  }
426
 
427
 
428
+ def direction_to_state(direction: str) -> str:
429
+ """Map trade direction to expected market state."""
430
+ return "BULL" if direction == "long" else "BEAR" if direction == "short" else "RANGING"
431
+
432
+
433
  def _narrative(symbol, direction, confidence, struct_notes, vol_notes,
434
  pos_notes, tf_data) -> str:
435
  m15 = tf_data.get("15m", {})
 
512
  atr_pct, sizing)
513
  duration = _estimate_duration(tf_data, levels)
514
 
515
+ # ── Markov + HMM state analysis ──────────────────────────────────────
516
+ markov_data = None
517
+ hmm_data = None
518
+ if _MARKOV_AVAILABLE:
519
+ try:
520
+ markov_data = _markov_analyze(src, symbol)
521
+ time.sleep(0.05)
522
+ except Exception:
523
+ pass
524
+ try:
525
+ hmm_data = _hmm_analyze(src, symbol)
526
+ except Exception:
527
+ pass
528
+
529
+ # ── State confidence gate: if HMM confidence < 60%, apply rank penalty ─
530
+ # Low-confidence state → market is ambiguous → penalise the overall score
531
+ hmm_state = hmm_data.get("state", "RANGING") if hmm_data else "RANGING"
532
+ hmm_confidence = hmm_data.get("confidence", 0.5) if hmm_data else 0.5
533
+ markov_state = markov_data.get("state", "RANGING") if markov_data else "RANGING"
534
+ markov_conf = markov_data.get("confidence", 0.5) if markov_data else 0.5
535
+ persistence = markov_data.get("persistence", 0.5) if markov_data else 0.5
536
+ forecast = markov_data.get("forecast", {}) if markov_data else {}
537
+
538
+ # States must agree with trade direction; penalise if conflicting
539
+ state_aligned = True
540
+ if direction == "long" and hmm_state == "BEAR": state_aligned = False
541
+ if direction == "short" and hmm_state == "BULL": state_aligned = False
542
+
543
+ # Confidence modifier: ranges from -1.5 to +1.0 on the 10-point scale
544
+ if hmm_confidence < 0.45:
545
+ state_modifier = -1.5 # very uncertain — penalise hard
546
+ elif hmm_confidence < 0.60:
547
+ state_modifier = -0.5 # moderate uncertainty
548
+ elif not state_aligned:
549
+ state_modifier = -1.0 # state opposes trade direction
550
+ elif hmm_state == direction_to_state(direction) and hmm_confidence >= 0.70:
551
+ state_modifier = +1.0 # state strongly confirms direction
552
+ else:
553
+ state_modifier = 0.0
554
+
555
+ confidence_adjusted = round(max(0.0, min(10.0, confidence + state_modifier)), 1)
556
+
557
  return {
558
  "symbol": symbol,
559
  "tv_symbol": src.tv_symbol(symbol),
560
  "direction": direction,
561
+ "confidence": confidence_adjusted,
562
+ "confidence_raw": confidence,
563
+ "narrative": _narrative(symbol, direction, confidence_adjusted,
564
  n_struct, n_vol, n_pos, tf_data),
565
  "close": close,
566
  "levels": levels,
 
576
  "leverage": leverage,
577
  # ── Duration estimate ──
578
  "duration": duration,
579
+ # ── Market State (Markov + HMM) ──
580
+ "market_state": {
581
+ "markov": {
582
+ "state": markov_state,
583
+ "confidence": markov_conf,
584
+ "persistence": persistence,
585
+ "forecast": forecast,
586
+ "reasons": markov_data.get("reasons", []) if markov_data else [],
587
+ "lookahead_safe": True,
588
+ } if markov_data else None,
589
+ "hmm": {
590
+ "state": hmm_state,
591
+ "confidence": hmm_confidence,
592
+ "state_probs": hmm_data.get("state_probs", {}) if hmm_data else {},
593
+ "observables": hmm_data.get("observables", {}) if hmm_data else {},
594
+ "reasons": hmm_data.get("reasons", []) if hmm_data else [],
595
+ "lookahead_safe": True,
596
+ } if hmm_data else None,
597
+ "aligned_with_trade": state_aligned,
598
+ "state_modifier": state_modifier,
599
+ },
600
  "evidence": {
601
  "structure": {"score": round(s_struct * 10, 1), "notes": n_struct},
602
  "volume": {"score": round(s_vol * 10, 1), "notes": n_vol},
signals.py CHANGED
@@ -17,6 +17,14 @@ import math, statistics, time
17
  from indicators import analyze_timeframe, ema, rma, atr as calc_atr, swing_pivots
18
  from catalyst import fetch_fear_greed, fetch_coin_news
19
 
 
 
 
 
 
 
 
 
20
  # ── Cache: signal results per symbol, 5 min TTL ──────────────────────────────
21
  _sig_cache: dict = {} # {symbol: {"result": dict, "ts": float}}
22
  SIG_TTL = 300
@@ -232,11 +240,80 @@ def _detect_news_spike(symbol: str) -> tuple[int, str, list[str]]:
232
  # MAIN ENTRY — scan one symbol
233
  # ─────────────────────────────────────────────────────────────────────────────
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  MOVE_LABELS = {
236
- "breakout": "Breakout",
237
- "acceleration": "Trend Acceleration",
238
- "reversal": "Reversal",
239
- "news": "News Spike",
 
240
  }
241
 
242
  def scan_symbol(src, symbol: str, secondary=None) -> dict | None:
@@ -286,9 +363,30 @@ def scan_symbol(src, symbol: str, secondary=None) -> dict | None:
286
  s_acc, d_acc, r_acc = _detect_acceleration(tf_data, funding)
287
  s_rev, d_rev, r_rev = _detect_reversal(df_1h, tf_data)
288
  s_news,d_news,r_news= _detect_news_spike(symbol)
 
 
 
 
289
 
290
- total_score = s_bo + s_acc + s_rev + s_news
291
- detectors_hit = sum(1 for s in (s_bo, s_acc, s_rev, s_news) if s > 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
 
293
  # ── Fire threshold: score ≥ 6 AND ≥ 2 detectors ──────────────────
294
  if total_score < 6 or detectors_hit < 2:
@@ -297,7 +395,7 @@ def scan_symbol(src, symbol: str, secondary=None) -> dict | None:
297
 
298
  # ── Determine dominant direction ──────────────────────────────────
299
  dir_votes: dict[str, int] = {}
300
- for d, s in [(d_bo, s_bo), (d_acc, s_acc), (d_rev, s_rev), (d_news, s_news)]:
301
  if d != "neutral" and s > 0:
302
  dir_votes[d] = dir_votes.get(d, 0) + s
303
  direction = max(dir_votes, key=dir_votes.get) if dir_votes else "neutral"
@@ -308,9 +406,10 @@ def scan_symbol(src, symbol: str, secondary=None) -> dict | None:
308
  if s_acc > 0: move_types.append("acceleration")
309
  if s_rev > 0: move_types.append("reversal")
310
  if s_news > 0: move_types.append("news")
 
311
 
312
  # ── All reasons combined ──────────────────────────────────────────
313
- all_reasons = r_bo + r_acc + r_rev + r_news
314
 
315
  m15_data = tf_data.get("15m", {})
316
  close = m15_data.get("close", 0)
@@ -330,14 +429,16 @@ def scan_symbol(src, symbol: str, secondary=None) -> dict | None:
330
  "move_types": move_types,
331
  "urgency": urgency,
332
  "total_score": total_score,
333
- "max_score": 12,
334
  "detectors": {
335
- "breakout": {"score": s_bo, "reasons": r_bo},
336
- "acceleration": {"score": s_acc, "reasons": r_acc},
337
- "reversal": {"score": s_rev, "reasons": r_rev},
338
- "news": {"score": s_news, "reasons": r_news},
 
339
  },
340
- "reasons": all_reasons,
 
341
  "close": close,
342
  "rsi14": m15_data.get("rsi14"),
343
  "atr_pct": m15_data.get("atr_pct"),
 
17
  from indicators import analyze_timeframe, ema, rma, atr as calc_atr, swing_pivots
18
  from catalyst import fetch_fear_greed, fetch_coin_news
19
 
20
+ # Markov + HMM optional — gracefully skip if unavailable
21
+ try:
22
+ from markov import analyze_symbol as _markov_analyze
23
+ from hmm import hmm_analyze as _hmm_analyze
24
+ _MK_AVAILABLE = True
25
+ except ImportError:
26
+ _MK_AVAILABLE = False
27
+
28
  # ── Cache: signal results per symbol, 5 min TTL ──────────────────────────────
29
  _sig_cache: dict = {} # {symbol: {"result": dict, "ts": float}}
30
  SIG_TTL = 300
 
240
  # MAIN ENTRY — scan one symbol
241
  # ─────────────────────────────────────────────────────────────────────────────
242
 
243
+ # ─────────────────────────────────────────────────────────────────────────────
244
+ # DETECTOR 5 — STATE TRANSITION SPIKE (Markov + HMM)
245
+ # Fires when HMM confidence is high AND Markov transition probability
246
+ # from current state to ANOTHER state in 1 day is elevated (>30%)
247
+ # ─────────────────────────────────────────────────────────────────────────────
248
+
249
+ def _detect_state_transition(src, symbol: str) -> tuple[int, str, list[str]]:
250
+ """Score 0–3. Returns (score, direction, reasons).
251
+
252
+ Uses Markov matrix to spot: will the current regime likely flip soon?
253
+ High transition prob → anticipate the coming move direction.
254
+ """
255
+ if not _MK_AVAILABLE:
256
+ return 0, "neutral", []
257
+
258
+ reasons = []
259
+ score = 0
260
+ direction = "neutral"
261
+
262
+ try:
263
+ mk = _markov_analyze(src, symbol)
264
+ hmm = _hmm_analyze(src, symbol)
265
+
266
+ state = mk.get("state", "RANGING")
267
+ persist = mk.get("persistence", 0.5)
268
+ conf = hmm.get("confidence", 0.5)
269
+ forecast_1d = mk.get("forecast", {}).get("1d", {})
270
+
271
+ if not forecast_1d:
272
+ return 0, "neutral", []
273
+
274
+ # High HMM confidence + low persistence = regime change imminent
275
+ if conf >= 0.65 and persist < 0.60:
276
+ score += 1
277
+ reasons.append(
278
+ f"State persistence {persist:.0%} — {state} regime showing cracks"
279
+ )
280
+
281
+ # Check if a different state has rising 1d probability
282
+ sorted_states = sorted(forecast_1d, key=forecast_1d.get, reverse=True)
283
+ top_state = sorted_states[0]
284
+ second_state = sorted_states[1]
285
+
286
+ # If the current state is NOT the most probable 1d state → transition likely
287
+ if top_state != state and forecast_1d[top_state] >= 0.45:
288
+ score += 1
289
+ reasons.append(
290
+ f"Markov 1d forecast: {top_state} {forecast_1d[top_state]:.0%} "
291
+ f"(currently {state}) — transition probable"
292
+ )
293
+ direction = "long" if top_state == "BULL" else "short" if top_state == "BEAR" else "neutral"
294
+
295
+ # Very high confidence of transitioning out
296
+ if state != "BULL" and forecast_1d.get("BULL", 0) >= 0.50:
297
+ score += 1
298
+ direction = "long"
299
+ reasons.append(f"BULL state probability {forecast_1d['BULL']:.0%} in 24h — momentum shift")
300
+ elif state != "BEAR" and forecast_1d.get("BEAR", 0) >= 0.50:
301
+ score += 1
302
+ direction = "short"
303
+ reasons.append(f"BEAR state probability {forecast_1d['BEAR']:.0%} in 24h — breakdown risk")
304
+
305
+ except Exception:
306
+ pass
307
+
308
+ return min(score, 3), direction, reasons
309
+
310
+
311
  MOVE_LABELS = {
312
+ "breakout": "Breakout",
313
+ "acceleration": "Trend Acceleration",
314
+ "reversal": "Reversal",
315
+ "news": "News Spike",
316
+ "state_transition": "Regime Change",
317
  }
318
 
319
  def scan_symbol(src, symbol: str, secondary=None) -> dict | None:
 
363
  s_acc, d_acc, r_acc = _detect_acceleration(tf_data, funding)
364
  s_rev, d_rev, r_rev = _detect_reversal(df_1h, tf_data)
365
  s_news,d_news,r_news= _detect_news_spike(symbol)
366
+ s_st, d_st, r_st = _detect_state_transition(src, symbol)
367
+
368
+ total_score = s_bo + s_acc + s_rev + s_news + s_st
369
+ detectors_hit = sum(1 for s in (s_bo, s_acc, s_rev, s_news, s_st) if s > 0)
370
 
371
+ # ── HMM confidence boost: high-confidence state confirmation adds 1pt ─
372
+ hmm_boost = 0
373
+ hmm_state_info = None
374
+ if _MK_AVAILABLE:
375
+ try:
376
+ hmm_out = _hmm_analyze(src, symbol)
377
+ hmm_conf = hmm_out.get("confidence", 0.5)
378
+ hmm_state_txt = hmm_out.get("state", "RANGING")
379
+ if hmm_conf >= 0.70:
380
+ hmm_boost = 1
381
+ hmm_state_info = {
382
+ "state": hmm_state_txt,
383
+ "confidence": hmm_conf,
384
+ "state_probs": hmm_out.get("state_probs", {}),
385
+ }
386
+ r_st = r_st + [f"HMM confidence {hmm_conf:.0%} — {hmm_state_txt} state confirmed"]
387
+ except Exception:
388
+ pass
389
+ total_score += hmm_boost
390
 
391
  # ── Fire threshold: score ≥ 6 AND ≥ 2 detectors ──────────────────
392
  if total_score < 6 or detectors_hit < 2:
 
395
 
396
  # ── Determine dominant direction ──────────────────────────────────
397
  dir_votes: dict[str, int] = {}
398
+ for d, s in [(d_bo, s_bo), (d_acc, s_acc), (d_rev, s_rev), (d_news, s_news), (d_st, s_st)]:
399
  if d != "neutral" and s > 0:
400
  dir_votes[d] = dir_votes.get(d, 0) + s
401
  direction = max(dir_votes, key=dir_votes.get) if dir_votes else "neutral"
 
406
  if s_acc > 0: move_types.append("acceleration")
407
  if s_rev > 0: move_types.append("reversal")
408
  if s_news > 0: move_types.append("news")
409
+ if s_st > 0: move_types.append("state_transition")
410
 
411
  # ── All reasons combined ──────────────────────────────────────────
412
+ all_reasons = r_bo + r_acc + r_rev + r_news + r_st
413
 
414
  m15_data = tf_data.get("15m", {})
415
  close = m15_data.get("close", 0)
 
429
  "move_types": move_types,
430
  "urgency": urgency,
431
  "total_score": total_score,
432
+ "max_score": 16, # 5 detectors × 3 + 1 HMM boost
433
  "detectors": {
434
+ "breakout": {"score": s_bo, "reasons": r_bo},
435
+ "acceleration": {"score": s_acc, "reasons": r_acc},
436
+ "reversal": {"score": s_rev, "reasons": r_rev},
437
+ "news": {"score": s_news, "reasons": r_news},
438
+ "state_transition": {"score": s_st, "reasons": r_st},
439
  },
440
+ "hmm_state": hmm_state_info,
441
+ "reasons": all_reasons,
442
  "close": close,
443
  "rsi14": m15_data.get("rsi14"),
444
  "atr_pct": m15_data.get("atr_pct"),
static/index.html CHANGED
@@ -1022,6 +1022,71 @@ nav {
1022
  .dur-lbl { font-size:9px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); margin-bottom:1px; }
1023
  .dur-val { font-size:15px; font-weight:700; font-family:var(--font-display); color:var(--t1); line-height:1.2; }
1024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
  /* ── Actions ── */
1026
  .card-acts { display:flex; gap:7px; margin-top:16px; }
1027
  .ca {
@@ -1318,6 +1383,13 @@ nav {
1318
  <div class="empty-p">Hit Scan Market to surface top trade setups</div>
1319
  </div>
1320
 
 
 
 
 
 
 
 
1321
  <div id="grid"></div>
1322
  </section>
1323
 
@@ -1548,6 +1620,24 @@ async function tickPrices() {
1548
  function startLiveTicker() { clearInterval(liveTickerTimer); liveTickerTimer=setInterval(tickPrices,5000); }
1549
  function stopLiveTicker() { clearInterval(liveTickerTimer); liveCards.clear(); }
1550
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1551
  /* ═══ Scan buttons ═══ */
1552
  $('scan-btn').addEventListener('click', runScan);
1553
  $('hero-cta').addEventListener('click', ()=>{
@@ -1590,6 +1680,12 @@ async function runScan() {
1590
  if(!data.cards.length) { empty.style.display='flex'; return; }
1591
 
1592
  const maxHours=+($('max-hours')?.value||0);
 
 
 
 
 
 
1593
  let visibleCount=0;
1594
  data.cards.forEach((c,i)=>{
1595
  // Max hours filter — hide cards whose min estimate exceeds the filter
@@ -1598,6 +1694,11 @@ async function runScan() {
1598
  // If we have a duration estimate and it exceeds the filter, skip
1599
  if(durMax!=null && durMax>maxHours) return;
1600
  }
 
 
 
 
 
1601
  visibleCount++;
1602
  const el=buildCard(c,account,risk);
1603
  el.style.animationDelay=(i*60)+'ms';
@@ -1637,6 +1738,7 @@ function buildCard(c, account, risk) {
1637
  const ev=c.evidence||{}, tf=c.timeframes||{};
1638
  const ri=c.rank_info||{};
1639
  const prob=c.probability||{}, lev=c.leverage||{}, dur=c.duration||{};
 
1640
 
1641
  const sym=c.symbol||'';
1642
  const hasDash=sym.includes('-');
@@ -1754,6 +1856,57 @@ function buildCard(c, account, risk) {
1754
  </div>
1755
  </div>`:'';
1756
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1757
  let narr=(c.narrative||'');
1758
  if(narr.length>210) narr=narr.slice(0,207)+'…';
1759
 
@@ -1819,7 +1972,7 @@ function buildCard(c, account, risk) {
1819
  <div class="sz-item"><div class="sz-lbl">Notional</div><div class="sz-val">${fu(sz.notional)}</div></div>
1820
  </div>
1821
 
1822
- ${probHTML}${levHTML}${durHTML}
1823
  ${narr?`<div class="narr">${narr}</div>`:''}
1824
  <div class="rule"></div>
1825
 
@@ -1870,7 +2023,8 @@ function buildCard(c, account, risk) {
1870
 
1871
  /* ═══ Signal cards ═══ */
1872
  const MOVE_LABELS = {
1873
- breakout:'Breakout', acceleration:'Trend Accel.', reversal:'Reversal', news:'News Spike'
 
1874
  };
1875
 
1876
  function buildSignalCard(a) {
@@ -1896,12 +2050,18 @@ function buildSignalCard(a) {
1896
  return `<div class="sig-reason ${cls}">${r}</div>`;
1897
  }).join('');
1898
 
 
 
 
 
 
 
1899
  const el = document.createElement('div');
1900
  el.className = `sig-card urgency-${urgency}`;
1901
  el.innerHTML = `
1902
  <div class="sig-header">
1903
  <div>
1904
- <div class="sig-sym">${base}<span class="q">${quote}</span></div>
1905
  <div style="font-size:10px;color:var(--t3);margin-top:2px;font-weight:600">${urgencyLabel}</div>
1906
  </div>
1907
  <span class="sig-dir ${dir}">${dir==='long'?'▲ LONG':dir==='short'?'▼ SHORT':'— WATCH'}</span>
 
1022
  .dur-lbl { font-size:9px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); margin-bottom:1px; }
1023
  .dur-val { font-size:15px; font-weight:700; font-family:var(--font-display); color:var(--t1); line-height:1.2; }
1024
 
1025
+ /* ── Market State strip (Markov + HMM) ── */
1026
+ .state-strip {
1027
+ background:rgba(255,255,255,0.55); border:1px solid rgba(0,0,0,0.06);
1028
+ border-radius:12px; padding:9px 12px; margin-bottom:10px;
1029
+ }
1030
+ .state-strip-top {
1031
+ display:flex; align-items:center; gap:8px; margin-bottom:6px;
1032
+ }
1033
+ .state-lbl { font-size:9px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); flex:1; }
1034
+ .state-trust {
1035
+ font-size:9px; font-weight:700; color:var(--teal); background:var(--teal3);
1036
+ border:1px solid rgba(13,148,136,0.18); border-radius:5px; padding:2px 6px;
1037
+ letter-spacing:0.3px;
1038
+ }
1039
+ .state-badges { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
1040
+ .state-badge {
1041
+ display:inline-flex; align-items:center; gap:4px;
1042
+ font-size:12px; font-weight:800; padding:3px 10px; border-radius:8px;
1043
+ letter-spacing:-0.2px; font-family:var(--font-display);
1044
+ }
1045
+ .state-badge.BULL { color:var(--profit); background:rgba(209,250,229,0.7); border:1px solid rgba(5,150,105,0.2); }
1046
+ .state-badge.BEAR { color:var(--loss); background:rgba(254,226,226,0.7); border:1px solid rgba(220,38,38,0.2); }
1047
+ .state-badge.RANGING { color:var(--warn); background:rgba(254,243,199,0.7); border:1px solid rgba(217,119,6,0.2); }
1048
+ .state-conf { font-size:10px; font-weight:600; color:var(--t3); }
1049
+ .state-conflict { font-size:10px; font-weight:600; color:var(--loss); margin-top:3px; }
1050
+
1051
+ /* ── Forecast row inside state strip ── */
1052
+ .forecast-row {
1053
+ display:flex; gap:8px; margin-top:8px;
1054
+ }
1055
+ .fc-cell {
1056
+ flex:1; background:rgba(0,0,0,0.03); border-radius:8px; padding:5px 6px; text-align:center;
1057
+ }
1058
+ .fc-horizon { font-size:9px; font-weight:800; letter-spacing:0.5px; text-transform:uppercase; color:var(--t3); margin-bottom:3px; }
1059
+ .fc-bars { display:flex; flex-direction:column; gap:2px; }
1060
+ .fc-bar-row { display:flex; align-items:center; gap:3px; }
1061
+ .fc-bar-lbl { font-size:8px; font-weight:700; color:var(--t3); width:16px; text-align:right; }
1062
+ .fc-bar-track { flex:1; height:5px; background:rgba(0,0,0,0.06); border-radius:3px; overflow:hidden; }
1063
+ .fc-bar-fill { height:100%; border-radius:3px; transition:width 0.8s var(--ease); }
1064
+ .fc-bar-fill.BULL { background:var(--profit2); }
1065
+ .fc-bar-fill.BEAR { background:var(--loss2); }
1066
+ .fc-bar-fill.RANGING { background:var(--warn2); }
1067
+ .fc-bar-pct { font-size:8px; font-weight:700; color:var(--t2); width:22px; font-variant-numeric:tabular-nums; }
1068
+
1069
+ /* ── State filter toggle ── */
1070
+ .state-filter-bar {
1071
+ display:flex; align-items:center; gap:8px; margin-bottom:12px;
1072
+ padding:8px 12px; background:rgba(255,255,255,0.6); border:1px solid rgba(0,0,0,0.06);
1073
+ border-radius:12px; backdrop-filter:blur(8px);
1074
+ }
1075
+ .state-filter-lbl { font-size:10px; font-weight:800; letter-spacing:0.6px; text-transform:uppercase; color:var(--t3); }
1076
+ .state-filter-toggle {
1077
+ display:inline-flex; align-items:center; gap:5px;
1078
+ background:rgba(255,255,255,0.9); border:1px solid rgba(0,0,0,0.07);
1079
+ border-radius:8px; padding:4px 10px;
1080
+ font-size:11px; font-weight:700; color:var(--t2); cursor:pointer;
1081
+ transition:all 0.2s;
1082
+ }
1083
+ .state-filter-toggle.active {
1084
+ background:var(--teal3); border-color:rgba(13,148,136,0.3); color:var(--teal);
1085
+ }
1086
+
1087
+ /* Signal type badge for state_transition */
1088
+ .sig-type-badge.state_transition { background:rgba(109,40,217,0.12); color:var(--purple2); }
1089
+
1090
  /* ── Actions ── */
1091
  .card-acts { display:flex; gap:7px; margin-top:16px; }
1092
  .ca {
 
1383
  <div class="empty-p">Hit Scan Market to surface top trade setups</div>
1384
  </div>
1385
 
1386
+ <div id="state-filter-bar" class="state-filter-bar" style="display:none">
1387
+ <span class="state-filter-lbl">🧠 State Filter</span>
1388
+ <button id="state-filter-toggle" class="state-filter-toggle" title="Only show cards where HMM state confidence ≥ 60%">
1389
+ Show all states
1390
+ </button>
1391
+ </div>
1392
+
1393
  <div id="grid"></div>
1394
  </section>
1395
 
 
1620
  function startLiveTicker() { clearInterval(liveTickerTimer); liveTickerTimer=setInterval(tickPrices,5000); }
1621
  function stopLiveTicker() { clearInterval(liveTickerTimer); liveCards.clear(); }
1622
 
1623
+ /* ═══ State filter toggle ═══ */
1624
+ const _stateToggle = $('state-filter-toggle');
1625
+ if (_stateToggle) {
1626
+ _stateToggle.addEventListener('click', () => {
1627
+ const isActive = _stateToggle.classList.toggle('active');
1628
+ _stateToggle.textContent = isActive
1629
+ ? '✓ HMM confidence ≥ 60% only'
1630
+ : 'Show all states';
1631
+ // Re-run scan to apply filter (grid already cleared each scan, so trigger new scan)
1632
+ // Just show a note — user needs to re-scan for filter to take effect
1633
+ if (isActive) {
1634
+ _stateToggle.title = 'Filter active — hit Scan Market to apply';
1635
+ } else {
1636
+ _stateToggle.title = 'Only show cards where HMM state confidence ≥ 60%';
1637
+ }
1638
+ });
1639
+ }
1640
+
1641
  /* ═══ Scan buttons ═══ */
1642
  $('scan-btn').addEventListener('click', runScan);
1643
  $('hero-cta').addEventListener('click', ()=>{
 
1680
  if(!data.cards.length) { empty.style.display='flex'; return; }
1681
 
1682
  const maxHours=+($('max-hours')?.value||0);
1683
+ const stateFilterOn = $('state-filter-toggle')?.classList.contains('active') || false;
1684
+
1685
+ // Show state filter bar now that we have data
1686
+ const sfBar = $('state-filter-bar');
1687
+ if (sfBar) sfBar.style.display = 'flex';
1688
+
1689
  let visibleCount=0;
1690
  data.cards.forEach((c,i)=>{
1691
  // Max hours filter — hide cards whose min estimate exceeds the filter
 
1694
  // If we have a duration estimate and it exceeds the filter, skip
1695
  if(durMax!=null && durMax>maxHours) return;
1696
  }
1697
+ // State confidence filter — only show cards where HMM confidence >= 60%
1698
+ if(stateFilterOn){
1699
+ const hmmConf = c.market_state?.hmm?.confidence || 0;
1700
+ if(hmmConf > 0 && hmmConf < 0.60) return;
1701
+ }
1702
  visibleCount++;
1703
  const el=buildCard(c,account,risk);
1704
  el.style.animationDelay=(i*60)+'ms';
 
1738
  const ev=c.evidence||{}, tf=c.timeframes||{};
1739
  const ri=c.rank_info||{};
1740
  const prob=c.probability||{}, lev=c.leverage||{}, dur=c.duration||{};
1741
+ const ms=c.market_state||{};
1742
 
1743
  const sym=c.symbol||'';
1744
  const hasDash=sym.includes('-');
 
1856
  </div>
1857
  </div>`:'';
1858
 
1859
+ // ── Market State block (Markov + HMM) ──
1860
+ let stateHTML = '';
1861
+ const mkData = ms.markov, hmmData = ms.hmm;
1862
+ if (mkData || hmmData) {
1863
+ const bestState = hmmData?.state || mkData?.state || 'RANGING';
1864
+ const bestConf = hmmData?.confidence || mkData?.confidence || 0.5;
1865
+ const confPctState = Math.round(bestConf * 100);
1866
+ const aligned = ms.aligned_with_trade !== false;
1867
+ const conflictNote = !aligned
1868
+ ? `<div class="state-conflict">⚠ State conflicts with trade direction — lower conviction</div>` : '';
1869
+
1870
+ // Markov + HMM badges
1871
+ let badges = '';
1872
+ if (hmmData) badges += `<span class="state-badge ${hmmData.state}" title="HMM: probabilistic inference">HMM · ${hmmData.state} <span class="state-conf">${Math.round(hmmData.confidence*100)}%</span></span>`;
1873
+ if (mkData) badges += `<span class="state-badge ${mkData.state}" title="Markov: state + transition matrix">MK · ${mkData.state} <span class="state-conf">${Math.round(mkData.confidence*100)}%</span></span>`;
1874
+
1875
+ // Forecast bars for 1d, 3d, 7d
1876
+ let forecastHTML = '';
1877
+ const forecast = mkData?.forecast || {};
1878
+ if (Object.keys(forecast).length > 0) {
1879
+ const horizons = [['1d','1 Day'], ['3d','3 Day'], ['7d','7 Day']];
1880
+ forecastHTML = `<div class="forecast-row">` + horizons.map(([key, label]) => {
1881
+ const f = forecast[key] || {};
1882
+ return `<div class="fc-cell">
1883
+ <div class="fc-horizon">${label}</div>
1884
+ <div class="fc-bars">
1885
+ ${['BULL','BEAR','RANGING'].map(s => {
1886
+ const pct = Math.round((f[s]||0)*100);
1887
+ return `<div class="fc-bar-row">
1888
+ <span class="fc-bar-lbl">${s[0]}</span>
1889
+ <div class="fc-bar-track"><div class="fc-bar-fill ${s}" style="width:${pct}%"></div></div>
1890
+ <span class="fc-bar-pct">${pct}%</span>
1891
+ </div>`;
1892
+ }).join('')}
1893
+ </div>
1894
+ </div>`;
1895
+ }).join('') + `</div>`;
1896
+ }
1897
+
1898
+ stateHTML = `
1899
+ <div class="state-strip" data-state="${bestState}" data-conf="${bestConf}">
1900
+ <div class="state-strip-top">
1901
+ <span class="state-lbl">🧠 Market State (Markov + HMM)</span>
1902
+ <span class="state-trust">✓ No lookahead bias</span>
1903
+ </div>
1904
+ <div class="state-badges">${badges}</div>
1905
+ ${conflictNote}
1906
+ ${forecastHTML}
1907
+ </div>`;
1908
+ }
1909
+
1910
  let narr=(c.narrative||'');
1911
  if(narr.length>210) narr=narr.slice(0,207)+'…';
1912
 
 
1972
  <div class="sz-item"><div class="sz-lbl">Notional</div><div class="sz-val">${fu(sz.notional)}</div></div>
1973
  </div>
1974
 
1975
+ ${probHTML}${levHTML}${durHTML}${stateHTML}
1976
  ${narr?`<div class="narr">${narr}</div>`:''}
1977
  <div class="rule"></div>
1978
 
 
2023
 
2024
  /* ═══ Signal cards ═══ */
2025
  const MOVE_LABELS = {
2026
+ breakout:'Breakout', acceleration:'Trend Accel.', reversal:'Reversal',
2027
+ news:'News Spike', state_transition:'Regime Change'
2028
  };
2029
 
2030
  function buildSignalCard(a) {
 
2050
  return `<div class="sig-reason ${cls}">${r}</div>`;
2051
  }).join('');
2052
 
2053
+ // HMM state badge in signal card
2054
+ const hmmSig = a.hmm_state;
2055
+ const hmmBadge = hmmSig
2056
+ ? `<span class="state-badge ${hmmSig.state}" style="font-size:10px;padding:2px 7px;margin-left:4px">HMM·${hmmSig.state} ${Math.round(hmmSig.confidence*100)}%</span>`
2057
+ : '';
2058
+
2059
  const el = document.createElement('div');
2060
  el.className = `sig-card urgency-${urgency}`;
2061
  el.innerHTML = `
2062
  <div class="sig-header">
2063
  <div>
2064
+ <div class="sig-sym">${base}<span class="q">${quote}</span>${hmmBadge}</div>
2065
  <div style="font-size:10px;color:var(--t3);margin-top:2px;font-weight:600">${urgencyLabel}</div>
2066
  </div>
2067
  <span class="sig-dir ${dir}">${dir==='long'?'▲ LONG':dir==='short'?'▼ SHORT':'— WATCH'}</span>