"""Ensemble probability forecasting for prediction markets.

Combines six independent signal extractors -- smart money flow, microstructure,
momentum, volume profile, holder concentration, and temporal patterns -- into a
single ensemble probability estimate for each market.  Surfaces edge
opportunities where the ensemble forecast diverges materially from the current
market price, and provides a pipeline factory (``oracle()``) for continuous
in-loop forecasting.

Typical usage::

    # One-shot forecast
    fc = hz.forecast_market("0xconditionid...", market_price=0.65)

    # Scan for edges across top markets
    edges = hz.scan_edges(min_edge_bps=300)

    # Pipeline integration
    hz.run(pipeline=[hz.oracle(forecast_interval_cycles=10)])

    # Full report
    report = hz.oracle_report(max_markets=20)
"""

from __future__ import annotations

import logging
import math
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Callable

from horizon._horizon import (
    auth_require_ultra,
    combine_signals,
    ema,
    kyles_lambda,
    VpinDetector,
    shannon_entropy,
    calibration_curve,
    log_loss,
    kelly_size,
)
from horizon.context import Context
from horizon.flow import get_top_holders, get_market_trades, Trade, Holder
from horizon.wallet_intel import score_wallet, WalletScore

logger = logging.getLogger("horizon.oracle")


# ---------------------------------------------------------------------------
# Module-level wallet score cache
# ---------------------------------------------------------------------------

_score_cache: dict[str, tuple[float, WalletScore]] = {}  # address -> (timestamp, score)
_CACHE_TTL = 300.0  # 5 minutes


def _cached_score_wallet(address: str, limit: int = 100) -> WalletScore | None:
    """Return a cached wallet score, refreshing if the TTL has expired.

    Returns ``None`` if the scoring call fails, so callers can skip the
    wallet gracefully rather than aborting the entire signal.
    """
    now = time.time()
    if address in _score_cache:
        ts, cached = _score_cache[address]
        if now - ts < _CACHE_TTL:
            return cached

    try:
        ws = score_wallet(address, limit=limit)
        _score_cache[address] = (now, ws)
        return ws
    except Exception as exc:  # noqa: BLE001
        logger.debug("score_wallet failed for %s: %s", address[:10], exc)
        return None


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class SignalReading:
    """A single signal's contribution to the ensemble forecast.

    Attributes:
        name: Human-readable signal identifier (e.g. ``"smart_money_flow"``).
        raw_value: Unprocessed output from the signal extractor.
        normalized_value: Value mapped to the [0, 1] range (0.5 = neutral).
        weight: Weight assigned by ``OracleConfig.signal_weights``.
        contribution: ``normalized_value * weight`` -- the signal's share of
            the weighted sum.
    """

    name: str
    raw_value: float
    normalized_value: float
    weight: float
    contribution: float


@dataclass(frozen=True)
class MarketForecast:
    """Ensemble probability forecast for a single market.

    Attributes:
        market_id: Condition ID of the market (0x-prefixed hex).
        market_title: Human-readable market question.
        ensemble_prob: Weighted-average probability from all signals, clamped
            to [0.01, 0.99].
        confidence_low: Lower bound of the forecast confidence interval.
        confidence_high: Upper bound of the forecast confidence interval.
        edge_bps: ``(ensemble_prob - market_price) * 10000`` -- positive means
            the market underprices YES.
        signals: Per-signal breakdown.
        market_price: Most recent market mid price at forecast time.
        timestamp: Unix timestamp when the forecast was generated.
    """

    market_id: str
    market_title: str
    ensemble_prob: float
    confidence_low: float
    confidence_high: float
    edge_bps: float
    signals: list[SignalReading]
    market_price: float
    timestamp: float


@dataclass(frozen=True)
class EdgeOpportunity:
    """An actionable edge detected between ensemble forecast and market price.

    Attributes:
        market_id: Condition ID of the market.
        market_title: Human-readable market question.
        forecast_prob: Ensemble probability.
        market_price: Current market mid price.
        edge_bps: Signed edge in basis points.
        confidence: Forecast confidence (0-1).
        direction: ``"buy_yes"`` when forecast > market, ``"buy_no"`` otherwise.
        signal_agreement: Fraction of signals agreeing with direction.
        recommended_size: Kelly-fraction suggested position size in USDC.
    """

    market_id: str
    market_title: str
    forecast_prob: float
    market_price: float
    edge_bps: float
    confidence: float
    direction: str  # "buy_yes" or "buy_no"
    signal_agreement: float
    recommended_size: float


