# Datei: /kleiner/live_scan_engine.py

from __future__ import annotations
from dataclasses import dataclass, asdict
from typing import Literal, Dict, Any, List, Optional, Tuple
from datetime import datetime, timedelta, timezone

# --------------------
# Basis-Filter-Konfiguration (Daytrader20 – Testprofil)
# Später holen wir diese Werte aus filter_config.json
# --------------------

PRICE_MIN = 5.0       # bisher: 5
PRICE_MAX = 1000.0    # bisher: 500

MIN_AVG_VOL_20 = 0   # bisher: 1_000_000
MIN_RVOL = 0             # bisher: 1.3

ATR_MIN = 1.0         # bisher: 2.0
ATR_MAX = 10.0        # bisher: 8.0

Direction = Literal["LONG", "SHORT"]
MarketMode = Literal["TREND_LONG", "TREND_SHORT", "RANGE", "UNKNOWN"]
SetupType = Literal["BREAKOUT", "PULLBACK"]


@dataclass
class MarketContext:
    mode: MarketMode
    label: str = "Unbekannt"
    spy_trend: str = "unknown"
    qqq_trend: str = "unknown"


@dataclass
class TickerSnapshot:
    ticker: str
    price: float
    prev_close: float
    open_price: float

    day_high: float
    day_low: float
    lookback_high: float
    lookback_low: float

    avg_vol_20: float
    today_vol: float
    atr_pct: float

    ema20: float
    ema50: float
    ema200: float
    vwap: float

    mtf_trend: Dict[str, str]  # {"D1": "up/down/sideways", "H1": "...", "M15": "..."}
    data_quality: str = "ok"   # "ok" | "warning"

    # optionale Felder (nicht zwingend nötig für v1)
    curr_candle_body_pct: Optional[float] = None
    curr_candle_upper_wick_pct: Optional[float] = None
    curr_candle_lower_wick_pct: Optional[float] = None


@dataclass
class Signal:
    ticker: str
    direction: Direction
    setup_type: SetupType
    score: float
    is_premium: bool
    crv_tp1: float
    crv_tp2: float

    entry: float
    stop_loss: float
    tp1: float
    tp2: float

    created_at: str
    entry_valid_until: str

    context: Dict[str, Any]
    score_breakdown: Dict[str, float]


# --------------------
# Hilfsfunktionen
# --------------------

def _calc_rvol(snapshot: TickerSnapshot) -> float:
    if snapshot.avg_vol_20 <= 0:
        return 0.0
    return snapshot.today_vol / snapshot.avg_vol_20


def _calc_gap_pct(snapshot: TickerSnapshot) -> float:
    if snapshot.prev_close <= 0:
        return 0.0
    return (snapshot.open_price - snapshot.prev_close) / snapshot.prev_close * 100.0


def _align_trend_label(label: str) -> str:
    """Macht aus 'Aufwärts', 'abwärts', 'seitwärts' etc. ein neutrales 'up/down/sideways'."""
    l = label.lower()
    if "auf" in l or "up" in l:
        return "up"
    if "ab" in l or "down" in l:
        return "down"
    if "seit" in l or "range" in l:
        return "sideways"
    return "unknown"


# --------------------
# Basisfilter gemäss Pflichtenheft
# --------------------

