"""
indicators.py

Pure functions for VWAP, EMA, ATR, slope, and price-structure detection
(higher highs/lows, consolidation, extension). Shared by scorer.py
(premarket scoring) and stream_features.py (live opening confirmation)
so both stages use identical math — the whole point of "confirm the
prediction" is that premarket and open must be measuring the same things.

All functions take plain lists/dicts of bar-like data:
    bar = {"t": timestamp, "o": open, "h": high, "l": low, "c": close, "v": volume}
so they work identically against Alpaca bars or synthetic premarket
snapshots.
"""

from statistics import mean


def vwap(bars: list) -> float:
    """Volume-weighted average price over the given bar list."""
    total_pv = 0.0
    total_v = 0.0
    for b in bars:
        typical = (b["h"] + b["l"] + b["c"]) / 3.0
        total_pv += typical * b["v"]
        total_v += b["v"]
    if total_v == 0:
        return bars[-1]["c"] if bars else 0.0
    return total_pv / total_v


def vwap_series(bars: list) -> list:
    """Running VWAP value at each bar (for slope calculation)."""
    out = []
    cum_pv = 0.0
    cum_v = 0.0
    for b in bars:
        typical = (b["h"] + b["l"] + b["c"]) / 3.0
        cum_pv += typical * b["v"]
        cum_v += b["v"]
        out.append(cum_pv / cum_v if cum_v > 0 else b["c"])
    return out


def vwap_slope(bars: list, lookback: int = 3) -> float:
    """Positive => VWAP rising over the last `lookback` bars."""
    series = vwap_series(bars)
    if len(series) < lookback + 1:
        return 0.0
    return series[-1] - series[-1 - lookback]


def ema(values: list, period: int) -> float:
    if not values:
        return 0.0
    k = 2 / (period + 1)
    e = values[0]
    for v in values[1:]:
        e = v * k + e * (1 - k)
    return e


def ema_series(values: list, period: int) -> list:
    if not values:
        return []
    k = 2 / (period + 1)
    out = [values[0]]
    for v in values[1:]:
        out.append(v * k + out[-1] * (1 - k))
    return out


def atr(bars: list, period: int = 14) -> float:
    """Average True Range using close-to-close/high-low true range."""
    if len(bars) < 2:
        return 0.0
    trs = []
    for i in range(1, len(bars)):
        h, l, prev_c = bars[i]["h"], bars[i]["l"], bars[i - 1]["c"]
        tr = max(h - l, abs(h - prev_c), abs(l - prev_c))
        trs.append(tr)
    window = trs[-period:] if len(trs) >= period else trs
    return mean(window) if window else 0.0


def rsi(bars: list, period: int = 14) -> float:
    """
    [FEATURE 2026-08-26] Relative Strength Index, using Wilder's original
    smoothing method (the standard definition -- an exponential-style
    average of gains/losses, not a plain rolling mean) over closing
    prices. Returns a 0-100 value; readings above ~70 are the classic
    "extended, overbought" signal, below ~30 "oversold." Needs at least
    period+1 closes to produce a real value; returns a neutral 50.0
    with too little data, since "unknown" shouldn't silently read as
    either overbought or oversold.

    This is a genuinely different measurement from price_slope/
    momentum_slope elsewhere in this codebase: those measure recent
    DIRECTION over a short (8-bar) lookback; RSI normalizes the
    balance of gains vs. losses over a longer window into a bounded
    scale, closer to "how stretched is this move," not just "which
    way is it currently pointing."
    """
    closes = [b["c"] for b in bars]
    if len(closes) < period + 1:
        return 50.0

    deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
    gains = [d if d > 0 else 0.0 for d in deltas]
    losses = [-d if d < 0 else 0.0 for d in deltas]

    avg_gain = mean(gains[:period])
    avg_loss = mean(losses[:period])

    # Wilder's smoothing for every bar after the initial seed window --
    # each new value is a weighted blend of the prior average and the
    # latest gain/loss, not a plain rolling mean over the last N bars.
    for i in range(period, len(gains)):
        avg_gain = (avg_gain * (period - 1) + gains[i]) / period
        avg_loss = (avg_loss * (period - 1) + losses[i]) / period

    if avg_loss == 0:
        return 100.0 if avg_gain > 0 else 50.0
    rs = avg_gain / avg_loss
    return 100.0 - (100.0 / (1.0 + rs))


def relative_volume(current_volume: float, avg_volume_baseline: float) -> float:
    if avg_volume_baseline <= 0:
        return 0.0
    return current_volume / avg_volume_baseline


def volume_acceleration(volumes: list) -> float:
    """
    Ratio of the average of the most recent half of the window vs the
    earlier half. >1 means volume is accelerating (matches the project
    spec's "developing" vs "fading" example).
    """
    n = len(volumes)
    if n < 4:
        return 1.0
    mid = n // 2
    early = mean(volumes[:mid]) or 1e-9
    recent = mean(volumes[mid:]) or 0.0
    return recent / early