@dataclass(frozen=True)
class OracleReport:
    """Summary report across multiple markets.

    Attributes:
        forecasts: Individual market forecasts.
        top_edges: Filtered and ranked edge opportunities.
        model_calibration: Log-loss of forecast probabilities vs market prices
            (lower is better); ``0.0`` when insufficient data.
        coverage: Number of markets successfully forecast.
        avg_confidence: Mean confidence across all forecasts.
        timestamp: Unix timestamp of report generation.
    """

    forecasts: list[MarketForecast]
    top_edges: list[EdgeOpportunity]
    model_calibration: float
    coverage: int
    avg_confidence: float
    timestamp: float


@dataclass
class OracleConfig:
    """Tunable knobs for the oracle ensemble.

    This dataclass is intentionally *not* frozen so users can mutate it
    before passing it to the public API.

    Attributes:
        signal_weights: Mapping from signal name to relative weight.  Weights
            are normalized to sum to 1 internally.
        min_confidence: Minimum forecast confidence to surface an edge.
        edge_threshold_bps: Minimum |edge| in basis points for inclusion in
            ``scan_edges`` and ``oracle_report``.
        recalibrate_interval: Reserved for future online recalibration.
        holder_limit: Max holders to fetch per market.
        trade_limit: Max trades to fetch per market.
    """

    signal_weights: dict[str, float] = field(default_factory=lambda: {
        "smart_money_flow": 0.25,
        "microstructure": 0.20,
        "momentum": 0.15,
        "volume_profile": 0.15,
        "holder_concentration": 0.15,
        "temporal_pattern": 0.10,
    })
    min_confidence: float = 0.3
    edge_threshold_bps: float = 200.0
    recalibrate_interval: int = 50
    holder_limit: int = 50
    trade_limit: int = 200


# ---------------------------------------------------------------------------
# Helper utilities
# ---------------------------------------------------------------------------


def _clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float:
    """Clamp *value* to the closed interval [lo, hi]."""
    return max(lo, min(hi, value))


def _sigmoid(x: float, k: float = 4.0) -> float:
    """Logistic sigmoid: 1 / (1 + exp(-k * x)).

    Centered at 0 so that x = 0 returns 0.5.
    """
    try:
        return 1.0 / (1.0 + math.exp(-k * x))
    except OverflowError:
        return 0.0 if x < 0 else 1.0


def _safe_std(values: list[float]) -> float:
    """Population standard deviation.  Returns 0 for empty / single-element lists."""
    n = len(values)
    if n < 2:
        return 0.0
    mean = sum(values) / n
    var = sum((v - mean) ** 2 for v in values) / n
    return math.sqrt(var)


# ---------------------------------------------------------------------------
# Signal extractors (private)
# ---------------------------------------------------------------------------


def _smart_money_flow_signal(market_id: str, config: OracleConfig) -> float:
    """Estimate directional conviction of 'smart money' holders.

    Fetches top holders, scores each wallet, and computes a size-weighted
    composite direction.  Returns 0.5 (neutral) on any error.

    A value > 0.5 means smart money is net long (YES), < 0.5 means net
    short (NO).
    """
    try:
        holders = get_top_holders(market_id, limit=config.holder_limit)
    except Exception as exc:  # noqa: BLE001
        logger.debug("_smart_money_flow_signal: holder fetch failed: %s", exc)
        return 0.5

    if not holders:
        return 0.5

    weighted_direction = 0.0
    total_amount = 0.0

    for holder in holders:
        if holder.amount <= 0:
            continue

        ws = _cached_score_wallet(holder.wallet, limit=100)
        if ws is None:
            continue

        # outcome_index 0 = YES, 1 = NO
        direction = 1.0 if holder.outcome_index == 0 else -1.0
        weighted_direction += holder.amount * ws.composite_score * direction
        total_amount += holder.amount

    if total_amount <= 0:
        return 0.5

    net_direction = weighted_direction / total_amount
    return _sigmoid(net_direction, k=4.0)


