#!/usr/bin/env python3
import json, os, time, math
from datetime import datetime, timedelta, timezone

ROOT = os.path.expanduser("~/kleiner")
DATA = os.path.join(ROOT, "data")
LIVE_DIR = os.path.join(DATA, "live_scan")
INTRA_DIR = os.path.join(DATA, "intraday")
MARKET_JSON = os.path.join(DATA, "market", "market.json")
DAYTRADER20 = os.path.join(LIVE_DIR, "daytrader_20.json")

SIGNALS_LIVE = os.path.join(LIVE_DIR, "signals_live.json")
COOLDOWN_FILE = os.path.join(LIVE_DIR, "cooldown.json")
SCANS_FILE = os.path.join(DATA, "scans.json")

# --- v1 Defaults (Pflichtenheft) ---
PRICE_MIN, PRICE_MAX = 5.0, 500.0
RVOL_MIN = 0.0
ATR_MIN, ATR_MAX = 2.0, 8.0
AVG_VOL20_MIN = 1_000_000

SCORE_MIN = 45
SCORE_PREMIUM = 93
CRV_MIN = 1.0

ENTRY_VALID_MIN = 45
COOLDOWN_MIN = 30

MAX_LONG_PER_RUN = 3
MAX_SHORT_PER_RUN = 3

# Handelszeiten (CH). Für v1 fix: 15:30–22:00 (US 09:30–16:00)
OPEN_CH_H, OPEN_CH_M = 15, 30
CLOSE_CH_H, CLOSE_CH_M = 22, 0


# ---------------- Helpers ----------------
def now_utc():
    return datetime.now(timezone.utc)

def now_ch_naive():
    # Server läuft i.d.R. in CH; wir behandeln es als "lokal" (naiv)
    return datetime.now()

def iso_z(dtobj):
    return dtobj.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")

def safe_load_json(path, default):
    try:
        if not os.path.exists(path):
            return default
        raw = open(path, "r", encoding="utf-8").read().strip()
        if not raw:
            return default
        return json.loads(raw)
    except Exception:
        return default

def atomic_write(path, obj):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(obj, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)

def parse_iso_z(s):
    # "2025-12-14T20:41:00Z"
    s = str(s).strip()
    if s.endswith("Z"):
        s = s[:-1] + "+00:00"
    return datetime.fromisoformat(s)

def ema(values, period):
    if not values or period <= 1:
        return []
    k = 2 / (period + 1)
    out = []
    e = values[0]
    out.append(e)
    for v in values[1:]:
        e = (v * k) + (e * (1 - k))
        out.append(e)
    return out

def vwap_day(bars):
    # VWAP nur für den letzten Handelstag in den Bars
    if not bars:
        return None
    # letzten Tag bestimmen
    last_dt = parse_iso_z(bars[-1]["ts"]).date()
    pv = 0.0
    vv = 0.0
    for b in bars:
        d = parse_iso_z(b["ts"]).date()
        if d != last_dt:
            continue
        tp = (b["h"] + b["l"] + b["c"]) / 3.0
        v = float(b.get("v", 0) or 0)
        pv += tp * v
        vv += v
    return (pv / vv) if vv > 0 else None

def atr_approx(bars, period=14):
    # ATR approximation on 5m (simple)
    if len(bars) < period + 2:
        return None
    trs = []
    for i in range(1, len(bars)):
        h = bars[i]["h"]; l = bars[i]["l"]; pc = bars[i-1]["c"]
        tr = max(h - l, abs(h - pc), abs(l - pc))
        trs.append(tr)
    # SMA
    if len(trs) < period:
        return None
    return sum(trs[-period:]) / period

def wick_quality(last_bar, direction):
    o,h,l,c = last_bar["o"], last_bar["h"], last_bar["l"], last_bar["c"]
    body = abs(c - o)
    upper = h - max(o,c)
    lower = min(o,c) - l
    # Rückgabe 0..10
    if body <= 0:
        return 3
    if direction == "LONG":
        # zu großer Upper-Wick = schlechter
        ratio = upper / body
        if ratio < 0.6: return 10
        if ratio < 1.2: return 7
        return 4
    else:
        ratio = lower / body
        if ratio < 0.6: return 10
        if ratio < 1.2: return 7
        return 4

def market_mode_from_market_json(mj):
    # bevorzugt: market_mode
    mm = (mj.get("market_mode") or "").strip()
    if mm:
        return mm

    # fallback: market_trend (Aufwärts/Abwärts/Seitwärts)
    mt = (mj.get("market_trend") or "").strip()
    if mt == "Aufwärts": return "LONG"
    if mt == "Abwärts": return "SHORT"
    if mt == "Seitwärts": return "RANGE"

    # fallback: position_vs_ema (oberhalb/unterhalb/nahe)
    pv = (mj.get("position_vs_ema") or "").strip()
    if pv == "oberhalb": return "LONG"
    if pv == "unterhalb": return "SHORT"
    if pv == "nahe": return "RANGE"

    return "RANGE"

