#!/usr/bin/env python3
"""
Markov Chain State Engine for Trading

Core mathematical framework from 0xRicker's $1.3M profit article.
Builds transition matrices from price data and provides state-based entry decisions.

Key Insight: Don't predict where price is going. Measure current market state
and probability of next state. Enter only when gap >= eps AND persistence >= tau.

References:
- Transition matrix P where P[i][j] = probability of moving from state i to j
- Entry filter: gap >= 0.05 AND diagonal persistence >= 0.87
- Updated every minute from live data
"""

import numpy as np
from typing import List, Tuple, Optional, Dict, Any
from dataclasses import dataclass
from datetime import datetime
import logging

logger = logging.getLogger(__name__)


@dataclass
class MarkovState:
    """Represents current Markov chain state and metrics."""
    state_id: int
    state_price_range: Tuple[float, float]
    persistence: float  # Diagonal value P[state][state]
    optimal_next_state: int
    transition_probability: float
    should_enter: bool
    arbitrage_gap: float
    timestamp: datetime


class MarkovEngine:
    """
    Markov Chain transition matrix builder and state analyzer.
    
    Core equation from article:
    - Transition matrix P where P[i][j] = probability of moving from state i to j
    - Entry only when: gap >= eps AND persistence >= tau
    
    Parameters:
        n_states: Number of discrete price states (default 10)
        tau: State persistence threshold (default 0.87 from article)
        eps: Arbitrage gap threshold (default 0.05 from article)
        lookback_periods: Number of price points for matrix calculation
    """
    
    def __init__(
        self,
        n_states: int = 10,
        tau: float = 0.87,
        eps: float = 0.05,
        lookback_periods: int = 100
    ):
        self.n_states = n_states
        self.tau = tau
        self.eps = eps
        self.lookback_periods = lookback_periods
        
        # Transition matrix P[i][j] = prob of moving from state i to state j
        self.transition_matrix: Optional[np.ndarray] = None
        
        # Price bounds for state discretization
        self.price_min: float = 0.0
        self.price_max: float = 1.0
        
        # State boundaries
        self.state_boundaries: List[float] = []
        
        # Transition counts (for building matrix)
        self.transition_counts: Optional[np.ndarray] = None
        
        # Current state tracking
        self.current_state: Optional[int] = None
        
        logger.info(f"MarkovEngine initialized: n_states={n_states}, tau={tau}, eps={eps}")
    
    def discretize_price(self, price: float) -> int:
        """
        Convert a price to a discrete state ID.
        
        Args:
            price: Current market price
            
        Returns:
            State ID (0 to n_states-1)
        """
        if not self.state_boundaries:
            raise RuntimeError("State boundaries not initialized. Call fit() first.")
        
        # Find which bin the price falls into
        for i, boundary in enumerate(self.state_boundaries):
            if price <= boundary:
                return i
        
        return self.n_states - 1  # Last state for prices above all boundaries
    
    def get_state_price_range(self, state_id: int) -> Tuple[float, float]:
        """Get the price range for a given state ID."""
        if not self.state_boundaries:
            raise RuntimeError("State boundaries not initialized.")
        
        lower = 0.0 if state_id == 0 else self.state_boundaries[state_id - 1]
        upper = self.state_boundaries[state_id] if state_id < len(self.state_boundaries) else self.price_max
        
        return (lower, upper)
    
    def fit(self, price_history: List[float]) -> None:
        """
        Fit the Markov model to historical price data.
        
        This builds:
        1. State boundaries (price discretization)
        2. Transition matrix P from observed state transitions
        
        Args:
            price_history: List of historical prices (most recent last)
        """
        if len(price_history) < self.lookback_periods:
            logger.warning(f"Insufficient price history: {len(price_history)} < {self.lookback_periods}")
            # Use all available data
            price_history = price_history[-len(price_history):]
        else:
            price_history = price_history[-self.lookback_periods:]
        
        # 1. Calculate price bounds and state boundaries
        self.price_min = min(price_history)
        self.price_max = max(price_history)
        
        # Handle edge case where all prices are the same
        if self.price_max == self.price_min:
            self.price_max = self.price_min * 1.01  # Add 1% range
        
        # Create equal-width state boundaries
        price_range = self.price_max - self.price_min
        self.state_boundaries = [
            self.price_min + (i + 1) * price_range / self.n_states
            for i in range(self.n_states - 1)
        ]
        
        logger.debug(f"Price range: [{self.price_min:.2f}, {self.price_max:.2f}]")
        logger.debug(f"State boundaries: {self.state_boundaries}")
        
        # 2. Convert prices to states
        states = [self.discretize_price(price) for price in price_history]
        
        # 3. Count transitions between states
        self.transition_counts = np.zeros((self.n_states, self.n_states), dtype=np.int64)
        
        for i in range(len(states) - 1):
            from_state = states[i]
            to_state = states[i + 1]
            self.transition_counts[from_state, to_state] += 1
        
        # 4. Normalize to probabilities (add small pseudocount to avoid zeros)
        transition_counts_smooth = self.transition_counts + 1
        
        # Normalize each row to sum to 1
        row_sums = transition_counts_smooth.sum(axis=1, keepdims=True)
        self.transition_matrix = transition_counts_smooth / row_sums
        
        # Set current state to the last observed state
        self.current_state = states[-1] if states else 0
        
        logger.info(f"Transition matrix built from {len(price_history)} price points")
        logger.debug(f"Transition matrix shape: {self.transition_matrix.shape}")
        
        # Log diagonal (persistence) values
        diagonal = np.diag(self.transition_matrix)
        logger.debug(f"State persistence (diagonal): {diagonal}")
    
    def update(self, new_price: float) -> None:
        """
        Update the model with a new price observation.
        
        This incrementally updates the transition matrix with the new observation.
        
        Args:
            new_price: Latest market price
        """
        if self.transition_matrix is None:
            raise RuntimeError("Model not fitted. Call fit() first.")
        
        # Get new state
        new_state = self.discretize_price(new_price)
        
        # Update transition counts
        if self.current_state is not None:
            self.transition_counts[self.current_state, new_state] += 1
            
            # Recalculate probabilities
            transition_counts_smooth = self.transition_counts + 1
            row_sums = transition_counts_smooth.sum(axis=1, keepdims=True)
            self.transition_matrix = transition_counts_smooth / row_sums
        
        # Update current state
        self.current_state = new_state
        
        logger.debug(f"Updated: state {self.current_state}, price {new_price:.2f}")
    
    def get_state_persistence(self, state_id: int) -> float:
        """
        Get the state persistence value (diagonal element).
        
        This measures how likely the market is to stay in its current state.
        
        Args:
            state_id: State ID to check
            
        Returns:
            Persistence value (0.0 to 1.0)
        """
        if self.transition_matrix is None:
            raise RuntimeError("Model not fitted.")
        
        return self.transition_matrix[state_id, state_id]
    
    def get_optimal_next_state(self, current_state: int) -> Tuple[int, float]:
        """
        Find the most likely next state and its probability.
        
        Args:
            current_state: Current state ID
            
        Returns:
            Tuple of (optimal_next_state, transition_probability)
        """
        if self.transition_matrix is None:
            raise RuntimeError("Model not fitted.")
        
        # Find state with highest transition probability
        optimal_next = np.argmax(self.transition_matrix[current_state])
        probability = self.transition_matrix[current_state, optimal_next]
        
        return optimal_next, probability
    
    def should_enter(
        self,
        current_state: Optional[int] = None,
        market_price: Optional[float] = None,
        tau: Optional[float] = None,
        eps: Optional[float] = None
    ) -> MarkovState:
        """
        Core entry filter from 0xRicker's article.
        
        Entry only when BOTH conditions are true:
        1. Arbitrage gap >= eps (model probability vs market price)
        2. State persistence >= tau (diagonal value of transition matrix)
        
        This is the entire decision engine - runs once per minute,
        across every open window, on every asset, 24 hours a day.
        
        Args:
            current_state: Override current state (defaults to tracked state)
            market_price: Current market price for gap calculation
            tau: Override persistence threshold (default: self.tau = 0.87)
            eps: Override gap threshold (default: self.eps = 0.05)
            
        Returns:
            MarkovState with all metrics and entry decision
        """
        if self.transition_matrix is None:
            raise RuntimeError("Model not fitted. Call fit() first.")
        
        # Use provided values or defaults
        tau = tau if tau is not None else self.tau
        eps = eps if eps is not None else self.eps
        
        # Use provided state or tracked state
        state_id = current_state if current_state is not None else self.current_state
        if state_id is None:
            raise RuntimeError("No current state. Call fit() or update() first.")
        
        # Get optimal next state
        optimal_next_state, p_hat = self.get_optimal_next_state(state_id)
        
        # Get state persistence (diagonal value)
        persistence = self.get_state_persistence(optimal_next_state)
        
        # Calculate arbitrage gap
        # p_hat is model's probability of optimal next state
        # market_price represents crowd's implied probability
        if market_price is not None:
            # Normalize market price to 0-1 range for comparison
            market_prob = (market_price - self.price_min) / (self.price_max - self.price_min)
            market_prob = np.clip(market_prob, 0, 1)
            gap = p_hat - market_prob
        else:
            # If no market price, use difference from uniform probability
            gap = p_hat - (1.0 / self.n_states)
        
        # Entry decision: BOTH conditions must be true
        enter = gap >= eps and persistence >= tau
        
        # Get state price range
        state_range = self.get_state_price_range(state_id)
        
        return MarkovState(
            state_id=state_id,
            state_price_range=state_range,
            persistence=persistence,
            optimal_next_state=optimal_next_state,
            transition_probability=p_hat,
            should_enter=enter,
            arbitrage_gap=gap,
            timestamp=datetime.now()
        )
    
    def calculate_expected_return(self, current_state: Optional[int] = None) -> float:
        """
        Calculate expected return from current state.
        
        Uses the formula from article: r = (1 - entry_price) / entry_price
        Adapted for our state-based approach.
        
        Args:
            current_state: State ID (defaults to tracked state)
            
        Returns:
            Expected return as decimal (e.g., 0.10 = 10%)
        """
        if self.transition_matrix is None:
            raise RuntimeError("Model not fitted.")
        
        state_id = current_state if current_state is not None else self.current_state
        if state_id is None:
            raise RuntimeError("No current state.")
        
        # Get optimal next state probability
        _, p_hat = self.get_optimal_next_state(state_id)
        
        # Expected return based on probability edge
        # If model says 90% chance and market implies 80%, edge is 10%
        expected_return = p_hat - (1.0 / self.n_states)
        
        return expected_return
    
    def get_matrix_summary(self) -> Dict:
        """
        Get a summary of the current transition matrix.
        
        Returns:
            Dictionary with matrix statistics
        """
        if self.transition_matrix is None:
            return {"error": "Model not fitted"}
        
        diagonal = np.diag(self.transition_matrix)
        
        return {
            "n_states": self.n_states,
            "price_range": (self.price_min, self.price_max),
            "current_state": self.current_state,
            "diagonal_persistence": diagonal.tolist(),
            "max_persistence": float(np.max(diagonal)),
            "min_persistence": float(np.min(diagonal)),
            "mean_persistence": float(np.mean(diagonal)),
            "states_above_tau": sum(1 for d in diagonal if d >= self.tau),
            "transition_matrix": self.transition_matrix.tolist()
        }
    
    def reset(self) -> None:
        """Reset the engine to initial state."""
        self.transition_matrix = None
        self.transition_counts = None
        self.current_state = None
        self.state_boundaries = []
        self.price_min = 0.0
        self.price_max = 1.0
        logger.info("MarkovEngine reset")
    
    def analyze_state(self, current_price: float) -> Dict[str, Any]:
        """
        Analyze current market state and return comprehensive analysis.
        
        This method is designed to feed into the OpportunityDetector,
        providing all necessary metrics for opportunity detection.
        
        Args:
            current_price: Current market price
            
        Returns:
            Dictionary with all analysis metrics
        """
        # Update model with new price
        self.update(current_price)
        
        # Get state analysis
        state = self.should_enter(market_price=current_price)
        
        # Calculate expected return
        expected_return = self.calculate_expected_return()
        
        # Get market probability (normalized price position)
        if self.price_max > self.price_min:
            market_probability = (current_price - self.price_min) / (self.price_max - self.price_min)
            market_probability = np.clip(market_probability, 0, 1)
        else:
            market_probability = 0.5
        
        return {
            'current_state': state.state_id,
            'state_range': state.state_price_range,
            'persistence': state.persistence,
            'optimal_next_state': state.optimal_next_state,
            'transition_probability': state.transition_probability,
            'arbitrage_gap': state.arbitrage_gap,
            'should_enter': state.should_enter,
            'expected_return_pct': expected_return * 100,  # Convert to percentage
            'current_price': current_price,
            'market_probability': market_probability,
            'tau_threshold': self.tau,
            'eps_threshold': self.eps,
            'price_min': self.price_min,
            'price_max': self.price_max,
        }