def _microstructure_signal(market_id: str, config: OracleConfig) -> float:
    """Detect informed trading via Kyle's lambda and VPIN.

    Higher values indicate more informed (directional) order flow.
    Returns 0.5 on error.
    """
    try:
        trades = get_market_trades(market_id, limit=config.trade_limit)
    except Exception as exc:  # noqa: BLE001
        logger.debug("_microstructure_signal: trade fetch failed: %s", exc)
        return 0.5

    if len(trades) < 10:
        return 0.5

    prices = [t.price for t in trades if t.price > 0]
    if len(prices) < 10:
        return 0.5

    # --- Kyle's lambda component ---
    lambda_component = 0.5
    try:
        lam = kyles_lambda(prices)
        # Normalize: lambda of 0.5 is considered very high
        lambda_component = _clamp(lam / 0.5, 0.0, 1.0)
    except Exception as exc:  # noqa: BLE001
        logger.debug("_microstructure_signal: kyles_lambda failed: %s", exc)

    # --- VPIN component ---
    vpin_component = 0.5
    try:
        detector = VpinDetector(bucket_size=50.0, n_buckets=10)
        for t in trades:
            is_buy = t.side.upper() == "BUY"
            detector.add_trade(t.price, t.size, is_buy)
        vpin_val = detector.vpin()
        if vpin_val is not None and not math.isnan(vpin_val):
            vpin_component = _clamp(vpin_val, 0.0, 1.0)
    except Exception as exc:  # noqa: BLE001
        logger.debug("_microstructure_signal: VPIN failed: %s", exc)

    # Blend
    informed_prob = 0.5 * lambda_component + 0.5 * vpin_component
    return _clamp(informed_prob, 0.0, 1.0)


def _momentum_signal(trades: list[Trade]) -> float:
    """Short vs long EMA crossover momentum.

    Values > 0.5 indicate upward momentum (price rising toward YES).
    Returns 0.5 when insufficient data.
    """
    if len(trades) < 10:
        return 0.5

    prices = [t.price for t in trades if t.price > 0]
    if len(prices) < 10:
        return 0.5

    # Reverse so oldest is first (trades arrive newest-first from API)
    prices = list(reversed(prices))

    try:
        short_span = min(5, len(prices))
        long_span = min(20, len(prices))
        alpha_short = 2.0 / (short_span + 1)
        alpha_long = 2.0 / (long_span + 1)
        short_ema = ema(prices, alpha_short)
        long_ema = ema(prices, alpha_long)
    except Exception as exc:  # noqa: BLE001
        logger.debug("_momentum_signal: EMA computation failed: %s", exc)
        return 0.5

    if long_ema <= 0:
        return 0.5

    ratio = short_ema / long_ema
    # Map: ratio 0.95 -> 0.0, ratio 1.05 -> 1.0, ratio 1.0 -> 0.5
    normalized = (ratio - 0.95) / 0.10
    return _clamp(normalized, 0.0, 1.0)


def _volume_profile_signal(trades: list[Trade]) -> float:
    """Buy vs sell volume imbalance.

    Returns buy_volume / total_volume, naturally in [0, 1].
    0.5 means balanced flow.
    """
    buy_vol = 0.0
    sell_vol = 0.0

    for t in trades:
        if t.side.upper() == "BUY":
            buy_vol += t.size
        elif t.side.upper() == "SELL":
            sell_vol += t.size

    total = buy_vol + sell_vol
    if total <= 0:
        return 0.5

    return buy_vol / total


def _holder_concentration_signal(market_id: str, config: OracleConfig) -> float:
    """Herfindahl-Hirschman Index of holder position sizes.

    Higher concentration means fewer large holders dominate, signaling
    stronger conviction.  Returns a value in [0, 1].
    """
    try:
        holders = get_top_holders(market_id, limit=config.holder_limit)
    except Exception as exc:  # noqa: BLE001
        logger.debug("_holder_concentration_signal: holder fetch failed: %s", exc)
        return 0.5

    if not holders:
        return 0.5

    total_amount = sum(h.amount for h in holders if h.amount > 0)
    if total_amount <= 0:
        return 0.5

    hhi = sum((h.amount / total_amount) ** 2 for h in holders if h.amount > 0)

    # Normalize: HHI of 0.1 -> 1.0
    normalized = _clamp(hhi * 10.0, 0.0, 1.0)
    return normalized