def within_trading_window_ch():
    t = now_ch_naive()
    start = t.replace(hour=OPEN_CH_H, minute=OPEN_CH_M, second=0, microsecond=0)
    end = t.replace(hour=CLOSE_CH_H, minute=CLOSE_CH_M, second=0, microsecond=0)
    return start <= t <= end, start, end

def minutes_to_close(end_dt):
    t = now_ch_naive()
    return int((end_dt - t).total_seconds() // 60)

def minutes_since_open(start_dt):
    t = now_ch_naive()
    return int((t - start_dt).total_seconds() // 60)

def load_cooldowns():
    return safe_load_json(COOLDOWN_FILE, {})

def save_cooldowns(cd):
    atomic_write(COOLDOWN_FILE, cd)

def cooldown_ok(cd, ticker, nowdt):
    last = cd.get(ticker)
    if not last:
        return True
    try:
        last_dt = parse_iso_z(last)
        return (nowdt - last_dt) >= timedelta(minutes=COOLDOWN_MIN)
    except Exception:
        return True


# ---------------- Setup detection ----------------
def detect_setup_5m(bars, direction):
    """
    returns (setup_type, setup_strength 0..1)
    """
    if len(bars) < 40:
        return (None, 0.0)

    closes = [b["c"] for b in bars]
    highs = [b["h"] for b in bars]
    lows  = [b["l"] for b in bars]
    vols  = [float(b.get("v",0) or 0) for b in bars]

    e20 = ema(closes, 20)
    e50 = ema(closes, 50)
    vw = vwap_day(bars)

    last = bars[-1]
    c = last["c"]
    v = vols[-1]
    vavg20 = sum(vols[-21:-1]) / 20.0 if len(vols) >= 21 else (sum(vols)/max(1,len(vols)))

    # 1) Breakout
    lookback = 12  # ~ 60min
    if direction == "LONG":
        prior_high = max(highs[-lookback-1:-1])
        if c > prior_high * 1.001 and v > vavg20 * 1.2:
            strength = min(1.0, (c/prior_high - 1.0) * 50)  # kleine Skala
            # VWAP/EMA Boost
            if vw and c > vw: strength = min(1.0, strength + 0.15)
            if e20 and c > e20[-1]: strength = min(1.0, strength + 0.10)
            return ("BREAKOUT", max(0.55, strength))
    else:
        prior_low = min(lows[-lookback-1:-1])
        if c < prior_low * 0.999 and v > vavg20 * 1.2:
            strength = min(1.0, (1.0 - c/prior_low) * 50)
            if vw and c < vw: strength = min(1.0, strength + 0.15)
            if e20 and c < e20[-1]: strength = min(1.0, strength + 0.10)
            return ("BREAKOUT", max(0.55, strength))

    # 2) Pullback (EMA20/VWAP touch in last ~10 bars + continuation)
    if not e20 or not e50:
        return (None, 0.0)

    if direction == "LONG":
        if c > e20[-1] > e50[-1] and (vw is None or c >= vw):
            for j in range(-10, -1):
                b = bars[j]
                # "touch" nahe EMA20/VWAP
                touch = (b["l"] <= e20[j] * 1.002) or (vw and b["l"] <= vw * 1.002)
                if touch and b["c"] > b["o"]:
                    # confirmation: last close bricht über high der touch-Kerze
                    if c > b["h"] * 1.001:
                        return ("PULLBACK", 0.65)
    else:
        if c < e20[-1] < e50[-1] and (vw is None or c <= vw):
            for j in range(-10, -1):
                b = bars[j]
                touch = (b["h"] >= e20[j] * 0.998) or (vw and b["h"] >= vw * 0.998)
                if touch and b["c"] < b["o"]:
                    if c < b["l"] * 0.999:
                        return ("PULLBACK", 0.65)

    return (None, 0.0)


def compute_score(row, direction, setup_type, setup_strength, bars):
    # Komponenten v1 (0..100)
    score = 0

    # 1) Markt/Trend Alignment (max 30)
    vs_ema200 = (row.get("vs_ema200") or "").lower()
    trend_d1 = (row.get("trend_d1") or "").upper()
    trend_h1 = (row.get("trend_h1") or "").upper()
    trend_m15 = (row.get("trend_m15") or "").upper()
    trend_simple = (row.get("trend_simple") or "").lower()

    if direction == "LONG":
        if vs_ema200 == "oberhalb": score += 15
        if trend_simple == "long": score += 5
        if trend_d1 == "LONG": score += 4
        if trend_h1 == "LONG": score += 3
        if trend_m15 == "LONG": score += 3
    else:
        if vs_ema200 == "unterhalb": score += 15
        if trend_simple == "short": score += 5
        if trend_d1 == "SHORT": score += 4
        if trend_h1 == "SHORT": score += 3
        if trend_m15 == "SHORT": score += 3

    # 2) RVOL (max 15)
    rvol = float(row.get("rvol") or 0.0)
    if rvol >= 2.0: score += 15
    elif rvol >= 1.6: score += 12
    elif rvol >= 1.3: score += 10
    elif rvol >= 1.1: score += 6
    else: score += 2

    # 3) ATR Sweetspot (max 10)
    atrp = float(row.get("atr_pct") or 0.0)
    if 3.0 <= atrp <= 6.0: score += 10
    elif ATR_MIN <= atrp <= ATR_MAX: score += 7
    elif atrp > 0: score += 3

    # 4) VWAP/EMA Lage (max 10)
    vw = vwap_day(bars)
    last = bars[-1] if bars else None
    if last and vw:
        c = last["c"]
        if direction == "LONG" and c > vw: score += 10
        elif direction == "SHORT" and c < vw: score += 10
        else: score += 4
    else:
        score += 4

    # 5) Setup Qualität (max 25)
    if setup_type == "BREAKOUT":
        score += int(18 + 7 * setup_strength)
    elif setup_type == "PULLBACK":
        score += int(16 + 6 * setup_strength)

    # 6) Gap Kontext (max 5)
    gap = float(row.get("gap_pct") or 0.0)
    if direction == "LONG":
        if gap >= 0: score += 5
        else: score += 2
    else:
        if gap <= 0: score += 5
        else: score += 2

    # 7) Candle Quality (max 10)
    if last:
        score += wick_quality(last, direction)

    return max(0, min(100, int(round(score))))


def make_levels(entry, direction, atrp, bars):
    # ATR$ bevorzugt aus 5m, fallback aus atr_pct
    atr_d = atr_approx(bars, 14)
    if atr_d is None and atrp > 0:
        atr_d = (atrp / 100.0) * entry

    if atr_d is None or atr_d <= 0:
        atr_d = entry * 0.01  # fallback 1%

    risk = max(atr_d * 0.6, entry * 0.008)  # konservativ

    if direction == "LONG":
        sl = entry - risk
        tp1 = entry + risk * 1.5
        tp2 = entry + risk * 2.8
        crv = (tp1 - entry) / (entry - sl) if (entry - sl) != 0 else 2.0
    else:
        sl = entry + risk
        tp1 = entry - risk * 1.5
        tp2 = entry - risk * 2.8
        crv = (entry - tp1) / (sl - entry) if (sl - entry) != 0 else 2.0

    return round(sl, 2), round(tp1, 2), round(tp2, 2), round(abs(crv), 2)


def append_scan_meta(meta):
    scans = safe_load_json(SCANS_FILE, [])
    if isinstance(scans, dict):
        scans = [scans]
    scans.insert(0, meta)
    atomic_write(SCANS_FILE, scans[:500])


def main():
    os.makedirs(LIVE_DIR, exist_ok=True)

    t0 = time.time()
    nowdt_utc = now_utc()

    # market mode
    mj = safe_load_json(MARKET_JSON, {})
    market_mode = market_mode_from_market_json(mj)

    # trading window rules
    in_window, start_ch, end_ch = within_trading_window_ch()
    mins_since_open = minutes_since_open(start_ch) if in_window else None
    mins_to_close = minutes_to_close(end_ch) if in_window else None

    # v1: Range defensiv -> keine Signale
    range_day = (market_mode == "RANGE")

    rows = safe_load_json(DAYTRADER20, [])
    if not isinstance(rows, list):
        rows = []

    cd = load_cooldowns()

    signals_candidates = []
    checked = 0
    filtered = 0

    # Wenn ausserhalb Handelszeiten: trotzdem file updaten, aber keine Signale
    allow_signals_now = in_window and (mins_since_open is not None and mins_since_open >= 5) and (mins_to_close is not None and mins_to_close >= 30) and (not range_day)

    for r in rows:
        if not isinstance(r, dict):
            continue
        checked += 1

        ticker = (r.get("ticker") or r.get("symbol") or "").upper().strip()
        if not ticker:
            continue

        # Datenqualität Pflichtregel
        dq = (r.get("data_quality") or "").lower()
        if False and dq == "warning":
            filtered += 1
            continue

        price = float(r.get("price") or 0.0)
        if not (PRICE_MIN <= price <= PRICE_MAX):
            filtered += 1
            continue

        # AvgVol20 nur prüfen, wenn Wert > 0 vorhanden ist (sonst nicht killen)
        avgv = float(r.get("avg_vol20") or 0.0)
        if avgv > 0 and avgv < AVG_VOL20_MIN:
            filtered += 1
            continue

        rvol = float(r.get("rvol") or 0.0)
        if rvol < RVOL_MIN:
            filtered += 1
            continue

        atrp = float(r.get("atr_pct") or 0.0)
        if atrp > 0 and not (ATR_MIN <= atrp <= ATR_MAX):
            filtered += 1
            continue

        # Markt-Kopplung (strikt)
        vs_ema200 = (r.get("vs_ema200") or "").lower()
        if market_mode == "LONG" and vs_ema200 not in ("oberhalb",):
            filtered += 1
            continue
        if market_mode == "SHORT" and vs_ema200 not in ("unterhalb",):
            filtered += 1
            continue

        if not allow_signals_now:
            continue  # keine Signals jetzt (Zeitfenster / Range / etc.)

        # Cooldown
        if not cooldown_ok(cd, ticker, nowdt_utc):
            continue

        # intraday bars
        p = os.path.join(INTRA_DIR, f"{ticker}_5m.json")
        bars = safe_load_json(p, [])
        if not isinstance(bars, list) or len(bars) < 40:
            continue

        direction = "LONG" if market_mode == "LONG" else "SHORT"

        setup_type, setup_strength = detect_setup_5m(bars, direction)
        if not setup_type:
            setup_type = "DEMO"
            setup_strength = 0.25

        # Entry = letzter Close
        entry = float(bars[-1]["c"])
        sl, tp1, tp2, crv = make_levels(entry, direction, atrp, bars)

        score = compute_score(r, direction, setup_type, setup_strength, bars)

        if score < SCORE_MIN:
            continue
        if crv < CRV_MIN:
            continue

        valid_until = nowdt_utc + timedelta(minutes=ENTRY_VALID_MIN)

        comment = f"{setup_type} | RVOL {rvol:.2f} | ATR% {atrp:.2f}"
        if mins_to_close is not None and mins_to_close <= 60:
            comment += f" | 🔴 Markt schliesst in {mins_to_close} min"

        signals_candidates.append({
            "ticker": ticker,
            "direction": direction,
            "entry": round(entry, 2),
            "sl": sl,
            "tp1": tp1,
            "tp2": tp2,
            "score": int(score),
            "crv": float(crv),
            "status": "Neu" if score < SCORE_PREMIUM else "A+",
            "valid_until_utc": iso_z(valid_until),
            "comment": comment
        })

    # Limits pro Run
    longs = [s for s in signals_candidates if s["direction"] == "LONG"]
    shorts = [s for s in signals_candidates if s["direction"] == "SHORT"]

    longs.sort(key=lambda x: (x["score"], x["crv"]), reverse=True)
    shorts.sort(key=lambda x: (x["score"], x["crv"]), reverse=True)

    out_signals = longs[:MAX_LONG_PER_RUN] + shorts[:MAX_SHORT_PER_RUN]
    out_signals.sort(key=lambda x: (x["score"], x["crv"]), reverse=True)

    # Cooldowns setzen für neu erzeugte Signale
    for s in out_signals:
        cd[s["ticker"]] = iso_z(nowdt_utc)
    save_cooldowns(cd)

    payload = {
        "last_update_utc": iso_z(nowdt_utc),
        "market_mode": market_mode,
        "signals": out_signals
    }
    atomic_write(SIGNALS_LIVE, payload)

    meta = {
        "type": "Live-Scan",
        "at": now_ch_naive().strftime("%Y-%m-%d, %H:%M:%S"),
        "duration": f"{int((time.time()-t0)*1000)} ms",
        "long": sum(1 for s in out_signals if s["direction"] == "LONG"),
        "short": sum(1 for s in out_signals if s["direction"] == "SHORT"),
        "status": "Erfolg",
        "checked": checked,
        "filtered": filtered,
        "market_mode": market_mode
    }
    append_scan_meta(meta)

    print("Market mode:", market_mode)
    print("Trading window:", "OK" if in_window else "NO", "| allow_signals_now:", allow_signals_now, "| range_day:", range_day)
    print("Checked:", checked, "Filtered:", filtered)
    print("Signals:", len(out_signals), "Long:", meta["long"], "Short:", meta["short"])
    print("Wrote:", SIGNALS_LIVE)

if __name__ == "__main__":
    main()
