#!/usr/bin/env python3
"""
Tactical Scheduler v2 — market-fingerprint-driven strategy fitness scoring.

Replaces static route weights with dynamic, multi-dimensional fitness scores
that adapt to real-time market microstructure.  Every bar cycle:

  1. Extract a 15-dimension market fingerprint from the bars
  2. Score each strategy on 4 axes (direction, timing, regime-fit, anti-fade)
  3. Blend scores → final_weight replaces static route weight
  4. Signal aggregation uses dynamic weights (proportional voting)

Design principle: the market decides which strategy fights, not hardcoded config.
"""

from __future__ import annotations

import math
from typing import Any, Dict, List, Optional

# ── strategy profiles: what each strategy needs to succeed ──────────

STRATEGY_PROFILES: Dict[str, Dict[str, Any]] = {
    "ema_macd": {
        "family": "trend",
        "min_adx": 25,
        "ideal_adx_min": 30,
        "ideal_adx_max": 65,
        "needs_ema_align": True,
        "hates_low_efficiency": True,
        "hates_ranging": True,
        "trend_stage_penalty": {"LATE": -0.30, "MIDDLE": -0.05},
    },
    "pullback_fade": {
        "family": "trend",
        "min_adx": 22,
        "ideal_pullback_low": 0.38,
        "ideal_pullback_high": 0.55,
        "needs_momentum_contraction": True,
        "hates_strong_breakout": True,
    },
    "adx_breakout": {
        "family": "trend",
        "min_adx": 28,
        "ideal_adx_min": 32,
        "needs_volume_expansion": True,
        "needs_donchian_break": True,
        "hates_ranging": True,
    },
    "wyckoff": {
        "family": "hybrid",
        "min_adx": 18,
        "max_adx": 45,
        "needs_trading_range": True,
        "hates_strong_trend": True,
    },
    "rsi_extreme": {
        "family": "range",
        "max_adx": 28,
        "ideal_rsi_buy": 22,
        "ideal_rsi_sell": 78,
        "needs_extreme_rsi": True,
        "hates_strong_trend": True,
    },
    "boll_band": {
        "family": "range",
        "max_adx": 25,
        "needs_bb_touch": True,
        "ideal_bb_position_buy": 0.05,
        "ideal_bb_position_sell": 0.95,
        "hates_narrow_bands": True,
    },
    "key_level": {
        "family": "range",
        "max_adx": 30,
        "needs_level_nearby": True,
        "max_level_distance_atr": 0.5,
        "hates_strong_breakout": True,
    },
    "grid_osc": {
        "family": "range",
        "max_adx": 22,
        "ideal_atr_ratio_max": 1.2,
        "needs_clear_range": True,
        "hates_volatility_spike": True,
    },
    "composite": {
        "family": "meta",
        "needs_sub_consensus": True,
        "consensus_bonus": 0.10,
        "conflict_penalty": 0.30,
    },
}


# ── market fingerprint extraction ──────────────────────────────────