def _temporal_pattern_signal(trades: list[Trade]) -> float:
    """Identify time-of-day buy bias relative to the current hour.

    Computes buy ratio per hour bucket, then returns the buy ratio for
    the current UTC hour.  Values > 0.5 mean historically bullish hour.
    """
    if len(trades) < 10:
        return 0.5

    import datetime

    hour_buys: dict[int, int] = defaultdict(int)
    hour_total: dict[int, int] = defaultdict(int)

    for t in trades:
        if t.timestamp <= 0:
            continue
        try:
            dt = datetime.datetime.fromtimestamp(t.timestamp, tz=datetime.timezone.utc)
            hour = dt.hour
        except (OSError, ValueError, OverflowError):
            continue

        hour_total[hour] += 1
        if t.side.upper() == "BUY":
            hour_buys[hour] += 1

    if not hour_total:
        return 0.5

    current_hour = datetime.datetime.now(datetime.timezone.utc).hour

    total_this_hour = hour_total.get(current_hour, 0)
    if total_this_hour == 0:
        # Fall back to global buy ratio
        all_buys = sum(hour_buys.values())
        all_total = sum(hour_total.values()) or 1
        return _clamp(all_buys / all_total, 0.0, 1.0)

    buy_ratio = hour_buys.get(current_hour, 0) / total_this_hour
    return _clamp(buy_ratio, 0.0, 1.0)


# ---------------------------------------------------------------------------
# Ensemble logic
# ---------------------------------------------------------------------------


def _compute_ensemble(
    signal_values: dict[str, float],
    config: OracleConfig,
) -> tuple[float, list[SignalReading]]:
    """Combine individual signals into a weighted ensemble probability.

    Returns ``(ensemble_prob, signal_readings)`` where *ensemble_prob* is
    clamped to [0.01, 0.99].
    """
    weights = config.signal_weights
    total_weight = sum(weights.get(name, 0.0) for name in signal_values)
    if total_weight <= 0:
        total_weight = 1.0

    readings: list[SignalReading] = []
    weighted_sum = 0.0

    for name, raw_val in signal_values.items():
        w = weights.get(name, 0.0)
        norm = _clamp(raw_val, 0.0, 1.0)
        contrib = norm * w
        weighted_sum += contrib
        readings.append(SignalReading(
            name=name,
            raw_value=round(raw_val, 4),
            normalized_value=round(norm, 4),
            weight=round(w, 4),
            contribution=round(contrib, 4),
        ))

    ensemble = weighted_sum / total_weight
    ensemble = _clamp(ensemble, 0.01, 0.99)
    return ensemble, readings