def apply_basic_filters(
    snapshot: TickerSnapshot,
    market_ctx: MarketContext
) -> Tuple[bool, str, Dict[str, Any]]:
    """
    Gibt zurück: (passed, reason, extra_info)
    reason = "" falls passed=True, sonst Erklärung.

    Nutzt die Konfigurationskonstanten:
    PRICE_MIN / PRICE_MAX / MIN_AVG_VOL_20 / MIN_RVOL / ATR_MIN / ATR_MAX
    """
    info: Dict[str, Any] = {}
    price = snapshot.price

    # 1) Kursbereich
    if not (PRICE_MIN <= price <= PRICE_MAX):
        return False, f"Preis {price:.2f} ausserhalb {PRICE_MIN:.0f}-{PRICE_MAX:.0f} USD", info

    # 2) Liquidität: AvgVol20 & RVOL
    rvol = _calc_rvol(snapshot)
    info["rvol"] = rvol
    info["avg_vol_20"] = snapshot.avg_vol_20

    if snapshot.avg_vol_20 < MIN_AVG_VOL_20:
        return False, f"AvgVol20 < {int(MIN_AVG_VOL_20)}", info

    if rvol < MIN_RVOL:
        return False, f"RVOL {rvol:.2f} < {MIN_RVOL:.2f}", info

    # 3) ATR%-Bereich
    if not (ATR_MIN <= snapshot.atr_pct <= ATR_MAX):
        return False, f"ATR% {snapshot.atr_pct:.2f} ausserhalb {ATR_MIN:.1f}-{ATR_MAX:.1f}%", info

        # 4) Datenqualität
    dq = (snapshot.data_quality or "").lower()
    info["data_quality"] = dq
    # Nur sehr schlechte Qualität blocken, "warnung" lassen wir im Test durch
    if dq in ("error", "invalid", "bad", "broken"):
        return False, f"Datenqualität = {snapshot.data_quality}", info

    # 5) Trend-Kopplung Aktie ↔ Markt
    # Im Daytrader20-Test nur als Hinweis, kein harter Filter
    if market_ctx.mode == "TREND_LONG" and price <= snapshot.ema200:
        info["trend_mismatch"] = "markt_long_preis_unter_ema200"
    elif market_ctx.mode == "TREND_SHORT" and price >= snapshot.ema200:
        info["trend_mismatch"] = "markt_short_preis_ueber_ema200"

    # 6) Marktmodus Range/Unknown – im Test nicht blocken, nur vermerken
    if market_ctx.mode in ("RANGE", "UNKNOWN"):
        info["market_mode_note"] = f"defensiv: {market_ctx.mode}"

    # 7) Gap-Puffer nur für Score
    gap_pct = _calc_gap_pct(snapshot)
    info["gap_pct"] = gap_pct

    return True, "", info


# --------------------
# Setup-Erkennung (Breakout & Pullback)
# --------------------

# Setup-Erkennung (Breakout & Pullback)

def detect_setup(
    snapshot: TickerSnapshot,
    market_ctx: MarketContext,
) -> Optional[Tuple[Direction, SetupType, Dict[str, Any]]]:
    """
    Vereinfachte Setup-Erkennung für die Daytrader20-Testphase.

    Idee:
    - Richtung folgt primär dem Marktmodus (TREND_LONG / TREND_SHORT),
      sonst EMA200 (über = LONG, unter = SHORT).
    - Wir unterscheiden:
        * BREAKOUT  = Preis in Nähe des Lookback-Hochs/-Tiefs + über/unter VWAP/EMA20
        * PULLBACK  = Trendfolge über/unter EMA20/EMA200
    - Wenn nichts passt, gibt es einen Fallback-PULLBACK, damit wir
      in der Testphase trotzdem Signale sehen.
    """

    price = snapshot.price
    day_high = snapshot.day_high
    day_low = snapshot.day_low
    lb_high = snapshot.lookback_high
    lb_low = snapshot.lookback_low
    ema20 = snapshot.ema20
    ema50 = snapshot.ema50
    ema200 = snapshot.ema200
    vwap = snapshot.vwap

    meta: Dict[str, Any] = {}

    # 1) Richtung bestimmen
    if market_ctx.mode == "TREND_LONG":
        direction: Direction = "LONG"
    elif market_ctx.mode == "TREND_SHORT":
        direction = "SHORT"
    else:
        direction = "LONG" if price >= ema200 else "SHORT"

    # 2) Hilfsgrößen (Abstände in %)
    if price > 0:
        dist_to_day_high_pct = (day_high - price) / price * 100.0
        dist_to_day_low_pct = (price - day_low) / price * 100.0
        dist_to_lb_high_pct = (lb_high - price) / price * 100.0
        dist_to_lb_low_pct = (price - lb_low) / price * 100.0
    else:
        dist_to_day_high_pct = dist_to_day_low_pct = 0.0
        dist_to_lb_high_pct = dist_to_lb_low_pct = 0.0

    meta["dist_to_day_high_pct"] = dist_to_day_high_pct
    meta["dist_to_day_low_pct"] = dist_to_day_low_pct
    meta["dist_to_lb_high_pct"] = dist_to_lb_high_pct
    meta["dist_to_lb_low_pct"] = dist_to_lb_low_pct

    # 3) LONG-Setups
    if direction == "LONG":
        # 3a) BREAKOUT nahe Lookback-Hoch
        if (
            lb_high > 0
            and (
                price >= lb_high * 0.995  # innerhalb ~0.5% vom Hoch
                or dist_to_lb_high_pct <= 0.5
            )
            and price >= max(vwap, ema20)
        ):
            meta["setup_label"] = "long_breakout_near_high"
            return "LONG", "BREAKOUT", meta

        # 3b) PULLBACK im Aufwärtstrend (über EMA200, EMA20 >= EMA50)
        if price >= ema200 and price >= ema20 and ema20 >= ema50:
            meta["setup_label"] = "long_trend_pullback"
            return "LONG", "PULLBACK", meta

    # 4) SHORT-Setups
    else:
        # 4a) BREAKOUT (Breakdown) nahe Lookback-Tief
        if (
            lb_low > 0
            and (
                price <= lb_low * 1.005  # innerhalb ~0.5% vom Tief
                or dist_to_lb_low_pct <= 0.5
            )
            and price <= min(vwap, ema20)
        ):
            meta["setup_label"] = "short_breakout_near_low"
            return "SHORT", "BREAKOUT", meta

        # 4b) PULLBACK im Abwärtstrend (unter EMA200, EMA20 <= EMA50)
        if price <= ema200 and price <= ema20 and ema20 <= ema50:
            meta["setup_label"] = "short_trend_pullback"
            return "SHORT", "PULLBACK", meta

    # 5) Fallback: Trend-PULLBACK, damit wir in der Testphase nie None zurückgeben
    meta["setup_label"] = "fallback_trend_setup"
    return direction, "PULLBACK", meta