def price_momentum(closes: list) -> float:
    """Simple normalized momentum: % change from first to last close."""
    if len(closes) < 2 or closes[0] == 0:
        return 0.0
    return (closes[-1] - closes[0]) / closes[0] * 100.0


def is_higher_highs_higher_lows(bars: list, lookback: int = 4) -> bool:
    """
    True if, over the last `lookback` bars, each bar's high is >= the
    prior bar's high (allowing 1 tick tolerance) and lows are trending
    up overall — i.e. a healthy stairstep rather than a single spike.
    """
    if len(bars) < lookback:
        return False
    window = bars[-lookback:]
    highs = [b["h"] for b in window]
    lows = [b["l"] for b in window]
    higher_highs = sum(1 for i in range(1, len(highs)) if highs[i] > highs[i - 1])
    higher_lows = sum(1 for i in range(1, len(lows)) if lows[i] > lows[i - 1])
    steps = len(window) - 1
    # Require a clear majority of steps to be strictly higher on both
    # fronts AND the window must net higher overall (first vs last),
    # otherwise a choppy up/down sequence can pass on step-count alone.
    threshold = max(1, round(steps * 0.7))
    net_higher = highs[-1] > highs[0] and lows[-1] > lows[0]
    return higher_highs >= threshold and higher_lows >= threshold and net_higher


def consolidation_tightness(bars: list, lookback: int = 6) -> float:
    """
    Returns range-as-percent-of-price over the lookback window.
    Lower = tighter consolidation (generally healthier pre-breakout).
    """
    if len(bars) < 2:
        return 100.0
    window = bars[-lookback:] if len(bars) >= lookback else bars
    highs = [b["h"] for b in window]
    lows = [b["l"] for b in window]
    closes = [b["c"] for b in window]
    avg_price = mean(closes) if closes else 1.0
    if avg_price == 0:
        return 100.0
    return (max(highs) - min(lows)) / avg_price * 100.0


def distance_from_high_pct(current_price: float, reference_high: float) -> float:
    if reference_high <= 0:
        return 0.0
    return (reference_high - current_price) / reference_high * 100.0


def extension_from_vwap_pct(current_price: float, vwap_value: float) -> float:
    if vwap_value <= 0:
        return 0.0
    return (current_price - vwap_value) / vwap_value * 100.0


def spread_pct(bid: float, ask: float) -> float:
    if bid <= 0 or ask <= 0:
        return 100.0
    mid = (bid + ask) / 2.0
    if mid == 0:
        return 100.0
    return (ask - bid) / mid * 100.0


def breakout_confirmed(bars: list, resistance: float, buffer_pct: float, hold_bars: int) -> bool:
    """
    True if the last `hold_bars` closes have all been above
    resistance * (1 + buffer_pct/100) — i.e. the breakout held rather
    than spiking through and immediately failing.
    """
    if len(bars) < hold_bars:
        return False
    trigger = resistance * (1 + buffer_pct / 100.0)
    return all(b["c"] > trigger for b in bars[-hold_bars:])


def pullback_then_continuation(bars: list, max_retrace_pct: float) -> bool:
    """
    Detects: price made a local high, pulled back no more than
    max_retrace_pct of the prior up-move, then resumed making new
    highs. Used to allow "healthy pullback followed by continuation"
    entries rather than only fresh breakouts.
    """
    if len(bars) < 6:
        return False
    closes = [b["c"] for b in bars]
    peak_idx = closes.index(max(closes[:-1])) if len(closes) > 1 else 0
    if peak_idx == 0 or peak_idx == len(closes) - 1:
        return False
    peak = closes[peak_idx]
    trough = min(closes[peak_idx:-1]) if closes[peak_idx:-1] else peak
    move_up = peak - closes[0]
    if move_up <= 0:
        return False
    retrace = (peak - trough) / move_up * 100.0
    resumed = closes[-1] > peak
    return retrace <= max_retrace_pct and resumed


# ---------------------------------------------------------------------------
# [FEATURE 2026-08-17] Regression-based slope + inverse structure helpers,
# added to support intraday_health.py's continuous candidate-health scoring.
# ---------------------------------------------------------------------------

def linreg_slope(values: list) -> float:
    """
    Least-squares linear regression slope of `values` against bar index
    0..n-1 (i.e. "change in value per bar"), using ALL points in the
    window rather than just comparing the first and last one — a single
    noisy bar at either end can't swing this the way a two-point delta
    can.
    """
    n = len(values)
    if n < 2:
        return 0.0
    xs = list(range(n))
    mean_x = sum(xs) / n
    mean_y = sum(values) / n
    num = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, values))
    den = sum((x - mean_x) ** 2 for x in xs)
    if den == 0:
        return 0.0
    return num / den


def normalized_slope_pct(values: list) -> float:
    """
    Regression slope expressed as a percentage of the series' own mean,
    per bar — makes slope comparable across stocks at very different
    price levels (a $6 stock and a $14 stock can both be scored on the
    same "% per bar" scale).
    """
    if not values:
        return 0.0
    mean_y = sum(values) / len(values)
    if mean_y == 0:
        return 0.0
    return (linreg_slope(values) / mean_y) * 100.0


