r"""
regime_monitor.py
=================
Macro regime classifier for the Dual FX Macro Model.

Computes a 0-100 score from 4 components and classifies the current macro
environment into EXPANSION / STABLE / COMPRESSION regimes.

USES:
    - data/raw/rates/us2y.csv, de2y.csv, jp2y.csv (rate data)
    - data/logs/live_monitor_v2.log (recent z-score breaches)

CALL SITES:
    - dashboard_server.py: compute_current_regime() called once per refresh
                            to populate data["regime"] in dashboard_data.json
    - One-off calibration: compute_historical_regime_series() to validate
                            score thresholds against backtest signal counts.

The score is DESCRIPTIVE — it characterises the current macro environment.
It is NOT predictive of next-period signal counts and should not be used
to make trading decisions. Its purpose is psychological context: knowing
whether you are in a quiet or active regime helps interpret trade frequency.

Author: Bennett's FX Quant project. Module added 2026-05-12.
"""
from __future__ import annotations

import re
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional

import numpy as np
import pandas as pd

# ──────────────────────────────────────────────────────────────────────
# Paths — robust to running from any working directory
# ──────────────────────────────────────────────────────────────────────
BASE_PATH = Path(__file__).resolve().parents[2]
RATES_DIR = BASE_PATH / "data" / "raw" / "rates"
LOG_FILE  = BASE_PATH / "data" / "logs" / "live_monitor_v2.log"

# ──────────────────────────────────────────────────────────────────────
# Component scoring — calibrated to 23yr percentile distribution.
# Median historical values produce mid scores (~40%); only p90+ outliers
# reach top scores. This ensures EXPANSION is reserved for genuinely
# active periods like 2022 hiking cycle, not normal mid-range macro.
#
# Percentile anchors (from regime_percentiles.py on 260 monthly snapshots):
#   US-DE 2Y |spread|:  p50=1.26, p75=1.88, p90=2.43, p95=2.86
#   US-JP 2Y |spread|:  p50=1.47, p75=3.12, p90=4.18, p95=4.40
#   US-DE 30d vol:      p50=0.032, p75=0.045, p90=0.065, p95=0.074
# ──────────────────────────────────────────────────────────────────────
def score_us_de_spread_level(spread: float) -> int:
    """Score the US-DE 2Y spread |level|. Max 30 points."""
    abs_sp = abs(spread)
    if abs_sp >= 2.80: return 30   # p95+
    if abs_sp >= 2.40: return 26   # p90+
    if abs_sp >= 1.85: return 20   # p75+
    if abs_sp >= 1.25: return 12   # p50+
    if abs_sp >= 0.60: return 6    # p25+
    if abs_sp >= 0.20: return 3    # p10+
    return 0

def score_us_de_vol(vol_30d: float) -> int:
    """Score the 30d std of US-DE 2Y daily changes. Max 20 points."""
    if vol_30d >= 0.074: return 20   # p95+
    if vol_30d >= 0.065: return 18   # p90+
    if vol_30d >= 0.045: return 14   # p75+
    if vol_30d >= 0.032: return 8    # p50+
    if vol_30d >= 0.022: return 4    # p25+
    if vol_30d >= 0.016: return 2    # p10+
    return 0

def score_us_jp_spread_level(spread: float) -> int:
    """Score the US-JP 2Y spread |level|. Max 20 points."""
    abs_sp = abs(spread)
    if abs_sp >= 4.40: return 20   # p95+
    if abs_sp >= 4.20: return 18   # p90+
    if abs_sp >= 3.10: return 14   # p75+
    if abs_sp >= 1.50: return 8    # p50+
    if abs_sp >= 0.50: return 4    # p25+
    if abs_sp >= 0.20: return 2    # p10+
    return 0

def score_realised_breaches(n_breaches_60d: int) -> int:
    """Score by combined (EU+UJ) z-score breaches in last 60 days. Max 30."""
    if n_breaches_60d >= 12: return 30
    if n_breaches_60d >= 9:  return 22
    if n_breaches_60d >= 6:  return 16
    if n_breaches_60d >= 3:  return 8
    if n_breaches_60d >= 1:  return 3
    return 0

