← Back
"""
Gamma API scanner — finds active weather markets on Polymarket.

Weather markets are multi-outcome events per city/date.
Each event has ~11 temperature bracket markets (e.g., "56-57°F", "≥74°F").
Access via event slug: highest-temperature-in-{city}-on-{month}-{day}-{year}
"""
import re
import json
import time
import requests
from datetime import datetime, timedelta, timezone
from loguru import logger
from config import GAMMA_API_URL
import db


# Cities with known weather markets on Polymarket (~49 active cities)
# Removed 11 dead cities (no markets): sydney, mumbai, berlin, rome, bangkok,
# dubai, osaka, cairo, nairobi, johannesburg, lima
POLYMARKET_CITIES = [
    # Verified active (29 original + 20 new = 49)
    "nyc", "london", "seoul", "tokyo", "paris", "miami", "toronto",
    "hong-kong", "moscow", "houston", "denver", "shanghai", "wuhan",
    "ankara", "warsaw", "munich", "amsterdam", "tel-aviv", "madrid",
    "los-angeles", "chicago", "seattle", "san-francisco", "singapore",
    "beijing", "mexico-city", "buenos-aires", "sao-paulo", "lagos",
    # New cities discovered on Polymarket (Apr 2026)
    "atlanta", "austin", "busan", "chongqing", "chengdu",
    "shenzhen", "guangzhou", "taipei", "jakarta", "manila",
    "kuala-lumpur", "helsinki", "istanbul", "milan", "jeddah",
    "lucknow", "karachi", "panama-city", "wellington", "cape-town",
]

# Cities that have LOWEST temperature markets (smaller set)
LOWEST_TEMP_CITIES = [
    "nyc", "london", "seoul", "tokyo", "miami", "hong-kong",
    "shanghai", "paris",
]

# Import CITY_COORDS from config (single source of truth)
from config import CITY_COORDS

MONTH_NAMES = {
    1: "january", 2: "february", 3: "march", 4: "april",
    5: "may", 6: "june", 7: "july", 8: "august",
    9: "september", 10: "october", 11: "november", 12: "december"
}


def scan_weather_markets() -> list[dict]:
    """
    Scan Polymarket for active weather events across all cities.
    Checks today + next 2 days for each city.
    Scans both highest-temperature (all cities) and lowest-temperature (8 cities).
    Returns list of parsed events with their bracket markets.
    """
    all_events = []
    now = datetime.now(timezone.utc)

    # Check today, tomorrow, day after
    dates = [now + timedelta(days=d) for d in range(3)]

    for dt in dates:
        date_str = dt.strftime("%Y-%m-%d")
        month_name = MONTH_NAMES[dt.month]
        day = dt.day
        year = dt.year
        slug_date = f"{month_name}-{day}-{year}"

        # 1) Highest temperature — all cities
        for i, city_slug in enumerate(POLYMARKET_CITIES):
            slug = f"highest-temperature-in-{city_slug}-on-{slug_date}"
            event = fetch_event(slug, city_slug, date_str, metric="high_temp")
            if event:
                all_events.append(event)
            # Throttle to avoid 429 from Gamma API
            if i % 5 == 4:
                time.sleep(1.0)

        # 2) Lowest temperature — subset of cities
        for i, city_slug in enumerate(LOWEST_TEMP_CITIES):
            slug = f"lowest-temperature-in-{city_slug}-on-{slug_date}"
            event = fetch_event(slug, city_slug, date_str, metric="low_temp")
            if event:
                all_events.append(event)
            # Throttle to avoid 429 from Gamma API
            if i % 5 == 4:
                time.sleep(1.0)

    logger.info(f"Scan complete: {len(all_events)} weather events found ({len(POLYMARKET_CITIES)} cities highest + {len(LOWEST_TEMP_CITIES)} cities lowest)")
    return all_events


def fetch_event(slug: str, city_slug: str, date: str, metric: str = "high_temp") -> dict | None:
    """Fetch a single weather event by slug"""
    try:
        resp = requests.get(
            f"{GAMMA_API_URL}/events/slug/{slug}",
            timeout=10
        )
        if resp.status_code == 429:
            # Rate-limited: log distinctly from 404 and back off (next cycle retries).
            logger.warning(f"Gamma rate-limited (429) on {slug} - backing off")
            return None
        if resp.status_code != 200:
            return None

        data = resp.json()
        raw_markets = data.get("markets", [])
        if not raw_markets:
            return None

        city_data = CITY_COORDS.get(city_slug, {})

        # Parse bracket markets
        brackets = []
        for m in raw_markets:
            # Skip closed / non-accepting markets (future-dated scan + peak-time skip
            # already avoid resolved markets, but be explicit for robustness).
            if m.get("closed") is True or m.get("acceptingOrders") is False:
                continue
            bracket = parse_bracket_market(m)
            if bracket:
                # Fill city/date/metric from parent event BEFORE saving
                bracket["city"] = city_data.get("name", city_slug)
                bracket["date"] = date
                bracket["metric"] = metric
                brackets.append(bracket)
                # Save individual market to DB
                db.save_market(bracket)

        event = {
            "event_id": data.get("id"),
            "slug": slug,
            "title": data.get("title", ""),
            "city_slug": city_slug,
            "city_name": city_data.get("name", city_slug),
            "date": date,
            "lat": city_data.get("lat"),
            "lon": city_data.get("lon"),
            "tz": city_data.get("tz"),
            "brackets": brackets,
            "total_markets": len(brackets),
        }

        logger.debug(f"Found {len(brackets)} brackets for {city_slug} on {date}")
        return event

    except requests.exceptions.Timeout:
        return None
    except Exception as e:
        logger.error(f"Error fetching {slug}: {e}")
        return None