# Convenience function matching article's signature exactly
def should_enter(
    P: np.ndarray,
    current_state: int,
    market_price: float,
    tau: float = 0.87,
    eps: float = 0.05
) -> bool:
    """
    Core entry filter from 0xRicker's article.
    
    Entry only when gap AND persistence both clear.
    
    Parameters:
        P: Transition matrix where P[i][j] = prob of i→j
        current_state: Current market state
        market_price: Current market price (normalized 0-1)
        tau: Persistence threshold (default 0.87)
        eps: Gap threshold (default 0.05)
    
    Returns:
        True if both conditions met, False otherwise
    
    Equation references:
        eq.(2.2): gap >= eps
        eq.(2.3): persist >= tau
    """
    # Optimal next state
    j_star = np.argmax(P[current_state])
    
    # Model probability of optimal next state
    p_hat = P[current_state][j_star]
    
    # State persistence (diagonal value)
    persist = P[j_star][j_star]
    
    # Arbitrage gap
    gap = p_hat - market_price
    
    # Both conditions must be true
    return gap >= eps and persist >= tau


if __name__ == "__main__":
    # Test the Markov engine
    import random
    
    # Generate synthetic price data
    random.seed(42)
    prices = [50000 + random.gauss(0, 1000) for _ in range(200)]
    
    # Create and fit engine
    engine = MarkovEngine(n_states=10, tau=0.87, eps=0.05)
    engine.fit(prices)
    
    print("=== Markov Engine Test ===")
    print(f"Price range: [{engine.price_min:.2f}, {engine.price_max:.2f}]")
    print(f"Current state: {engine.current_state}")
    print(f"State price range: {engine.get_state_price_range(engine.current_state)}")
    
    # Get matrix summary
    summary = engine.get_matrix_summary()
    print(f"\nMatrix Summary:")
    print(f"  Max persistence: {summary['max_persistence']:.3f}")
    print(f"  Mean persistence: {summary['mean_persistence']:.3f}")
    print(f"  States above tau (0.87): {summary['states_above_tau']}")
    
    # Test entry decision
    state = engine.should_enter(market_price=prices[-1])
    print(f"\nEntry Decision:")
    print(f"  Should enter: {state.should_enter}")
    print(f"  Persistence: {state.persistence:.3f} (threshold: 0.87)")
    print(f"  Gap: {state.arbitrage_gap:.3f} (threshold: 0.05)")
    print(f"  Optimal next state: {state.optimal_next_state}")
    print(f"  Transition probability: {state.transition_probability:.3f}")
    
    # Test expected return
    expected_return = engine.calculate_expected_return()
    print(f"\nExpected return: {expected_return:.2%}")