def _compute_confidence(signal_values: dict[str, float]) -> float:
    """Compute forecast confidence from signal agreement.

    Confidence = 1 - coefficient_of_variation of the non-neutral signals.
    A CV near 0 means all signals agree, yielding confidence near 1.
    """
    # Filter out neutral (0.5) signals -- they carry no information
    active = [v for v in signal_values.values() if abs(v - 0.5) > 0.02]

    if len(active) < 2:
        return 0.5  # not enough signal diversity to assess confidence

    mean = sum(active) / len(active)
    if abs(mean) < 1e-9:
        return 0.1

    std = _safe_std(active)
    cv = std / abs(mean)
    confidence = _clamp(1.0 - cv, 0.0, 1.0)
    return confidence


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def forecast_market(
    market_id: str,
    market_title: str = "",
    market_price: float = 0.0,
    config: OracleConfig | None = None,
    exchange: str = "polymarket",
    include_wallet_signal: bool = False,
) -> MarketForecast:
    """Generate an ensemble probability forecast for a single market.

    Runs all six signal extractors, computes a weighted ensemble, and
    returns a :class:`MarketForecast` with per-signal breakdown and
    edge estimate.

    For Kalshi markets, flow-dependent signals (smart money, microstructure,
    holder concentration, momentum, volume profile, temporal) return neutral
    (0.5) because Kalshi has no public on-chain trade/holder data.

    Args:
        market_id: Condition ID (0x-prefixed hex) of the market.
        market_title: Optional human-readable title.
        market_price: Current market mid price.  Used only for edge
            calculation -- does not affect the probability estimate.
        config: Oracle configuration.  Defaults to ``OracleConfig()``.
        exchange: Exchange (``"polymarket"`` or ``"kalshi"``).
        include_wallet_signal: If ``True``, add a ``"wallet_consensus"``
            signal from the wallet tracker module.  Requires a non-zero
            weight in ``config.signal_weights["wallet_consensus"]`` to
            have an effect.  Defaults to ``False``.

    Returns:
        :class:`MarketForecast` with ensemble probability and signal details.

    Raises:
        RuntimeError: If the caller's license tier is below Ultra.
    """
    auth_require_ultra()

    config = config or OracleConfig()

    # ------------------------------------------------------------------
    # Fetch trades once (shared by momentum, volume_profile, temporal)
    # Kalshi has no public on-chain trade data -- skip gracefully.
    # ------------------------------------------------------------------
    trades: list[Trade] = []
    if exchange != "kalshi":
        try:
            trades = get_market_trades(market_id, limit=config.trade_limit)
        except Exception as exc:  # noqa: BLE001
            logger.warning("forecast_market: trade fetch failed for %s: %s", market_id, exc)

    # ------------------------------------------------------------------
    # Run all six signal extractors
    # ------------------------------------------------------------------
    signal_values: dict[str, float] = {}

    # 1. Smart money flow (needs holders + wallet scoring -- Polymarket only)
    if exchange == "kalshi":
        signal_values["smart_money_flow"] = 0.5
    else:
        try:
            signal_values["smart_money_flow"] = _smart_money_flow_signal(market_id, config)
        except Exception as exc:  # noqa: BLE001
            logger.debug("Signal smart_money_flow failed for %s: %s", market_id, exc)
            signal_values["smart_money_flow"] = 0.5

    # 2. Microstructure (Kyle's lambda + VPIN -- Polymarket only)
    if exchange == "kalshi":
        signal_values["microstructure"] = 0.5
    else:
        try:
            signal_values["microstructure"] = _microstructure_signal(market_id, config)
        except Exception as exc:  # noqa: BLE001
            logger.debug("Signal microstructure failed for %s: %s", market_id, exc)
            signal_values["microstructure"] = 0.5

    # 3. Momentum (short vs long EMA)
    try:
        signal_values["momentum"] = _momentum_signal(trades)
    except Exception as exc:  # noqa: BLE001
        logger.debug("Signal momentum failed for %s: %s", market_id, exc)
        signal_values["momentum"] = 0.5

    # 4. Volume profile (buy/sell imbalance)
    try:
        signal_values["volume_profile"] = _volume_profile_signal(trades)
    except Exception as exc:  # noqa: BLE001
        logger.debug("Signal volume_profile failed for %s: %s", market_id, exc)
        signal_values["volume_profile"] = 0.5

    # 5. Holder concentration (HHI -- Polymarket only)
    if exchange == "kalshi":
        signal_values["holder_concentration"] = 0.5
    else:
        try:
            signal_values["holder_concentration"] = _holder_concentration_signal(
                market_id, config,
            )
        except Exception as exc:  # noqa: BLE001
            logger.debug("Signal holder_concentration failed for %s: %s", market_id, exc)
            signal_values["holder_concentration"] = 0.5

    # 6. Temporal patterns (hourly buy ratio)
    try:
        signal_values["temporal_pattern"] = _temporal_pattern_signal(trades)
    except Exception as exc:  # noqa: BLE001
        logger.debug("Signal temporal_pattern failed for %s: %s", market_id, exc)
        signal_values["temporal_pattern"] = 0.5

    # 7. Wallet consensus (opt-in via include_wallet_signal)
    if include_wallet_signal and exchange != "kalshi":
        try:
            from horizon.wallet_tracker import compute_consensus, TrackerConfig
            consensus = compute_consensus(market_id, config=TrackerConfig(), market_price=market_price)
            if consensus.is_actionable:
                signal_values["wallet_consensus"] = consensus.signal
            else:
                signal_values["wallet_consensus"] = 0.5
        except Exception as exc:  # noqa: BLE001
            logger.debug("Signal wallet_consensus failed for %s: %s", market_id, exc)
            signal_values["wallet_consensus"] = 0.5

    # ------------------------------------------------------------------
    # Ensemble
    # ------------------------------------------------------------------
    ensemble, readings = _compute_ensemble(signal_values, config)
    confidence = _compute_confidence(signal_values)

    # Confidence interval
    half_width = (1.0 - confidence) * 0.2
    confidence_low = max(0.01, ensemble - half_width)
    confidence_high = min(0.99, ensemble + half_width)

    # Edge vs market price
    edge_bps = round((ensemble - market_price) * 10000, 4) if market_price > 0 else 0.0

    now = time.time()
    logger.debug(
        "forecast_market(%s): ensemble=%.4f price=%.4f edge=%.0fbps confidence=%.2f",
        market_id[:12], ensemble, market_price, edge_bps, confidence,
    )

    return MarketForecast(
        market_id=market_id,
        market_title=market_title,
        ensemble_prob=round(ensemble, 4),
        confidence_low=round(confidence_low, 4),
        confidence_high=round(confidence_high, 4),
        edge_bps=round(edge_bps, 4),
        signals=readings,
        market_price=round(market_price, 4),
        timestamp=now,
    )


