← Назад"""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)