โ† Back
โ˜†
"""Edge detection โ€” compares model probability vs market price"""
from loguru import logger
from config import (
    MIN_EDGE, MAX_EDGE, MIN_VOLUME, EDGE_TIERS, BET_SIZE, BANKROLL, MAX_PER_MARKET,
    MIN_PRICE, KELLY_FRACTION, MIN_BET, MAX_ENSEMBLE_STD, MAX_SLIPPAGE,
    BLOCK_YES_SIDE, MIN_BUY_PRICE, MAX_BUY_PRICE, KELLY_BANKROLL,
)
import db

# In-memory cache for city calibration (refreshed each scan cycle)
_city_cal_cache = {}
_city_cal_ts = 0

# === Safety filters ===
# MAX_EDGE imported from config (default 0.23) โ€” data shows 25%+ edge = 57% WR, loses money
# MIN/MAX_BUY_PRICE imported from config โ€” sweet spot 75-84ยข (85.6% WR, +$38 PnL)
MIN_MODEL_CONFIDENCE = 0.80  # Min model confidence for our side โ€” below 80% = 38% WR, -$12 loss


def analyze_opportunity(market: dict, forecast: dict) -> dict | None:
    """
    Compare model probability with market price.
    Returns trade signal if edge > threshold, else None.
    """
    model_prob = forecast.get("model_probability")
    yes_price = market.get("yes_price")
    no_price = market.get("no_price")
    volume = market.get("volume", 0)

    if model_prob is None or yes_price is None:
        return None

    # Volume filter
    if volume < MIN_VOLUME:
        logger.debug(f"Skip {market['id'][:8]}: volume ${volume} < ${MIN_VOLUME}")
        return None

    # Ensure no_price is set (should sum to ~1 with yes_price)
    if not no_price:
        no_price = 1 - yes_price

    # Slippage filter: YES + NO should be ~1.0
    # Spread > MAX_SLIPPAGE means illiquid market or stale prices
    price_sum = yes_price + no_price
    spread = abs(price_sum - 1.0)
    if spread > MAX_SLIPPAGE:
        logger.debug(f"Skip {market['id'][:8]}: spread {spread:.3f} > {MAX_SLIPPAGE} (illiquid)")
        return None

    # Calculate edge both directions
    edge_yes = model_prob - yes_price     # buy YES if positive
    edge_no = (1 - model_prob) - no_price  # buy NO if positive

    # Pick best direction. On an exact edge tie, prefer NO: YES may be blocked by
    # BLOCK_YES_SIDE, and silently dropping a tie to YES would skip an equally-good
    # tradeable NO. When YES is not blocked, tie-to-NO is harmless.
    if edge_yes > edge_no and edge_yes >= MIN_EDGE:
        side = "YES"
        edge = edge_yes
        token_id = market["yes_token_id"]
        buy_price = yes_price
        market_prob = yes_price
    elif edge_no >= edge_yes and edge_no >= MIN_EDGE:
        side = "NO"
        edge = edge_no
        token_id = market["no_token_id"]
        buy_price = no_price
        market_prob = no_price
    else:
        return None

    # === SAFETY FILTERS ===

    # 0. Block YES side entirely โ€” data: 42.9% WR, -$13.49 over 14 trades
    if side == "YES" and BLOCK_YES_SIDE:
        logger.debug(f"Skip {market['id'][:8]}: YES blocked (BLOCK_YES_SIDE=true, 42.9% WR historically)")
        return None

    # 0a. Even if YES not blocked globally, skip when market thinks it's very unlikely
    if side == "YES" and market_prob < 0.10:
        logger.debug(f"Skip {market['id'][:8]}: YES blocked โ€” market {market_prob:.1%} < 10% (model likely overconfident)")
        return None

    # 0b. Borderline filter for between/eq markets
    # These markets resolve on ยฑ0.5ยฐF rounding โ€” too risky when model is uncertain
    # Data: 2/2 false WINs (Seattle, SF) were borderline between/eq with model_prob 35-40%
    operator = market.get("operator", "")
    if operator in ("between", "eq"):
        # Gaussian fallback (no ensemble) is least reliable for narrow,
        # rounding-sensitive between/eq brackets. Refuse to trade without ensemble.
        if forecast.get("n_members", 0) < 10:
            logger.debug(f"Skip {market['id'][:8]}: {operator} without ensemble (Gaussian fallback unreliable)")
            return None
        # model_prob here is for YES outcome; win_prob for our side
        yes_prob = model_prob
        if 0.35 <= yes_prob <= 0.65:
            logger.debug(
                f"Skip {market['id'][:8]}: borderline {operator} โ€” model YES prob {yes_prob:.1%} "
                f"in [35-65%] range (too uncertain for rounding-sensitive market)"
            )
            return None

    # 1. Max edge filter โ€” if model disagrees too much, model is likely wrong
    if edge >= MAX_EDGE:
        logger.debug(f"Skip {market['id'][:8]}: edge {edge:.1%} >= {MAX_EDGE:.0%} (model disagrees too much)")
        return None

    # 2. Price range filter โ€” sweet spot 75-84ยข (85.6% WR, +$38 PnL)
    # Below MIN_BUY_PRICE: model overconfident on uncertain markets (55-68% WR)
    # Above MAX_BUY_PRICE: R:R too low, need >87% WR to profit (actual 82%)
    if buy_price < MIN_BUY_PRICE:
        logger.debug(f"Skip {market['id'][:8]}: {side} price ${buy_price:.2f} < ${MIN_BUY_PRICE} (bad R:R zone)")
        return None
    if buy_price > MAX_BUY_PRICE:
        logger.debug(f"Skip {market['id'][:8]}: {side} price ${buy_price:.2f} > ${MAX_BUY_PRICE} (R:R too low)")
        return None

    # 3. Set bid price: slightly above current market price (improve our queue position)
    price = min(buy_price + 0.01, (model_prob if side == "YES" else 1 - model_prob) - 0.02)

    # Ensure price is valid and above minimum (avoid orders that never fill)
    price = round(max(0.01, min(0.99, price)), 2)

    # Re-validate the FINAL bid against the price band. The win_prob cap above can
    # pull `price` below MIN_BUY_PRICE (the band check ran on buy_price, not the
    # final bid), re-entering the bad R:R zone the band filter meant to exclude.
    if price < MIN_BUY_PRICE or price > MAX_BUY_PRICE:
        logger.debug(f"Skip {market['id'][:8]}: final bid ${price:.2f} outside band [${MIN_BUY_PRICE}, ${MAX_BUY_PRICE}]")
        return None

    if price < MIN_PRICE:
        logger.debug(f"Skip {market['id'][:8]}: bid price ${price:.2f} < MIN_PRICE ${MIN_PRICE} (would never fill)")
        return None

    # === ENSEMBLE CONSENSUS FILTER ===
    ensemble_std = forecast.get("ensemble_std")
    n_members = forecast.get("n_members", 0)

    if n_members >= 10 and ensemble_std and ensemble_std > MAX_ENSEMBLE_STD:
        logger.debug(
            f"Skip {market['id'][:8]}: ensemble std {ensemble_std:.1f}ยฐF > {MAX_ENSEMBLE_STD}ยฐF "
            f"(no consensus among {n_members} members)"
        )
        return None

    # Ensemble agreement filter: require majority consensus for the signal direction
    member_values = forecast.get("member_values")
    if member_values and len(member_values) >= 10:
        threshold = market.get("threshold")
        if threshold is not None:
            th = threshold
            if market.get("threshold_unit") == "C":
                th = th * 9/5 + 32
            # For YES bets on "gte": most members should be above threshold
            # For NO bets on "gte": most members should be below threshold
            op = market.get("operator", "gte")
            if op == "gte":
                above = sum(1 for v in member_values if v >= th)
                agreement = above / len(member_values) if side == "YES" else (1 - above / len(member_values))
            elif op == "lte":
                below = sum(1 for v in member_values if v <= th)
                agreement = below / len(member_values) if side == "YES" else (1 - below / len(member_values))
            else:
                agreement = 1.0  # skip check for between/eq (already well-filtered by probability)

            if agreement < 0.55:  # Need at least 55% of members agreeing
                logger.debug(
                    f"Skip {market['id'][:8]}: ensemble agreement {agreement:.0%} < 55% for {side}"
                )
                return None

    # Determine edge tier and bet size (Kelly Criterion + city calibration)
    tier = _get_edge_tier(edge)
    win_prob = model_prob if side == "YES" else (1 - model_prob)

    # Model confidence filter: skip trades where model isn't confident enough
    # Data: <80% conf = 38% WR (-$12.33), โ‰ฅ80% conf = 75% WR (-$1.15)
    if win_prob < MIN_MODEL_CONFIDENCE:
        logger.debug(
            f"Skip {market['id'][:8]}: model confidence {win_prob:.1%} < {MIN_MODEL_CONFIDENCE:.0%} "
            f"({side} on {market.get('city', '?')})"
        )
        return None
    city_name = market.get("city", "")
    city_cal = _get_city_calibration(city_name)
    bet_size = _calculate_kelly_size(win_prob, price, edge, forecast, city_cal)

    if bet_size < MIN_BET:
        logger.debug(
            f"Skip {market['id'][:8]}: Kelly size ${bet_size:.2f} < MIN_BET ${MIN_BET} "
            f"(edge {edge:.1%} too small for confident bet)"
        )
        return None

    signal = {
        "market_id": market["id"],
        "side": side,
        "token_id": token_id,
        "price": price,
        "size": bet_size,
        "edge": round(edge, 4),
        "edge_tier": tier["label"],
        "model_prob": model_prob,
        "market_prob": round(market_prob, 4),
        "question": market.get("question", ""),
        "city": market.get("city", ""),
        "forecast_value": forecast.get("forecast_value"),
        "threshold": market.get("threshold"),
    }

    cal_info = ""
    if city_cal:
        cal_info = f" | Cal: BS={city_cal['brier_score']:.2f} ร—{city_cal['confidence_mult']:.1f} ({city_cal['sample_count']}s)"

    logger.info(
        f"๐ŸŽฏ OPPORTUNITY: {market.get('city', '?')} | "
        f"{side} @ ${price:.2f} | "
        f"Edge: {edge:.1%} ({tier['label']}) | "
        f"Model: {win_prob:.1%} vs Market: {market_prob:.1%} | "
        f"Kelly: ${bet_size:.2f}{cal_info}"
    )

    return signal