# ──────────────────────────────────────────────────────────────────────
# Regime classification — score → label
# Thresholds will be calibrated against historical data in Phase 2.
# ──────────────────────────────────────────────────────────────────────
def classify_regime(score: int) -> dict:
    """Map a 0-100 score to a regime label, colour, and frequency expectation."""
    if score >= 70:
        return {
            "label":      "EXPANSION",
            "indicator":  "🟢",
            "frequency":  "8-12 signals/month combined (~2-3 trades/week)",
            "tone":       "Active macro environment. Signals should fire regularly.",
        }
    if score >= 40:
        return {
            "label":      "STABLE",
            "indicator":  "🟡",
            "frequency":  "4-7 signals/month combined (~1-2 trades/week)",
            "tone":       "Mixed macro environment. Normal signal frequency expected.",
        }
    return {
        "label":      "COMPRESSION",
        "indicator":  "🔴",
        "frequency":  "0-3 signals/month combined (<1 trade/week)",
        "tone":       "Quiet macro environment. Low signal frequency is expected. Patience required.",
    }

# ──────────────────────────────────────────────────────────────────────
# Data loaders
# ──────────────────────────────────────────────────────────────────────
def _load_rate(filename: str, col: str) -> pd.DataFrame:
    path = RATES_DIR / filename
    df = pd.read_csv(path)
    df.columns = [c.lower() for c in df.columns]
    df["date"] = pd.to_datetime(df.iloc[:, 0], errors="coerce")
    df[col]    = pd.to_numeric(df.iloc[:, 1], errors="coerce")
    return df[["date", col]].dropna().sort_values("date").reset_index(drop=True)

def _build_spread_series() -> pd.DataFrame:
    """Build daily US-DE 2Y and US-JP 2Y spread series with forward-filling."""
    us2y = _load_rate("us2y.csv", "us2y")
    de2y = _load_rate("de2y.csv", "de2y")
    jp2y = _load_rate("jp2y.csv", "jp2y")

    start = max(us2y["date"].min(), de2y["date"].min(), jp2y["date"].min())
    end   = min(us2y["date"].max(), de2y["date"].max(), jp2y["date"].max())
    dates = pd.DataFrame({"date": pd.date_range(start, end, freq="D")})

    def asof(df, col):
        return pd.merge_asof(
            dates.astype({"date": "datetime64[us]"}),
            df.astype({"date": "datetime64[us]"}),
            on="date", direction="backward")

    out = asof(us2y, "us2y").merge(asof(de2y, "de2y"), on="date") \
                            .merge(asof(jp2y, "jp2y"), on="date")
    out["us_de_2y"] = out["us2y"] - out["de2y"]
    out["us_jp_2y"] = out["us2y"] - out["jp2y"]
    return out.dropna().reset_index(drop=True)

def _count_breaches_in_log(window_days: int = 60) -> int:
    """Count combined EU+UJ z-score threshold breaches in the live monitor log."""
    if not LOG_FILE.exists():
        return 0
    try:
        cutoff = datetime.now() - timedelta(days=window_days)
        n = 0
        # Match log lines like "2026-04-30 06:01:00,123 [INFO] [USDJPY] Z-score: 3.3230 (threshold ±2.0)"
        pat = re.compile(
            r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d),\d+ \[INFO\] \[(EURUSD|USDJPY)\] '
            r'Z-score: (-?\d+\.\d+) \(threshold ±(\d+\.\d+)\)'
        )
        with open(LOG_FILE, encoding="utf-8", errors="replace") as f:
            for line in f:
                m = pat.match(line)
                if not m:
                    continue
                try:
                    ts = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S")
                except ValueError:
                    continue
                if ts < cutoff:
                    continue
                z = float(m.group(3))
                thresh = float(m.group(4))
                if abs(z) >= thresh:
                    n += 1
        return n
    except Exception:
        return 0