# --------------------
# Score-System 0–100
# --------------------

def compute_score(
    snapshot: TickerSnapshot,
    market_ctx: MarketContext,
    direction: Direction,
    setup_type: SetupType,
    extra_info: Dict[str, Any]
) -> Tuple[float, Dict[str, float]]:
    """
    Score gemäss Pflichtenheft, grob in 7 Komponenten:
    - Marktmodus + Ticker-Trend
    - Volumen & RVOL
    - ATR%-Sweetspot
    - VWAP-/EMA-Lage
    - Setup-Qualität (Breakout/Pullback)
    - Gap-Kontext
    - Candle-Qualität
    """
    breakdown: Dict[str, float] = {}

    # --- 1) Marktmodus + Trend (max 25) ---
    trend_score = 0.0

    # Marktmodus richtig getroffen
    if market_ctx.mode == "TREND_LONG" and direction == "LONG":
        trend_score += 10.0
    elif market_ctx.mode == "TREND_SHORT" and direction == "SHORT":
        trend_score += 10.0

    # Beziehung zum EMA200
    if direction == "LONG" and snapshot.price > snapshot.ema200:
        trend_score += 5.0
    if direction == "SHORT" and snapshot.price < snapshot.ema200:
        trend_score += 5.0

    # MTF-Trend
    mtf = {k: _align_trend_label(v) for k, v in snapshot.mtf_trend.items()}
    if direction == "LONG":
        if mtf.get("D1") == "up":
            trend_score += 4.0
        if mtf.get("H1") == "up":
            trend_score += 4.0
        if mtf.get("M15") == "up":
            trend_score += 2.0
    else:
        if mtf.get("D1") == "down":
            trend_score += 4.0
        if mtf.get("H1") == "down":
            trend_score += 4.0
        if mtf.get("M15") == "down":
            trend_score += 2.0

    trend_score = min(trend_score, 25.0)
    breakdown["trend"] = trend_score

    # --- 2) Volumen & RVOL (max 20) ---
    rvol = extra_info.get("rvol", _calc_rvol(snapshot))
    if rvol <= 1.3:
        vol_score = 0.0
    elif rvol >= 3.0:
        vol_score = 20.0
    else:
        # linear von 1.3 → 3.0
        vol_score = 20.0 * (rvol - 1.3) / (3.0 - 1.3)
    breakdown["volume"] = vol_score

    # --- 3) ATR%-Sweetspot (max 15) ---
    atr = snapshot.atr_pct
    if 2.0 <= atr <= 8.0:
        # Sweetspot 3–5% = volle Punktzahl
        if 3.0 <= atr <= 5.0:
            atr_score = 15.0
        else:
            # linearer Abfall zu den Rändern 2% und 8%
            if atr < 3.0:
                atr_score = 15.0 * (atr - 2.0) / (3.0 - 2.0)
            else:
                atr_score = 15.0 * (8.0 - atr) / (8.0 - 5.0)
    else:
        atr_score = 0.0
    atr_score = max(0.0, min(15.0, atr_score))
    breakdown["atr"] = atr_score

    # --- 4) VWAP-/EMA-Lage (max 15) ---
    vwap_score = 0.0
    if direction == "LONG":
        if snapshot.price > snapshot.vwap:
            vwap_score += 6.0
        if snapshot.price > snapshot.ema20:
            vwap_score += 5.0
        if snapshot.price > snapshot.ema50:
            vwap_score += 4.0
    else:
        if snapshot.price < snapshot.vwap:
            vwap_score += 6.0
        if snapshot.price < snapshot.ema20:
            vwap_score += 5.0
        if snapshot.price < snapshot.ema50:
            vwap_score += 4.0
    vwap_score = min(vwap_score, 15.0)
    breakdown["vwap_ema"] = vwap_score

    # --- 5) Setup-Typ & Struktur (max 15) ---
    if setup_type == "BREAKOUT":
        setup_score = 15.0
    else:  # PULLBACK
        setup_score = 12.0
    breakdown["setup"] = setup_score

    # --- 6) Gap-Kontext (max +5 / -10) ---
    gap_pct = extra_info.get("gap_pct", _calc_gap_pct(snapshot))
    gap_score = 0.0
    if direction == "LONG":
        if 0.5 <= gap_pct <= 5.0:
            gap_score = 5.0
        elif gap_pct < -3.0:
            gap_score = -10.0
        elif gap_pct < 0.0:
            gap_score = -5.0
    else:  # SHORT
        if -5.0 <= gap_pct <= -0.5:
            gap_score = 5.0
        elif gap_pct > 3.0:
            gap_score = -10.0
        elif gap_pct > 0.0:
            gap_score = -5.0
    breakdown["gap"] = gap_score

    # --- 7) Candle-Qualität (max 5) ---
    body = snapshot.curr_candle_body_pct
    upper = snapshot.curr_candle_upper_wick_pct
    if body is None or upper is None:
        candle_score = 3.0  # neutral, wenn wir es nicht wissen
    else:
        candle_score = 0.0
        if body >= 60.0:
            candle_score += 3.0
        if upper <= 20.0:
            candle_score += 2.0
        candle_score = min(candle_score, 5.0)
    breakdown["candle"] = candle_score

    # Gesamt-Score
    score = sum(breakdown.values())
    # Score auf 0–100 begrenzen
    score = max(0.0, min(100.0, score))

    return score, breakdown


