Spaces:
Running
Running
| """Markov Chain Market State Analysis — markov.py | |
| Classifies the current market into BULL / BEAR / RANGING using 3 objective | |
| criteria (EMA alignment, RSI zone, volume ratio) on CLOSED candles only. | |
| No lookahead bias: all computations use iloc[:-1] or iloc[-2] as 'current'. | |
| Functions: | |
| classify_state(df) → (state_str, confidence_0_to_1, reasons) | |
| build_transition_matrix(df) → {from_state: {to_state: prob, ...}, ...} | |
| forecast_states(state, matrix, days) → {day: {state: prob, ...}, ...} | |
| persistence_score(matrix, state) → float 0.0–1.0 | |
| analyze_symbol(src, symbol) → full analysis dict | |
| States: "BULL", "BEAR", "RANGING" | |
| """ | |
| from __future__ import annotations | |
| import math, time | |
| from typing import Literal | |
| # ─── State type ────────────────────────────────────────────────────────────── | |
| State = Literal["BULL", "BEAR", "RANGING"] | |
| ALL_STATES: list[State] = ["BULL", "BEAR", "RANGING"] | |
| # ─── Cache: 30-min TTL per symbol ──────────────────────────────────────────── | |
| _mk_cache: dict = {} | |
| MK_TTL = 1800 # 30 minutes — matrix is stable; no need to re-compute every scan | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # CORE: Classify a single candle/row into a state | |
| # Uses only CLOSED data — no lookahead. Call on df.iloc[:-1] for live candle. | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _classify_row(close_series, vol_series, idx: int, lookback: int = 50) -> State: | |
| """Classify state at index `idx` using data UP TO idx (inclusive). | |
| Requires at least `lookback` rows before idx.""" | |
| start = max(0, idx - lookback + 1) | |
| closes = close_series.iloc[start:idx + 1] | |
| vols = vol_series.iloc[start:idx + 1] | |
| if len(closes) < 20: | |
| return "RANGING" | |
| c = closes.values | |
| # ── EMA alignment ──────────────────────────────────────────────────────── | |
| # Fast EMA(20) vs Slow EMA(50) | |
| def _ema(arr, span): | |
| k = 2.0 / (span + 1) | |
| result = [arr[0]] | |
| for v in arr[1:]: | |
| result.append(result[-1] * (1 - k) + v * k) | |
| return result | |
| if len(c) >= 50: | |
| e20 = _ema(c, 20)[-1] | |
| e50 = _ema(c, 50)[-1] | |
| ema_bull = e20 > e50 * 1.001 # 0.1% buffer to avoid noise | |
| ema_bear = e20 < e50 * 0.999 | |
| elif len(c) >= 20: | |
| e20 = _ema(c, 20)[-1] | |
| e10 = _ema(c, 10)[-1] | |
| ema_bull = e10 > e20 * 1.001 | |
| ema_bear = e10 < e20 * 0.999 | |
| else: | |
| ema_bull = ema_bear = False | |
| # ── RSI(14) zone ───────────────────────────────────────────────────────── | |
| rsi_len = min(14, len(c) - 1) | |
| if rsi_len >= 2: | |
| deltas = [c[i] - c[i-1] for i in range(1, len(c))] | |
| gains = [max(d, 0) for d in deltas[-rsi_len:]] | |
| losses = [max(-d, 0) for d in deltas[-rsi_len:]] | |
| avg_g = sum(gains) / rsi_len | |
| avg_l = sum(losses) / rsi_len | |
| if avg_l == 0: | |
| rsi = 100.0 | |
| elif avg_g == 0: | |
| rsi = 0.0 | |
| else: | |
| rs = avg_g / avg_l | |
| rsi = 100.0 - (100.0 / (1.0 + rs)) | |
| else: | |
| rsi = 50.0 | |
| rsi_bull = rsi >= 55 | |
| rsi_bear = rsi <= 45 | |
| # ── Volume ratio (recent 5 vs 20-bar avg) ───────────────────────────────── | |
| if len(vols) >= 20: | |
| vol_avg = float(vols.iloc[-20:].mean()) | |
| vol_recent = float(vols.iloc[-5:].mean()) if len(vols) >= 5 else vol_avg | |
| vol_ratio = vol_recent / vol_avg if vol_avg > 0 else 1.0 | |
| else: | |
| vol_ratio = 1.0 | |
| # Volume above average strengthens the directional signal | |
| vol_confirms = vol_ratio >= 1.10 # 10% above avg = confirming | |
| # ── Vote: majority rule across 3 criteria ───────────────────────────────── | |
| bull_votes = sum([ema_bull, rsi_bull, (vol_confirms and ema_bull)]) | |
| bear_votes = sum([ema_bear, rsi_bear, (vol_confirms and ema_bear)]) | |
| if bull_votes >= 2: | |
| return "BULL" | |
| elif bear_votes >= 2: | |
| return "BEAR" | |
| else: | |
| return "RANGING" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # PUBLIC: classify_state — current market state + confidence | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def classify_state(df) -> tuple[State, float, list[str]]: | |
| """Classify current market state from a klines DataFrame. | |
| Uses only closed candles (iloc[:-1] = last confirmed candle). | |
| Returns (state, confidence_0_to_1, reasons). | |
| """ | |
| reasons = [] | |
| if df is None or len(df) < 22: | |
| return "RANGING", 0.40, ["Insufficient data"] | |
| # Use last CLOSED candle = iloc[-2] for live; for building matrix use all | |
| closes = df["close"] | |
| vols = df["volume"] if "volume" in df.columns else df.get("vol", df["close"] * 0) | |
| # ── EMA alignment on full history (closed candles only = iloc[:-1]) ────── | |
| idx = len(closes) - 2 # last confirmed closed candle | |
| c = closes.values[:idx+1] | |
| def _ema_arr(arr, span): | |
| k = 2.0 / (span + 1) | |
| result = [arr[0]] | |
| for v in arr[1:]: | |
| result.append(result[-1] * (1 - k) + v * k) | |
| return result | |
| e20 = _ema_arr(c, 20)[-1] if len(c) >= 20 else c[-1] | |
| e50 = _ema_arr(c, 50)[-1] if len(c) >= 50 else e20 | |
| ema_bull = e20 > e50 * 1.001 | |
| ema_bear = e20 < e50 * 0.999 | |
| if ema_bull: | |
| reasons.append(f"EMA20 ({e20:.4g}) > EMA50 ({e50:.4g}) — bullish alignment") | |
| elif ema_bear: | |
| reasons.append(f"EMA20 ({e20:.4g}) < EMA50 ({e50:.4g}) — bearish alignment") | |
| else: | |
| reasons.append(f"EMA20 ≈ EMA50 — no trend") | |
| # ── RSI(14) ────────────────────────────────────────────────────────────── | |
| rsi_len = 14 | |
| deltas = [float(c[i]) - float(c[i-1]) for i in range(1, len(c))] | |
| gains = [max(d, 0) for d in deltas[-rsi_len:]] | |
| losses = [max(-d, 0) for d in deltas[-rsi_len:]] | |
| avg_g = sum(gains) / rsi_len | |
| avg_l = sum(losses) / rsi_len | |
| if avg_l == 0: | |
| rsi = 100.0 | |
| elif avg_g == 0: | |
| rsi = 0.0 | |
| else: | |
| rsi = 100.0 - 100.0 / (1.0 + avg_g / avg_l) | |
| rsi_bull = rsi >= 55 | |
| rsi_bear = rsi <= 45 | |
| reasons.append(f"RSI14 = {rsi:.1f} ({'bullish' if rsi_bull else 'bearish' if rsi_bear else 'neutral'})") | |
| # ── Volume ratio ───────────────────────────────────────────────────────── | |
| vol_vals = vols.values[:idx+1] | |
| if len(vol_vals) >= 20: | |
| vol_avg = sum(vol_vals[-20:]) / 20 | |
| vol_recent = sum(vol_vals[-5:]) / 5 if len(vol_vals) >= 5 else vol_avg | |
| vol_ratio = vol_recent / vol_avg if vol_avg > 0 else 1.0 | |
| else: | |
| vol_ratio = 1.0 | |
| vol_up = vol_ratio >= 1.10 | |
| reasons.append(f"Volume ratio {vol_ratio:.2f}× 20-bar avg ({'elevated' if vol_up else 'normal/low'})") | |
| # ── State + confidence ──────────────────────────────────────────────────── | |
| bull_score = sum([ema_bull, rsi_bull, (vol_up and ema_bull)]) | |
| bear_score = sum([ema_bear, rsi_bear, (vol_up and ema_bear)]) | |
| if bull_score >= 2: | |
| state = "BULL" | |
| # Confidence: how strongly all signals agree | |
| confidence = 0.50 + 0.15 * bull_score # 2→0.80, 3→0.95 | |
| elif bear_score >= 2: | |
| state = "BEAR" | |
| confidence = 0.50 + 0.15 * bear_score | |
| else: | |
| state = "RANGING" | |
| # Lower confidence when signals are mixed, higher when cleanly flat | |
| mix = abs(bull_score - bear_score) | |
| confidence = 0.55 if mix == 0 else 0.48 | |
| confidence = round(min(0.95, max(0.35, confidence)), 2) | |
| return state, confidence, reasons | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # PUBLIC: build_transition_matrix — empirical from 500 candles | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def build_transition_matrix(df, lookback: int = 500) -> dict[str, dict[str, float]]: | |
| """Walk through last `lookback` closed candles, classify each, count transitions. | |
| Returns: | |
| { | |
| "BULL": {"BULL": 0.78, "BEAR": 0.08, "RANGING": 0.14}, | |
| "BEAR": {"BULL": 0.09, "BEAR": 0.76, "RANGING": 0.15}, | |
| "RANGING": {"BULL": 0.28, "BEAR": 0.27, "RANGING": 0.45}, | |
| } | |
| """ | |
| closes = df["close"] | |
| vols = df["volume"] if "volume" in df.columns else df.get("vol", df["close"] * 0) | |
| n = len(closes) | |
| # We need at least 50 rows to meaningfully classify; cap lookback | |
| start = max(50, n - lookback) | |
| end = n - 1 # exclude live (open) candle | |
| # Classify each candle | |
| labels: list[State] = [] | |
| for i in range(start, end): | |
| labels.append(_classify_row(closes, vols, i)) | |
| # Count transitions | |
| counts: dict[str, dict[str, int]] = { | |
| s: {t: 0 for t in ALL_STATES} for s in ALL_STATES | |
| } | |
| for i in range(len(labels) - 1): | |
| counts[labels[i]][labels[i+1]] += 1 | |
| # Normalize rows | |
| matrix: dict[str, dict[str, float]] = {} | |
| for state in ALL_STATES: | |
| total = sum(counts[state].values()) | |
| if total == 0: | |
| # No observations: uniform prior | |
| matrix[state] = {s: 1/3 for s in ALL_STATES} | |
| else: | |
| matrix[state] = {s: round(counts[state][s] / total, 4) for s in ALL_STATES} | |
| return matrix | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # PUBLIC: forecast_states — matrix exponentiation for multi-day forecast | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _mat_multiply(A: dict, B: dict) -> dict: | |
| """Multiply two 3×3 dicts-of-dicts.""" | |
| result = {s: {t: 0.0 for t in ALL_STATES} for s in ALL_STATES} | |
| for i in ALL_STATES: | |
| for k in ALL_STATES: | |
| for j in ALL_STATES: | |
| result[i][j] += A[i][k] * B[k][j] | |
| return result | |
| def _mat_pow(M: dict, n: int) -> dict: | |
| """Raise transition matrix M to the nth power.""" | |
| if n <= 0: | |
| # Identity | |
| return {s: {t: (1.0 if s == t else 0.0) for t in ALL_STATES} for s in ALL_STATES} | |
| if n == 1: | |
| return {s: dict(M[s]) for s in ALL_STATES} | |
| half = _mat_pow(M, n // 2) | |
| result = _mat_multiply(half, half) | |
| if n % 2 == 1: | |
| result = _mat_multiply(result, M) | |
| return result | |
| def forecast_states( | |
| current_state: State, | |
| matrix: dict[str, dict[str, float]], | |
| days: list[int] = None, | |
| candles_per_day: int = 24, # 1h candles → 24 per day | |
| ) -> dict[int, dict[str, float]]: | |
| """Forecast probability distribution over states for each horizon. | |
| Args: | |
| current_state: Current state ("BULL", "BEAR", "RANGING") | |
| matrix: Transition matrix from build_transition_matrix() | |
| days: List of forecast horizons in days [1, 3, 7] | |
| candles_per_day: Number of 1h candles per day (24 for 1h bars) | |
| Returns: | |
| {1: {"BULL": 0.74, "BEAR": 0.14, "RANGING": 0.12}, | |
| 3: {"BULL": 0.58, ...}, | |
| 7: {"BULL": 0.45, ...}} | |
| """ | |
| if days is None: | |
| days = [1, 3, 7] | |
| result = {} | |
| for d in days: | |
| steps = d * candles_per_day | |
| Mn = _mat_pow(matrix, steps) | |
| row = Mn[current_state] | |
| result[d] = {s: round(row[s], 3) for s in ALL_STATES} | |
| return result | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # PUBLIC: persistence_score — how sticky is the current state? | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def persistence_score(matrix: dict[str, dict[str, float]], state: State) -> float: | |
| """Self-transition probability = P(state → same state). | |
| Returns 0.0–1.0. High = state tends to persist. | |
| """ | |
| return round(matrix.get(state, {}).get(state, 0.5), 4) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # PUBLIC: analyze_symbol — full Markov analysis for one symbol | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def analyze_symbol(src, symbol: str) -> dict: | |
| """Fetch 1h klines and run full Markov analysis. | |
| Cached for MK_TTL seconds per symbol. | |
| Returns: | |
| { | |
| symbol, state, confidence, reasons, | |
| matrix, forecast, persistence, | |
| candles_used, ts | |
| } | |
| """ | |
| cached = _mk_cache.get(symbol) | |
| if cached and time.time() - cached["ts"] < MK_TTL: | |
| return cached["result"] | |
| try: | |
| df = src.klines(symbol, "1h") | |
| state, conf, reasons = classify_state(df) | |
| matrix = build_transition_matrix(df, lookback=500) | |
| forecast = forecast_states(state, matrix, days=[1, 3, 7]) | |
| persist = persistence_score(matrix, state) | |
| result = { | |
| "symbol": symbol, | |
| "state": state, | |
| "confidence": conf, | |
| "reasons": reasons, | |
| "matrix": matrix, | |
| "forecast": { | |
| "1d": forecast[1], | |
| "3d": forecast[3], | |
| "7d": forecast[7], | |
| }, | |
| "persistence": persist, | |
| "candles_used": min(len(df), 500), | |
| "lookahead_safe": True, # badge for frontend: closed candles only | |
| "ts": time.time(), | |
| } | |
| _mk_cache[symbol] = {"result": result, "ts": time.time()} | |
| return result | |
| except Exception as e: | |
| err = { | |
| "symbol": symbol, | |
| "state": "RANGING", | |
| "confidence": 0.40, | |
| "reasons": [f"Error: {str(e)[:80]}"], | |
| "matrix": {s: {t: 1/3 for t in ALL_STATES} for s in ALL_STATES}, | |
| "forecast": { | |
| "1d": {s: 1/3 for s in ALL_STATES}, | |
| "3d": {s: 1/3 for s in ALL_STATES}, | |
| "7d": {s: 1/3 for s in ALL_STATES}, | |
| }, | |
| "persistence": 0.50, | |
| "candles_used": 0, | |
| "lookahead_safe": True, | |
| "error": str(e)[:80], | |
| "ts": time.time(), | |
| } | |
| _mk_cache[symbol] = {"result": err, "ts": time.time()} | |
| return err | |