# ──────────────────────────────────────────────────────────────────────
# Main entry point — call this from dashboard_server.py
# ──────────────────────────────────────────────────────────────────────
def compute_current_regime() -> dict:
    """Compute the current macro regime score and classification.

    Returns a dict ready to write into dashboard_data.json["regime"]:
      {
        "score":      <int 0-100>,
        "label":      "EXPANSION" | "STABLE" | "COMPRESSION",
        "indicator":  "🟢" | "🟡" | "🔴",
        "frequency":  "...",
        "tone":       "...",
        "components": {
            "us_de_spread":   {"value": 1.27, "score": 12, "max": 30, "status": "..."},
            "us_de_vol_30d":  {"value": 0.04, "score": 10, "max": 20, "status": "..."},
            "us_jp_spread":   {"value": 2.51, "score": 10, "max": 20, "status": "..."},
            "breaches_60d":   {"value": 9,    "score": 24, "max": 30, "status": "..."},
        },
        "timestamp":  "<ISO>",
      }

    On any failure, returns a graceful fallback dict so the dashboard never breaks.
    """
    try:
        spreads = _build_spread_series()

        # Use the latest available row of spread data
        latest = spreads.iloc[-1]
        us_de_spread = float(latest["us_de_2y"])
        us_jp_spread = float(latest["us_jp_2y"])

        # 30d vol of daily changes in US-DE 2Y spread
        recent = spreads.tail(60).copy()
        recent["us_de_change"] = recent["us_de_2y"].diff()
        vol_30d = float(recent["us_de_change"].tail(30).std())
        if not np.isfinite(vol_30d):
            vol_30d = 0.0

        breaches_60d = _count_breaches_in_log(window_days=60)

        # Score each component
        s_level   = score_us_de_spread_level(us_de_spread)
        s_vol     = score_us_de_vol(vol_30d)
        s_us_jp   = score_us_jp_spread_level(us_jp_spread)
        s_breach  = score_realised_breaches(breaches_60d)
        total     = s_level + s_vol + s_us_jp + s_breach

        regime = classify_regime(total)

        def status_for(score, max_pts):
            pct = score / max_pts
            if pct >= 0.75: return "High"
            if pct >= 0.40: return "Normal"
            if pct >  0:    return "Low"
            return "Very low"

        return {
            "score":      int(total),
            "label":      regime["label"],
            "indicator":  regime["indicator"],
            "frequency":  regime["frequency"],
            "tone":       regime["tone"],
            "components": {
                "us_de_spread":  {"value": round(us_de_spread, 3), "score": s_level,  "max": 30, "status": status_for(s_level, 30),  "unit": "%"},
                "us_de_vol_30d": {"value": round(vol_30d, 4),       "score": s_vol,    "max": 20, "status": status_for(s_vol, 20),    "unit": ""},
                "us_jp_spread":  {"value": round(us_jp_spread, 3), "score": s_us_jp,  "max": 20, "status": status_for(s_us_jp, 20),  "unit": "%"},
                "breaches_60d":  {"value": breaches_60d,            "score": s_breach, "max": 30, "status": status_for(s_breach, 30), "unit": ""},
            },
            "timestamp":  datetime.now().isoformat(),
        }
    except Exception as e:
        return {
            "score":      0,
            "label":      "UNKNOWN",
            "indicator":  "⚪",
            "frequency":  "Unable to compute regime — check rate data and log files.",
            "tone":       f"Regime calculator failed: {e}",
            "components": {},
            "timestamp":  datetime.now().isoformat(),
        }

# ──────────────────────────────────────────────────────────────────────
# Historical calibration — used once to validate score thresholds
# ──────────────────────────────────────────────────────────────────────
def compute_historical_regime_series(
    start_year: int = 2005,
    end_year: int = 2026,
) -> pd.DataFrame:
    """Compute monthly regime scores across history for threshold calibration.

    For each month, this computes what the regime score WOULD have been on
    the first day of the month, using only data available at that point.
    Returns a DataFrame with columns:
      month, us_de_spread, us_de_vol_30d, us_jp_spread, score, label
    """
    spreads = _build_spread_series()
    spreads["us_de_change"] = spreads["us_de_2y"].diff()

    months = pd.date_range(f"{start_year}-01-01", f"{end_year}-12-01", freq="MS")
    rows = []
    for m_start in months:
        snap = spreads[spreads["date"] <= m_start]
        if len(snap) < 60:
            continue
        us_de = float(snap["us_de_2y"].iloc[-1])
        us_jp = float(snap["us_jp_2y"].iloc[-1])
        vol30 = float(snap["us_de_change"].tail(30).std())
        if not np.isfinite(vol30):
            vol30 = 0.0

        s_level  = score_us_de_spread_level(us_de)
        s_vol    = score_us_de_vol(vol30)
        s_us_jp  = score_us_jp_spread_level(us_jp)
        # Breach component skipped historically — log only covers recent live period
        total_no_breach = s_level + s_vol + s_us_jp  # max 70

        rows.append({
            "month":         m_start,
            "us_de_spread":  round(us_de, 3),
            "us_de_vol_30d": round(vol30, 4),
            "us_jp_spread":  round(us_jp, 3),
            "score_no_breach": total_no_breach,
            "label_no_breach": classify_regime(int(total_no_breach * 100 / 70))["label"],
        })
    return pd.DataFrame(rows)

# ──────────────────────────────────────────────────────────────────────
# CLI entry point — for manual testing
# ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    import json, sys
    if len(sys.argv) > 1 and sys.argv[1] == "calibrate":
        df = compute_historical_regime_series()
        print("\nHistorical monthly regime scores (no-breach component):")
        print(df.to_string(index=False))
        print(f"\n{len(df)} months computed.")
        print("\nLabel distribution:")
        print(df["label_no_breach"].value_counts())
    else:
        result = compute_current_regime()
        print(json.dumps(result, indent=2, default=str))