def parse_bracket_market(raw: dict) -> dict | None:
    """Parse a single bracket market from Gamma API event data"""
    question = raw.get("question", "")
    condition_id = raw.get("conditionId", "")

    if not question or not condition_id:
        return None

    # Extract token IDs (Gamma API returns JSON string, not list)
    clob_tokens_raw = raw.get("clobTokenIds", [])
    if isinstance(clob_tokens_raw, str):
        try:
            clob_tokens = json.loads(clob_tokens_raw)
        except Exception as e:
            logger.debug(f"Failed to parse clobTokenIds: {clob_tokens_raw!r}: {e}")
            clob_tokens = []
    else:
        clob_tokens = clob_tokens_raw
    yes_token = clob_tokens[0] if len(clob_tokens) > 0 else None
    no_token = clob_tokens[1] if len(clob_tokens) > 1 else None

    # Extract prices
    outcome_prices = raw.get("outcomePrices", "")
    yes_price = 0.0
    no_price = 0.0
    try:
        if isinstance(outcome_prices, str) and outcome_prices:
            prices = json.loads(outcome_prices)
            yes_price = float(prices[0]) if len(prices) > 0 else 0.0
            no_price = float(prices[1]) if len(prices) > 1 else 0.0
        elif isinstance(outcome_prices, list):
            yes_price = float(outcome_prices[0]) if len(outcome_prices) > 0 else 0.0
            no_price = float(outcome_prices[1]) if len(outcome_prices) > 1 else 0.0
    except Exception as e:
        logger.debug(f"Failed to parse outcomePrices: {outcome_prices!r}: {e}")

    # Parse temperature bracket from question
    bracket = extract_bracket(question)

    # Prefer volumeNum (numeric); fall back to volume. Parse deterministically so a
    # genuine 0 is not confused with a missing field via an or-chain.
    vol_raw = raw.get("volumeNum")
    if vol_raw is None:
        vol_raw = raw.get("volume", 0)
    try:
        volume = float(vol_raw or 0)
    except (TypeError, ValueError):
        volume = 0.0

    return {
        "id": condition_id,
        "question": question,
        "city": None,  # filled by parent event
        "date": None,  # filled by parent event
        "metric": "high_temp",
        "threshold": bracket.get("mid") or bracket.get("value"),
        "threshold_low": bracket.get("low"),
        "threshold_high": bracket.get("high"),
        "threshold_unit": bracket.get("unit", "F"),
        "operator": bracket.get("operator", "between"),
        "yes_token_id": yes_token,
        "no_token_id": no_token,
        "yes_price": yes_price,
        "no_price": no_price,
        "volume": volume,
        "end_date": raw.get("endDate"),
        "resolution_source": raw.get("resolutionSource", ""),
    }


def extract_bracket(question: str) -> dict:
    """
    Extract temperature bracket from question text.

    Examples:
    - "...be 55°F or below..." → {operator: "lte", value: 55, unit: "F"}
    - "...be between 56-57°F..." → {operator: "between", low: 56, high: 57, mid: 56.5, unit: "F"}
    - "...be 74°F or higher..." → {operator: "gte", value: 74, unit: "F"}
    - "...be 13°C or below..." → {operator: "lte", value: 13, unit: "C"}
    """
    q = question

    # Detect unit
    unit = "C" if "°C" in q else "F"

    # Pattern: "between X-Y°F/°C"
    match = re.search(r'between\s+(\d+)[–-](\d+)\s*°', q)
    if match:
        low = float(match.group(1))
        high = float(match.group(2))
        return {"operator": "between", "low": low, "high": high, "mid": (low + high) / 2, "unit": unit}

    # Pattern: "be X°F/°C on" (exact single value)
    match = re.search(r'be\s+(\d+)\s*°[FC]\s+on', q)
    if match:
        val = float(match.group(1))
        return {"operator": "eq", "value": val, "low": val, "high": val, "mid": val, "unit": unit}

    # Pattern: "X°F or below/lower"
    match = re.search(r'(\d+)\s*°[FC]\s+or\s+(?:below|lower)', q)
    if match:
        val = float(match.group(1))
        return {"operator": "lte", "value": val, "high": val, "mid": val, "unit": unit}

    # Pattern: "X°F or higher/above"
    match = re.search(r'(\d+)\s*°[FC]\s+or\s+(?:higher|above|more)', q)
    if match:
        val = float(match.group(1))
        return {"operator": "gte", "value": val, "low": val, "mid": val, "unit": unit}

    return {"operator": "unknown", "unit": unit}

📜 Git History

058de34fix(audit): chunk 4 - minor robustness, display, calibration5 weeks ago
8fca132chore: initial commit — version control setup5 weeks ago
Show last diff
Loading...