← Назад
"""Edge detection — compares model probability vs market price""" from loguru import logger from config import MIN_EDGE, MIN_VOLUME, EDGE_TIERS, BET_SIZE, BANKROLL, MAX_PER_MARKET, MIN_PRICE, KELLY_FRACTION, MIN_BET, MAX_ENSEMBLE_STD, MAX_SLIPPAGE import db # In-memory cache for city calibration (refreshed each scan cycle) _city_cal_cache = {} _city_cal_ts = 0 # === Safety filters === MAX_EDGE = 0.40 # Skip if edge > 40% — model disagrees too much with market MIN_MARKET_PRICE = 0.05 # Skip side if market price < 5¢ (illiquid / near-resolved) MAX_MARKET_PRICE = 0.95 # Skip side if market price > 95¢ (near-resolved) 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 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. YES filter — don't bet YES when market thinks it's very unlikely # Data: 7/7 YES bets with market_prob < 10% were losses (model overconfident) 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"): # 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 > 40%, 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. Skip near-resolved / heavily skewed markets if buy_price < MIN_MARKET_PRICE: logger.debug(f"Skip {market['id'][:8]}: {side} buy price ${buy_price:.2f} < ${MIN_MARKET_PRICE} (near-resolved)") return None if buy_price > MAX_MARKET_PRICE: logger.debug(f"Skip {market['id'][:8]}: {side} buy price ${buy_price:.2f} > ${MAX_MARKET_PRICE} (near-resolved)") 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) 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: pass 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 size = bet_fraction * BANKROLL # Cap at max per market and max % of bankroll size = min(size, MAX_PER_MARKET) size = min(size, BANKROLL * 0.05) # Never more than 5% of bankroll return round(max(0, size), 2)