def extract_market_fingerprint(
    bars: list[dict],
    *,
    session_bucket: str = "NORMAL",
    indicators: Optional[dict] = None,
    regime_label: str = "UNKNOWN",
) -> Dict[str, Any]:
    """Extract 15-dimension market fingerprint from OHLCV bars.

    Returns a flat dict of scalar features that every strategy scorer can consume.
    """
    ind = indicators or {}
    n = len(bars)
    if n < 20:
        return {"_stale": True, "_bars": n}

    closes = [float(b.get("close", 0) or 0) for b in bars]
    highs = [float(b.get("high", 0) or 0) for b in bars]
    lows = [float(b.get("low", 0) or 0) for b in bars]
    volumes = [float(b.get("tick_volume", 0) or 0) for b in bars]
    price = closes[-1]

    def _ema(series, period):
        k = 2.0 / (period + 1)
        out = [series[0]]
        for v in series[1:]:
            out.append(v * k + out[-1] * (1 - k))
        return out

    def _mean(vals):
        return sum(vals) / len(vals) if vals else 0.0

    # ── trend axis ──
    tr = [max(h - l, abs(h - closes[i - 1]) if i > 0 else h - l, abs(l - closes[i - 1]) if i > 0 else h - l)
          for i, (h, l) in enumerate(zip(highs, lows))]
    atr14 = _mean(tr[-14:]) if len(tr) >= 14 else _mean(tr)
    atr50 = _mean(tr[-50:]) if len(tr) >= 50 else atr14
    atr_ratio = atr14 / max(0.01, price)

    plus_dm, minus_dm = [], []
    for i in range(1, len(highs)):
        up = highs[i] - highs[i - 1]
        down = lows[i - 1] - lows[i]
        plus_dm.append(up if up > down and up > 0 else 0)
        minus_dm.append(down if down > up and down > 0 else 0)
    atr_smooth = _ema(tr, 14)
    pdi = _ema([pd / max(a, 0.0001) * 100 for pd, a in zip(plus_dm, atr_smooth[1:])], 14) if plus_dm else [25]
    mdi = _ema([md / max(a, 0.0001) * 100 for md, a in zip(minus_dm, atr_smooth[1:])], 14) if minus_dm else [25]
    dx_vals = [abs(p - m) / max(p + m, 0.01) * 100 for p, m in zip(pdi, mdi)]
    adx_vals = _ema(dx_vals, 14) if dx_vals else [20]
    adx = adx_vals[-1]
    adx_slope = adx_vals[-1] - adx_vals[-6] if len(adx_vals) >= 6 else 0
    di_diff = pdi[-1] - mdi[-1]

    e9 = _ema(closes, 9)
    e21 = _ema(closes, 21)
    e50 = _ema(closes, 50)
    bullish_align = e9[-1] > e21[-1] > e50[-1]
    bearish_align = e9[-1] < e21[-1] < e50[-1]
    ema_aligned = bullish_align or bearish_align

    # ── volatility axis ──
    atr_expansion = atr14 / max(0.01, atr50)
    daily_range = (max(highs[-72:]) - min(lows[-72:])) if len(highs) >= 72 else (max(highs) - min(lows))
    bb_center = _mean(closes[-20:])
    bb_std = (sum((c - bb_center) ** 2 for c in closes[-20:]) / 20) ** 0.5
    bb_upper = bb_center + 2 * bb_std
    bb_lower = bb_center - 2 * bb_std
    bb_width = (bb_upper - bb_lower) / max(0.01, price)
    bb_position = (price - bb_lower) / max(0.01, bb_upper - bb_lower) if bb_upper > bb_lower else 0.5
    bar_range_ratio = _mean([(h - l) / max(0.01, c) for h, l, c in zip(highs[-5:], lows[-5:], closes[-5:])])
    prev_bar_range = _mean([(h - l) / max(0.01, c) for h, l, c in zip(highs[-10:-5], lows[-10:-5], closes[-10:-5])])

    # ── position axis ──
    rsi = float(ind.get("rsi14", 50) or 50)
    rsi_change = rsi - float(ind.get("rsi14_prev", 50) or 50)

    # ── momentum axis ──
    avg_vol_20 = _mean(volumes[-20:]) if len(volumes) >= 20 else _mean(volumes)
    recent_vol = _mean(volumes[-5:]) if len(volumes) >= 5 else avg_vol_20
    vol_expanding = recent_vol > avg_vol_20 * 1.3
    vol_contracting = recent_vol < avg_vol_20 * 0.7

    def _efficiency_ratio(series, window):
        if len(series) < window:
            return 0.5
        direction = abs(series[-1] - series[-window])
        volatility = sum(abs(series[i] - series[i - 1]) for i in range(len(series) - window, len(series)))
        return direction / max(0.01, volatility)

    efficiency = _efficiency_ratio(closes, 12)

    # donchian
    dc_high = max(highs[-20:])
    dc_low = min(lows[-20:])
    dc_breakout_up = price > dc_high * 0.998
    dc_breakout_down = price < dc_low * 1.002

    # pullback depth from recent swing
    swing_high = max(highs[-20:])
    swing_low = min(lows[-20:])
    swing_range = swing_high - swing_low
    pullback_depth = (swing_high - price) / max(0.01, swing_range) if swing_range > 0 else 0.5

    # macd histogram change
    ema12 = _ema(closes, 12)
    ema26 = _ema(closes, 26)
    macd = [e12 - e26 for e12, e26 in zip(ema12, ema26)]
    signal = _ema(macd, 9)
    macd_hist = macd[-1] - signal[-1]
    macd_hist_prev = macd[-2] - signal[-2] if len(macd) >= 2 else 0
    macd_hist_change = macd_hist - macd_hist_prev

    # consecutive directional bars
    same_dir = 0
    if len(closes) >= 10:
        dir_sign = 1 if closes[-1] > closes[-2] else -1
        for i in range(2, min(11, len(closes))):
            if (closes[-i + 1] - closes[-i]) * dir_sign > 0:
                same_dir += 1
            else:
                break

    return {
        "_stale": False,
        "_bars": n,
        "_session": str(session_bucket or "NORMAL").upper(),
        "_regime": str(regime_label or "UNKNOWN").upper(),
        # trend
        "adx": round(adx, 1),
        "adx_slope": round(adx_slope, 2),
        "di_diff": round(di_diff, 1),
        "ema_aligned": ema_aligned,
        "bullish_align": bullish_align,
        "bearish_align": bearish_align,
        "efficiency": round(efficiency, 3),
        "same_dir_bars": same_dir,
        # volatility
        "atr_ratio": round(atr_ratio, 6),
        "atr_expansion": round(atr_expansion, 3),
        "bb_width": round(bb_width, 4),
        "bb_position": round(bb_position, 3),
        "bar_range_ratio": round(bar_range_ratio, 6),
        "prev_bar_range": round(prev_bar_range, 6),
        "momentum_contracting": bar_range_ratio < prev_bar_range * 0.8,
        # position
        "rsi": round(rsi, 1),
        "rsi_change": round(rsi_change, 2),
        # momentum
        "vol_expanding": vol_expanding,
        "vol_contracting": vol_contracting,
        "dc_breakout_up": dc_breakout_up,
        "dc_breakout_down": dc_breakout_down,
        "macd_hist_change": round(macd_hist_change, 6),
        "pullback_depth": round(pullback_depth, 3),
    }


