"""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)