trade-copilot / markov.py
utkarshpathak48's picture
feat: Markov + HMM market state analysis
cecb83e
Raw
History Blame Contribute Delete
16.8 kB
"""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