# ── per-strategy scoring ──────────────────────────────────────────

def score_strategy_fitness(
    strategy_id: str,
    fp: Dict[str, Any],
    *,
    route_base_weight: float = 0.10,
    session_bucket: str = "NORMAL",
    sub_consensus: float = 0.0,
) -> Dict[str, Any]:
    """Score a single strategy's fitness for current market conditions.

    Returns {final_weight, direction_score, timing_score, regime_fit, anti_fade, breakdown}
    """
    if fp.get("_stale"):
        return _dead_score(route_base_weight, "stale_data")

    profile = STRATEGY_PROFILES.get(strategy_id, {})
    family = profile.get("family", "unknown")
    adx = fp["adx"]
    session = str(session_bucket or "NORMAL").upper()

    # ── direction_score: does the strategy align with current market direction? ──
    direction_score = 0.50  # neutral baseline
    reasons: list[str] = []

    if family == "trend":
        if fp["ema_aligned"]:
            direction_score += 0.20
            reasons.append("ema_aligned")
        if abs(fp["di_diff"]) >= 8:
            direction_score += 0.15
            reasons.append(f"di_diff={fp['di_diff']:.1f}")
        if fp["efficiency"] >= 0.35:
            direction_score += 0.10
            reasons.append("efficiency_ok")
        if fp["same_dir_bars"] >= 6:
            direction_score += 0.05
            reasons.append("stealth_trend")
    elif family == "range":
        if not fp["ema_aligned"]:
            direction_score += 0.15
            reasons.append("ema_not_aligned")
        if abs(fp["di_diff"]) < 5:
            direction_score += 0.15
            reasons.append("di_neutral")
        if fp["efficiency"] < 0.30:
            direction_score += 0.10
            reasons.append("low_efficiency")
        if 0.00025 <= fp["atr_ratio"] <= 0.0015:
            direction_score += 0.10
            reasons.append("moderate_atr")
    elif family == "hybrid":
        direction_score += 0.10
        reasons.append("hybrid_flexible")
    elif family == "meta":
        direction_score += 0.05
        if sub_consensus > 0:
            direction_score += sub_consensus * 0.20
            reasons.append(f"sub_consensus={sub_consensus:.2f}")

    direction_score = max(0.05, min(0.95, direction_score))

    # ── timing_score: is this the right moment for the strategy? ──
    timing_score = 0.50

    # adx window checks
    min_adx = profile.get("min_adx")
    max_adx = profile.get("max_adx")
    ideal_adx_min = profile.get("ideal_adx_min")
    ideal_adx_max = profile.get("ideal_adx_max")

    if min_adx is not None and adx < min_adx:
        timing_score -= 0.15
        reasons.append(f"adx<{min_adx}")
    elif max_adx is not None and adx > max_adx:
        timing_score -= 0.10
        reasons.append(f"adx>{max_adx}")
    elif ideal_adx_min is not None and adx >= ideal_adx_min:
        timing_score += 0.10
        reasons.append(f"adx_ideal>={ideal_adx_min}")

    if ideal_adx_max is not None and adx > ideal_adx_max:
        timing_score -= 0.05

    # strategy-specific timing
    sid = strategy_id
    if sid == "ema_macd":
        if fp["ema_aligned"]:
            timing_score += 0.15
        if fp["macd_hist_change"] * fp["di_diff"] > 0:
            timing_score += 0.10
    elif sid == "pullback_fade":
        ideal_low = profile.get("ideal_pullback_low", 0.38)
        ideal_high = profile.get("ideal_pullback_high", 0.55)
        if ideal_low <= fp["pullback_depth"] <= ideal_high:
            timing_score += 0.20
            reasons.append("fib_ideal")
        if fp.get("momentum_contracting"):
            timing_score += 0.10
            reasons.append("momentum_contracting")
    elif sid == "adx_breakout":
        if fp["dc_breakout_up"] or fp["dc_breakout_down"]:
            timing_score += 0.20
            reasons.append("donchian_break")
        if fp["vol_expanding"]:
            timing_score += 0.10
            reasons.append("vol_expanding")
    elif sid == "wyckoff":
        if 20 <= adx <= 40:
            timing_score += 0.10
    elif sid == "rsi_extreme":
        rsi = fp["rsi"]
        if rsi <= profile.get("ideal_rsi_buy", 22) or rsi >= profile.get("ideal_rsi_sell", 78):
            timing_score += 0.25
            reasons.append(f"rsi_extreme={rsi}")
    elif sid == "boll_band":
        bb_pos = fp["bb_position"]
        if bb_pos <= 0.10 or bb_pos >= 0.90:
            timing_score += 0.20
            reasons.append("bb_touch")
        if fp["bb_width"] > 0.002:
            timing_score += 0.05
    elif sid == "key_level":
        if fp.get("nearest_support_dist_atr", 999) < profile.get("max_level_distance_atr", 0.5):
            timing_score += 0.20
            reasons.append("level_nearby")
    elif sid == "grid_osc":
        if fp["atr_expansion"] < 1.3:
            timing_score += 0.10
        if adx < 22:
            timing_score += 0.10
    elif sid == "composite":
        if sub_consensus > 0.5:
            timing_score += 0.15
        elif sub_consensus < 0:
            timing_score -= 0.20

    timing_score = max(0.05, min(0.95, timing_score))

    # ── regime_fit: how well does the strategy fit the current regime? ──
    regime_fit = 0.50
    regime = fp["_regime"]

    if family == "trend":
        if regime in ("STRONG_TREND", "WEAK_TREND"):
            regime_fit = 0.90 if regime == "STRONG_TREND" else 0.75
        elif regime == "STEALTH_TREND":
            regime_fit = 0.60
        elif regime == "TRANSITION":
            regime_fit = 0.35
        elif regime == "RANGING":
            regime_fit = 0.15
    elif family == "range":
        if regime == "RANGING":
            regime_fit = 0.90
        elif regime == "TRANSITION":
            regime_fit = 0.50
        elif regime in ("STRONG_TREND", "WEAK_TREND"):
            regime_fit = 0.15
    elif family == "hybrid":
        if regime == "TRANSITION":
            regime_fit = 0.75
        elif regime == "RANGING":
            regime_fit = 0.60
        else:
            regime_fit = 0.45
    elif family == "meta":
        if regime == "TRANSITION":
            regime_fit = 0.85
        else:
            regime_fit = 0.55

    # ── anti_fade: dampen strategies that struggle in the current environment ──
    anti_fade = 1.0
    if profile.get("hates_strong_trend") and regime in ("STRONG_TREND",):
        anti_fade *= 0.30
    if profile.get("hates_ranging") and regime == "RANGING":
        anti_fade *= 0.25
    if profile.get("hates_volatility_spike") and fp["atr_expansion"] >= 1.8:
        anti_fade *= 0.35
    if profile.get("hates_strong_breakout") and fp["dc_breakout_up"]:
        anti_fade *= 0.40
    if profile.get("hates_low_efficiency") and fp["efficiency"] < 0.20:
        anti_fade *= 0.50
    anti_fade = max(0.05, anti_fade)

    # ── session multiplier ──
    session_mult = 1.0
    if family == "trend" and session in ("ASIA", "ASIA_NOISE"):
        session_mult = 0.70
    elif family == "range" and session in ("ASIA",):
        session_mult = 1.10
    elif family == "trend" and session == "OVERLAP":
        session_mult = 1.10

    # ── final weight ──
    final_weight = (
        route_base_weight
        * direction_score
        * timing_score
        * regime_fit
        * anti_fade
        * session_mult
    )

    final_weight = max(0.001, min(0.50, final_weight))

    return {
        "final_weight": round(final_weight, 4),
        "direction_score": round(direction_score, 3),
        "timing_score": round(timing_score, 3),
        "regime_fit": round(regime_fit, 3),
        "anti_fade": round(anti_fade, 3),
        "session_mult": round(session_mult, 2),
        "breakdown": reasons[:8],
    }