# --------------------
# CRV & Levels: Entry, SL, TP1, TP2
# --------------------

def compute_levels(
    snapshot: TickerSnapshot,
    direction: Direction
) -> Dict[str, float]:
    """
    Nutzt ATR%, um einen vernünftigen Stop + Ziele zu setzen.
    Ziel: CRV (TP1) >= 1.5, TP2 ~ 2.5–3.0.
    """
    price = snapshot.price
    atr_pct = snapshot.atr_pct

    # Risiko zwischen 1–4 % des Preises, grob 0.6 * ATR
    risk_pct = max(1.0, min(4.0, atr_pct * 0.6))
    risk_abs = price * risk_pct / 100.0

    if direction == "LONG":
        entry = price
        stop = price - risk_abs
        tp1 = price + 1.5 * risk_abs
        tp2 = price + 2.7 * risk_abs
    else:
        entry = price
        stop = price + risk_abs
        tp1 = price - 1.5 * risk_abs
        tp2 = price - 2.7 * risk_abs

    crv_tp1 = abs(tp1 - entry) / max(1e-6, abs(entry - stop))
    crv_tp2 = abs(tp2 - entry) / max(1e-6, abs(entry - stop))

    return {
        "entry": entry,
        "stop_loss": stop,
        "tp1": tp1,
        "tp2": tp2,
        "crv_tp1": crv_tp1,
        "crv_tp2": crv_tp2,
    }