# ---------------------------------------------------------------------------
# Edge scanning
# ---------------------------------------------------------------------------


def scan_edges(
    markets: list[dict[str, Any]] | None = None,
    min_edge_bps: float = 200.0,
    max_markets: int = 30,
    config: OracleConfig | None = None,
    exchange: str = "polymarket",
) -> list[EdgeOpportunity]:
    """Scan multiple markets and return those with material edge.

    For each market the ensemble forecast is compared with the current
    price.  Markets whose |edge| exceeds *min_edge_bps* are returned,
    ranked by ``|edge| * confidence``.

    Args:
        markets: List of dicts with at least ``"id"`` (condition_id) and
            optionally ``"title"`` and ``"price"``.  If ``None``, the
            function tries to auto-discover top markets via
            ``discovery.top_markets``.
        min_edge_bps: Minimum absolute edge in basis points.
        max_markets: Maximum number of markets to scan.
        config: Oracle configuration.
        exchange: Exchange (``"polymarket"`` or ``"kalshi"``).

    Returns:
        Sorted list of :class:`EdgeOpportunity` instances.
    """
    auth_require_ultra()

    config = config or OracleConfig()

    # ------------------------------------------------------------------
    # Auto-discover markets if none provided
    # ------------------------------------------------------------------
    if markets is None:
        try:
            from horizon.discovery import top_markets
            discovered = top_markets(limit=max_markets, exchange=exchange)
            markets = [
                {
                    "id": m.id,
                    "title": m.name,
                    "price": 0.5,  # discovery does not always carry price
                }
                for m in discovered
            ]
            logger.info("scan_edges: discovered %d markets", len(markets))
        except Exception as exc:  # noqa: BLE001
            logger.warning("scan_edges: market discovery failed: %s", exc)
            return []

    if not markets:
        logger.info("scan_edges: no markets to scan")
        return []

    # ------------------------------------------------------------------
    # Forecast each market
    # ------------------------------------------------------------------
    opportunities: list[EdgeOpportunity] = []

    for mkt in markets[:max_markets]:
        mkt_id = str(mkt.get("id", ""))
        mkt_title = str(mkt.get("title", ""))
        mkt_price = float(mkt.get("price", 0.5) or 0.5)

        if not mkt_id:
            continue

        try:
            fc = forecast_market(
                mkt_id,
                market_title=mkt_title,
                market_price=mkt_price,
                config=config,
                exchange=exchange,
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("scan_edges: forecast failed for %s: %s", mkt_id[:12], exc)
            continue

        abs_edge = abs(fc.edge_bps)
        if abs_edge < min_edge_bps:
            continue

        # Direction
        direction = "buy_yes" if fc.edge_bps > 0 else "buy_no"

        # Signal agreement: fraction of signals agreeing with direction
        threshold = 0.5
        if direction == "buy_yes":
            agreeing = sum(1 for s in fc.signals if s.normalized_value > threshold)
        else:
            agreeing = sum(1 for s in fc.signals if s.normalized_value < threshold)
        total_signals = len(fc.signals) or 1
        signal_agreement = round(agreeing / total_signals, 4)

        # Confidence from CV
        confidence = _compute_confidence(
            {s.name: s.normalized_value for s in fc.signals},
        )

        # Recommended size via Kelly criterion (quarter-Kelly)
        recommended_size = 0.0
        try:
            prob = fc.ensemble_prob
            if direction == "buy_no":
                prob = 1.0 - prob
            # kelly_size(prob, odds, bankroll, fraction)
            odds = mkt_price / (1.0 - mkt_price) if 0 < mkt_price < 1 else 1.0
            raw_kelly = kelly_size(prob, odds, 1000.0, 0.25)
            recommended_size = round(max(0.0, raw_kelly), 2)
        except Exception as exc:  # noqa: BLE001
            logger.debug("scan_edges: kelly_size failed for %s: %s", mkt_id[:12], exc)

        opportunities.append(EdgeOpportunity(
            market_id=mkt_id,
            market_title=mkt_title,
            forecast_prob=round(fc.ensemble_prob, 4),
            market_price=round(mkt_price, 4),
            edge_bps=round(fc.edge_bps, 4),
            confidence=round(confidence, 4),
            direction=direction,
            signal_agreement=signal_agreement,
            recommended_size=recommended_size,
        ))

    # Sort by |edge| * confidence descending
    opportunities.sort(
        key=lambda o: abs(o.edge_bps) * o.confidence,
        reverse=True,
    )

    logger.info(
        "scan_edges: %d / %d markets have edge >= %.0f bps",
        len(opportunities), min(len(markets), max_markets), min_edge_bps,
    )
    return opportunities


# ---------------------------------------------------------------------------
# Pipeline factory
# ---------------------------------------------------------------------------


def oracle(
    config: OracleConfig | None = None,
    forecast_interval_cycles: int = 20,
    exchange: str = "polymarket",
) -> Callable[[Context], None]:
    """Create a pipeline function that forecasts the current market periodically.

    The returned callable maintains per-market state and refreshes the
    ensemble forecast every *forecast_interval_cycles* cycles.  Between
    refreshes it injects the most recent forecast into ``ctx.params``.

    Injected context parameters:
        ``oracle_forecast`` -- the :class:`MarketForecast` object.
        ``oracle_edge_bps`` -- ``float``, signed edge in basis points.

    Args:
        config: Oracle configuration.
        forecast_interval_cycles: Number of pipeline cycles between
            forecast refreshes.  Lower values increase API usage.
        exchange: Exchange (``"polymarket"`` or ``"kalshi"``).

    Returns:
        Pipeline function with signature ``(Context) -> None``.

    Example::

        hz.run(
            markets=[market],
            pipeline=[hz.oracle(forecast_interval_cycles=10), my_quoter],
        )
    """
    auth_require_ultra()

    config = config or OracleConfig()

    forecasts: dict[str, MarketForecast] = {}
    cycle_count = 0

    def _inner(ctx: Context) -> None:
        nonlocal forecasts, cycle_count
        cycle_count += 1

        market = ctx.market
        if market is None:
            return
        market_id = market.id
        if not market_id:
            return

        if cycle_count % forecast_interval_cycles == 0:
            try:
                price = ctx.feed.price if ctx.feed else 0.5
                fc = forecast_market(
                    market_id,
                    market_title=market.name,
                    market_price=price,
                    config=config,
                    exchange=exchange,
                )
                forecasts[market_id] = fc
                ctx.params["oracle_forecast"] = fc
                ctx.params["oracle_edge_bps"] = fc.edge_bps
                logger.info(
                    "Oracle forecast for %s: prob=%.4f edge=%.0fbps",
                    market_id[:12], fc.ensemble_prob, fc.edge_bps,
                )
            except Exception as exc:  # noqa: BLE001
                logger.warning("Oracle forecast failed for %s: %s", market_id[:12], exc)
        elif market_id in forecasts:
            ctx.params["oracle_forecast"] = forecasts[market_id]
            ctx.params["oracle_edge_bps"] = forecasts[market_id].edge_bps

    _inner.__name__ = "oracle"
    return _inner


# ---------------------------------------------------------------------------
# Full report
# ---------------------------------------------------------------------------


def oracle_report(
    markets: list[dict[str, Any]] | None = None,
    max_markets: int = 20,
    config: OracleConfig | None = None,
    exchange: str = "polymarket",
) -> OracleReport:
    """Generate an oracle report across multiple markets.

    Forecasts each market, computes model calibration via log-loss, and
    surfaces the top edge opportunities.

    Args:
        markets: List of dicts with ``"id"``, ``"title"``, ``"price"``
            keys.  If ``None``, the function auto-discovers top markets.
        max_markets: Maximum number of markets to include.
        config: Oracle configuration.
        exchange: Exchange (``"polymarket"`` or ``"kalshi"``).

    Returns:
        :class:`OracleReport` with forecasts, edges, and summary stats.
    """
    auth_require_ultra()

    config = config or OracleConfig()

    # ------------------------------------------------------------------
    # Discover markets if needed
    # ------------------------------------------------------------------
    if markets is None:
        try:
            from horizon.discovery import top_markets
            discovered = top_markets(limit=max_markets, exchange=exchange)
            markets = [
                {
                    "id": m.id,
                    "title": m.name,
                    "price": 0.5,
                }
                for m in discovered
            ]
            logger.info("oracle_report: discovered %d markets", len(markets))
        except Exception as exc:  # noqa: BLE001
            logger.warning("oracle_report: market discovery failed: %s", exc)
            markets = []

    if not markets:
        return OracleReport(
            forecasts=[],
            top_edges=[],
            model_calibration=0.0,
            coverage=0,
            avg_confidence=0.0,
            timestamp=time.time(),
        )

    # ------------------------------------------------------------------
    # Forecast all markets
    # ------------------------------------------------------------------
    all_forecasts: list[MarketForecast] = []
    forecast_probs: list[float] = []
    market_prices: list[float] = []

    for mkt in markets[:max_markets]:
        mkt_id = str(mkt.get("id", ""))
        mkt_title = str(mkt.get("title", ""))
        mkt_price = float(mkt.get("price", 0.5) or 0.5)

        if not mkt_id:
            continue

        try:
            fc = forecast_market(
                mkt_id,
                market_title=mkt_title,
                market_price=mkt_price,
                config=config,
                exchange=exchange,
            )
            all_forecasts.append(fc)
            forecast_probs.append(fc.ensemble_prob)
            market_prices.append(mkt_price)
        except Exception as exc:  # noqa: BLE001
            logger.warning("oracle_report: forecast failed for %s: %s", mkt_id[:12], exc)
            continue

    # ------------------------------------------------------------------
    # Build edge opportunities
    # ------------------------------------------------------------------
    edges: list[EdgeOpportunity] = []

    for fc in all_forecasts:
        abs_edge = abs(fc.edge_bps)
        if abs_edge < config.edge_threshold_bps:
            continue

        direction = "buy_yes" if fc.edge_bps > 0 else "buy_no"

        # Signal agreement
        threshold = 0.5
        if direction == "buy_yes":
            agreeing = sum(1 for s in fc.signals if s.normalized_value > threshold)
        else:
            agreeing = sum(1 for s in fc.signals if s.normalized_value < threshold)
        total_signals = len(fc.signals) or 1
        signal_agreement = round(agreeing / total_signals, 4)

        confidence = _compute_confidence(
            {s.name: s.normalized_value for s in fc.signals},
        )

        # Kelly sizing
        recommended_size = 0.0
        try:
            prob = fc.ensemble_prob
            if direction == "buy_no":
                prob = 1.0 - prob
            mp = fc.market_price if fc.market_price > 0 else 0.5
            odds = mp / (1.0 - mp) if 0 < mp < 1 else 1.0
            raw_kelly = kelly_size(prob, odds, 1000.0, 0.25)
            recommended_size = round(max(0.0, raw_kelly), 2)
        except Exception as exc:  # noqa: BLE001
            logger.debug("oracle_report: kelly_size failed: %s", exc)

        edges.append(EdgeOpportunity(
            market_id=fc.market_id,
            market_title=fc.market_title,
            forecast_prob=round(fc.ensemble_prob, 4),
            market_price=round(fc.market_price, 4),
            edge_bps=round(fc.edge_bps, 4),
            confidence=round(confidence, 4),
            direction=direction,
            signal_agreement=signal_agreement,
            recommended_size=recommended_size,
        ))

    edges.sort(key=lambda o: abs(o.edge_bps) * o.confidence, reverse=True)

    # ------------------------------------------------------------------
    # Model calibration via log-loss
    # ------------------------------------------------------------------
    model_calibration = 0.0
    if len(forecast_probs) >= 2 and len(market_prices) >= 2:
        # Treat market prices as "true" probabilities (binary labels at 0/1
        # are unavailable, so we use cross-entropy between forecast and
        # market as a proxy calibration metric).
        try:
            # Binarize market prices: > 0.5 -> 1, else 0
            labels = [1.0 if p > 0.5 else 0.0 for p in market_prices]
            model_calibration = round(log_loss(labels, forecast_probs), 4)
        except Exception as exc:  # noqa: BLE001
            logger.debug("oracle_report: log_loss failed: %s", exc)

    # ------------------------------------------------------------------
    # Summary statistics
    # ------------------------------------------------------------------
    coverage = len(all_forecasts)

    if all_forecasts:
        all_confidences = [
            _compute_confidence({s.name: s.normalized_value for s in fc.signals})
            for fc in all_forecasts
        ]
        avg_confidence = round(sum(all_confidences) / len(all_confidences), 4)
    else:
        avg_confidence = 0.0

    logger.info(
        "oracle_report: %d forecasts, %d edges, calibration=%.4f",
        coverage, len(edges), model_calibration,
    )

    return OracleReport(
        forecasts=all_forecasts,
        top_edges=edges,
        model_calibration=model_calibration,
        coverage=coverage,
        avg_confidence=avg_confidence,
        timestamp=time.time(),
    )