def _dead_score(base: float, reason: str) -> Dict[str, Any]:
    return {
        "final_weight": 0.0,
        "direction_score": 0.0,
        "timing_score": 0.0,
        "regime_fit": 0.0,
        "anti_fade": 0.0,
        "session_mult": 0.0,
        "breakdown": [reason],
    }


# ── batch scoring for all active instances ────────────────────────

def score_all_strategies(
    fp: Dict[str, Any],
    *,
    route_weights: Dict[str, float],
    session_bucket: str = "NORMAL",
    sub_consensus_map: Optional[Dict[str, float]] = None,
) -> Dict[str, Dict[str, Any]]:
    """Score every active strategy against the current market fingerprint.

    Args:
        fp: Market fingerprint from extract_market_fingerprint()
        route_weights: {strategy_id: base_weight} from route config
        session_bucket: ASIA/LONDON/NEW_YORK/OVERLAP
        sub_consensus_map: {strategy_id: consensus_strength} (0-1)

    Returns:
        {strategy_id: TacticalScore dict}
    """
    consensus = sub_consensus_map or {}
    results: Dict[str, Dict[str, Any]] = {}
    for sid, base_w in route_weights.items():
        results[sid] = score_strategy_fitness(
            sid,
            fp,
            route_base_weight=base_w,
            session_bucket=session_bucket,
            sub_consensus=consensus.get(sid, 0.0),
        )
    return results