def classify_slope(slope_pct: float, flat_threshold_pct: float) -> str:
    """
    Buckets a normalized slope (see normalized_slope_pct) into
    "positive" / "flat" / "negative" against a symmetric dead-zone
    around zero, so small noise doesn't get labeled as a trend.
    """
    if slope_pct > flat_threshold_pct:
        return "positive"
    if slope_pct < -flat_threshold_pct:
        return "negative"
    return "flat"


def is_lower_highs_lower_lows(bars: list, lookback: int = 4) -> bool:
    """
    The deteriorating-structure mirror of is_higher_highs_higher_lows():
    True if a clear majority of steps in the window make a strictly
    LOWER high and a strictly lower low, AND the window is net lower
    overall (first vs last) — same net-direction guard used on the
    healthy-structure check, so a choppy down-up-down sequence can't
    pass on step-count alone.
    """
    if len(bars) < lookback:
        return False
    window = bars[-lookback:]
    highs = [b["h"] for b in window]
    lows = [b["l"] for b in window]
    lower_highs = sum(1 for i in range(1, len(highs)) if highs[i] < highs[i - 1])
    lower_lows = sum(1 for i in range(1, len(lows)) if lows[i] < lows[i - 1])
    steps = len(window) - 1
    threshold = max(1, round(steps * 0.7))
    net_lower = highs[-1] < highs[0] and lows[-1] < lows[0]
    return lower_highs >= threshold and lower_lows >= threshold and net_lower


# ---------------------------------------------------------------------------
# [FEATURE 2026-09-03] Session-length trend detection, added because
# is_lower_highs_lower_lows()/is_higher_highs_higher_lows() above (and
# intraday_health.py's price_slope, both windowed by config to 4-8 bars)
# are only ever looking at the last 4-8 MINUTES. That's the right window
# for "did the breakout hold," but it's blind to a decline that took
# hours to happen: real case, ACHR on 2026-09-03 went $5.96 (09:35 ET) ->
# $5.70 (13:02 ET), then bounced to $5.79 by 14:01 ET when the bot
# entered -- the 8-bar price_slope and 4-bar structure check both read
# cleanly "positive"/healthy at that instant because the last few minutes
# WERE ticking up, with nothing evaluating whether that bounce had
# actually undone the prior multi-hour decline (it hadn't: still 2.85%
# off the session high). These two functions answer that separate
# question at the FULL-SESSION scale instead.
# ---------------------------------------------------------------------------

def regression_fit_pct_change(values: list) -> float:
    """
    Percent change implied by a least-squares regression line fit across
    `values`, from the fitted line's value at the first point to its
    value at the last — NOT (values[-1] - values[0]) / values[0], which
    is exactly the two-point noise problem linreg_slope()'s docstring
    already warns about for a raw first/last delta.

    This is deliberately a different shape of number from
    normalized_slope_pct(): that one is "%-per-bar," which shrinks in
    magnitude as the window gets longer (the same total move spread over
    more bars) — useful for classify_slope()'s per-bar dead-zone, but not
    for comparing "how far did this move, net" across windows of very
    different lengths. Using the fitted line's own endpoints instead of
    the raw first/last bars keeps that comparison meaningful at any
    window length while still being robust to a single noisy bar at
    either end.
    """
    n = len(values)
    if n < 2:
        return 0.0
    slope = linreg_slope(values)
    mean_x = (n - 1) / 2.0
    mean_y = sum(values) / n
    intercept = mean_y - slope * mean_x
    fit_start = intercept
    fit_end = intercept + slope * (n - 1)
    if fit_start == 0:
        return 0.0
    return (fit_end - fit_start) / fit_start * 100.0


def is_extended_downtrend(bars: list, min_bars: int, min_decline_pct: float,
                           recovery_threshold_pct: float) -> bool:
    """
    True only when BOTH hold — same "signals must agree" pattern as
    intraday_health._is_severe():

      - regression_fit_pct_change() over ALL of `bars` (the full session
        so far, not a short lookback) is down at least min_decline_pct —
        the day, net, is still heading down, not just the last few bars.
      - current price remains at least recovery_threshold_pct below the
        highest high anywhere in that same history — a bounce that has
        already reclaimed the session high isn't "still in a downtrend"
        no matter what the regression over the whole window says, so
        this can't fire on a name that's already fully recovered.

    Requires at least min_bars of bars; with less history than that,
    "the session's trend so far" isn't a meaningful question yet (a
    stock 3 minutes off the open has no session trend), so this fails
    safe to False rather than judging a fresh candidate on noise —
    same fail-safe-to-normal-behavior pattern used elsewhere in this
    project.
    """
    if len(bars) < min_bars:
        return False
    closes = [b["c"] for b in bars]
    current_price = closes[-1]
    session_high = max(b["h"] for b in bars)
    fit_pct_change = regression_fit_pct_change(closes)
    dist_from_high = distance_from_high_pct(current_price, session_high)
    return fit_pct_change <= -min_decline_pct and dist_from_high >= recovery_threshold_pct