# --------------------
# Haupt-Scanprozedur pro Run
# --------------------

def run_live_scan(
    snapshots: List[TickerSnapshot],
    market_ctx: MarketContext,
    now: Optional[datetime] = None,
    entry_valid_minutes: int = 45,
    min_score: float = 85.0,
    premium_score: float = 93.0,
    min_crv_tp1: float = 1.5,
    max_long_per_run: int = 3,
    max_short_per_run: int = 3
) -> Dict[str, Any]:
    """
    Kernfunktion für Phase 3:
    - nimmt Ticker-Snapshots + Marktmodus
    - filtert nach Basisfiltern
    - sucht Setups
    - berechnet Score & CRV
    - limitiert Anzahl Signale
    - liefert ein Dict, das direkt nach JSON serialisiert werden kann
    """
    if now is None:
        now = datetime.now(timezone.utc)
    now_iso = now.replace(microsecond=0).isoformat()

    candidates: List[Signal] = []

    for snap in snapshots:
        basic_ok, reason, info = apply_basic_filters(snap, market_ctx)
        if not basic_ok:
            continue

        setup = detect_setup(snap, market_ctx)
        if setup is None:
            continue

        direction, setup_type, setup_meta = setup
        info.update(setup_meta)

        score, breakdown = compute_score(
            snap, market_ctx, direction, setup_type, info
        )
        if score < min_score:
            continue

        levels = compute_levels(snap, direction)
        if levels["crv_tp1"] < min_crv_tp1:
            continue

        created_at = now_iso
        entry_valid_until = (
            now + timedelta(minutes=entry_valid_minutes)
        ).replace(microsecond=0).isoformat()

        is_premium = score >= premium_score

        context = {
            "market_mode": market_ctx.mode,
            "market_label": market_ctx.label,
            "spy_trend": market_ctx.spy_trend,
            "qqq_trend": market_ctx.qqq_trend,
            "price": snap.price,
            "avg_vol_20": snap.avg_vol_20,
            "today_vol": snap.today_vol,
            "rvol": info.get("rvol"),
            "atr_pct": snap.atr_pct,
            "gap_pct": info.get("gap_pct"),
            "ema20": snap.ema20,
            "ema50": snap.ema50,
            "ema200": snap.ema200,
            "vwap": snap.vwap,
            "mtf_trend": snap.mtf_trend,
            "data_quality": snap.data_quality,
        }

        sig = Signal(
            ticker=snap.ticker,
            direction=direction,
            setup_type=setup_type,
            score=score,
            is_premium=is_premium,
            crv_tp1=levels["crv_tp1"],
            crv_tp2=levels["crv_tp2"],
            entry=levels["entry"],
            stop_loss=levels["stop_loss"],
            tp1=levels["tp1"],
            tp2=levels["tp2"],
            created_at=created_at,
            entry_valid_until=entry_valid_until,
            context=context,
            score_breakdown=breakdown,
        )
        candidates.append(sig)

    # Long/Short trennen & nach Score sortieren
    long_signals = sorted(
        [s for s in candidates if s.direction == "LONG"],
        key=lambda s: s.score,
        reverse=True,
    )[:max_long_per_run]

    short_signals = sorted(
        [s for s in candidates if s.direction == "SHORT"],
        key=lambda s: s.score,
        reverse=True,
    )[:max_short_per_run]

    selected = long_signals + short_signals

    result = {
        "generated_at": now_iso,
        "market_mode": market_ctx.mode,
        "stats": {
            "checked": len(snapshots),
            "candidates_after_filters": len(candidates),
            "signals_selected": len(selected),
            "long_signals": len(long_signals),
            "short_signals": len(short_signals),
            "min_score": min_score,
        },
        "signals": [asdict(s) for s in selected],
    }

    return result