def _get_edge_tier(edge: float) -> dict:
    """Get edge tier (strong/medium/weak)"""
    for tier in EDGE_TIERS:  # sorted by min_edge desc in config
        if edge >= tier["min_edge"]:
            return tier
    return EDGE_TIERS[-1]  # weakest


def _get_city_calibration(city: str) -> dict | None:
    """Get cached city calibration (refreshed every 5 min)"""
    import time
    global _city_cal_cache, _city_cal_ts

    now = time.time()
    if now - _city_cal_ts > 300:  # Refresh every 5 min
        try:
            cals = db.get_all_city_calibrations(min_samples=5)
            _city_cal_cache = {c["city"]: c for c in cals}
            _city_cal_ts = now
        except Exception as e:
            logger.warning(f"City calibration refresh failed: {e}")

    return _city_cal_cache.get(city)


def _calculate_kelly_size(win_prob: float, price: float, edge: float, forecast: dict, city_cal: dict = None) -> float:
    """
    Fractional Kelly Criterion for Polymarket binary outcomes.

    On Polymarket you buy shares at `price`, if you win you get $1/share.
      - Odds (b) = (1 - price) / price  (net payout per dollar risked)
      - Kelly f* = (p * b - q) / b  where p = win_prob, q = 1 - p
      - Fractional Kelly = f* ร— KELLY_FRACTION (conservative: 20%)

    Extra discount for Gaussian fallback (no ensemble โ†’ less confident).
    City calibration adjusts fraction based on historical accuracy.
    """
    if price <= 0 or price >= 1:
        return 0.0

    # Polymarket odds: pay `price`, receive $1 if win
    b = (1 - price) / price  # net odds (e.g., price=0.40 โ†’ b=1.5)
    p = win_prob
    q = 1 - p

    # Kelly formula
    kelly_f = (p * b - q) / b
    if kelly_f <= 0:
        return 0.0  # Negative Kelly = no bet

    # Apply fractional Kelly
    fraction = KELLY_FRACTION

    # Extra discount if no ensemble (Gaussian fallback = less reliable)
    prob_method = forecast.get("prob_method", "gaussian")
    if prob_method != "ensemble":
        fraction *= 0.5  # Half Kelly for non-ensemble (uncertain model)

    # Additional discount by ensemble spread (high std = less confident)
    ensemble_std = forecast.get("ensemble_std")
    if ensemble_std and ensemble_std > 5.0:
        # Wide ensemble spread โ†’ reduce confidence
        fraction *= max(0.3, 1.0 - (ensemble_std - 5.0) * 0.1)

    # City calibration adjustment (self-learning)
    if city_cal:
        fraction *= city_cal["confidence_mult"]

    bet_fraction = kelly_f * fraction

    # Calculate dollar amount using KELLY_BANKROLL (separate from risk BANKROLL)
    # BANKROLL=$10k is for risk limits / position counting
    # KELLY_BANKROLL=$25 allows Kelly to size $1-$3 based on edge quality
    size = bet_fraction * KELLY_BANKROLL

    # Cap at max per market
    size = min(size, MAX_PER_MARKET)

    return round(max(0, size), 2)

๐Ÿ“œ Git History

3de9313fix(audit): chunk 3 - executor robustness, scheduler, redeem, edge logic5 weeks ago
ddaa0a2fix(audit): chunk 2 - kill switch, auth, config safety5 weeks ago
8fca132chore: initial commit โ€” version control setup5 weeks ago
Show last diff
Loading...