Skill: mrjoeteam/simmer-weather-trader (version 1.0.0)

--- name: simmer-weather-trader description: Automated weather prediction market trading skill for Simmer/Polymarket. Cross-references 4 weather sources (NOAA, Open-Meteo, Wunderground, NVIDIA FourcastNet) and only trades when all forecasts agree within ±1°F. Remixable — add your own data sources or adjust the confidence thresholds. author: "Joe" version: "1.0.0" displayName: "Simmer Weather Trader" difficulty: "advanced" --- # Simmer Weather Trader An automated trading bot for Simmer weather prediction markets. Fetches active weather markets, cross-references temperature forecasts from **4 independent sources**, and only trades when the consensus is strong. ## How it works 1. **Market Discovery** — fetches active weather markets from Simmer via the SDK 2. **Multi-Source Forecast** — gets high temperature predictions from: - **NOAA** (US government weather API) - **Open-Meteo** (free global weather API) - **Wunderground** (scraped via Playwright for broader coverage) - **NVIDIA FourcastNet** (physics-based atmospheric model) 3. **Confidence Scoring** — computes a 0–100 score based on: - Source agreement (all within ±1°F) - Market bucket fit - Simmer edge recommendation - Time to resolution 4. **Execution** — only trades when score reaches 100 (maximum confidence) ## Default signal The default strategy is **conservative multi-source consensus**: - All 3 weather sources must agree within ±1°F - FourcastNet must confirm the bucket - Simmer edge must recommend TRADE - Only YES trades (betting the temp falls within the market bucket) > **This is a template.** The default signal uses 4 weather models. Remix it by: > - Adding more weather sources (AccuWeather, Weather.com, etc.) > - Adjusting the agreement threshold (currently strict ±1°F) > - Adding NO trades (betting the temp falls outside the bucket) > - Using ML models trained on historical forecast accuracy per source ## Setup ### Environment variables ```bash SIMMER_API_KEY=your_key # Required — from simmer.markets NVIDIA_API_KEY=your_key # Required — for FourcastNet TELEGRAM_BOT_TOKEN=your_token # Optional — for Telegram UI TRADE_AMOUNT=10.0 # Optional — default $10 CONFIDENCE_THRESHOLD=100 # Optional — default max confidence only SIMMER_BASE_URL=https://api.simmer.markets # Optional SIMMER_VENUE=sim # Optional — default "sim" ``` ### Dependencies ```bash pip install httpx python-telegram-bot python-dotenv numpy pip install netCDF4 # For FourcastNet output parsing pip install playwright # For Wunderground scraping playwright install chromium # Required for Wunderground ``` ## Supported cities New York, Los Angeles, Chicago, Miami, Houston, Phoenix, Philadelphia, San Francisco, Seattle, Denver, Boston, Atlanta, Dallas, Minneapolis, Las Vegas, Detroit, Portland, San Antonio, San Diego, Milan, Madrid, Tel Aviv, London, Paris, Berlin, Tokyo. Add more in `city_map.py`. ## Remix guide Swap in your own signals: - **Different weather sources**: Replace or add forecast functions in the `simmer_weather_bot/` folder - **Different scoring**: Modify `compute_confidence()` in `strategy.py` - **Add NO trades**: Extend the strategy to also bet against consensus - **ML-based**: Train a model on historical forecast accuracy and replace the simple agreement check The plumbing (market discovery, trade execution, Telegram UI, health checks) stays the same. ## Hard rules - Always defaults to dry-run. Pass `--live` for real trades. - Always tags trades with source and skill_slug for tracking. - Always includes reasoning with the weather data used. - Reads API keys from env — never hardcodes credentials.

path: clawhub.json
content: {
  "emoji": "🌦️",
  "requires": {
    "pip": ["simmer-sdk", "httpx", "python-telegram-bot", "numpy", "netCDF4"],
    "env": ["SIMMER_API_KEY", "NVIDIA_API_KEY"]
  },
  "cron": "*/30 * * * *",
  "automaton": {
    "managed": true,
    "entrypoint": "simmer_weather_bot/main.py"
  }
}


path: simmer_weather_bot/README.md
content: # Simmer Weather Trading Bot

A production-grade Python Telegram bot that discovers weather prediction markets on Simmer Markets, fetches real temperature forecasts from three independent live APIs, uses DeepSeek AI to reason over the data, and executes trades exclusively through the Simmer SDK.

## Features

- **Startup health checks** — verifies all 6 APIs before the bot activates
- **Real forecasts** from NOAA, Open-Meteo, and Wunderground (Playwright)
- **DeepSeek-V3.1 AI reasoning** via NVIDIA NIM
- **Confidence scoring** — trades only when score == 100%
- **Simmer SDK trading** — all trades via `venue="sim"` (virtual $SIM)
- **Zero mock data** — every number comes from a live API

## File Structure

```
simmer_weather_bot/
├── main.py             # Entry point — health checks first, then bot
├── health_check.py     # Startup API health checker for all 6 services
├── telegram_ui.py      # Inline keyboard + formatted message output
├── simmer_client.py    # Simmer API wrapper (markets, context, trade, positions)
├── noaa.py             # NOAA National Weather Service live forecast
├── openmeteo.py        # Open-Meteo live forecast
├── wunderground.py     # Wunderground live forecast (Playwright headless)
├── ai_analyzer.py      # DeepSeek AI reasoning via NVIDIA NIM
├── strategy.py         # Confidence scoring (0–100, trade only at 100)
├── city_map.py         # City name → lat/lon + Wunderground slug resolver
├── config.py           # All env vars loaded from environment/dotenv
├── requirements.txt
├── .env.example
└── README.md
```

## Setup

### 1. Install dependencies

```bash
pip install -r requirements.txt
python -m playwright install chromium
```

### 2. Configure secrets

Copy `.env.example` to `.env` and fill in:

```
TELEGRAM_BOT_TOKEN=   # from @BotFather on Telegram
SIMMER_API_KEY=       # your Simmer Markets sk_live_... key
NVIDIA_API_KEY=       # your NVIDIA NIM nvapi-... key
```

All other values have defaults and are pre-configured.

### 3. Run the bot

```bash
python main.py
```

### Startup output (if all APIs pass)

```
============================================================
🔍 SIMMER WEATHER BOT — STARTUP HEALTH CHECK
============================================================
  ✅ Simmer API Auth           PASS | Agent: abc123 | Balance: ...
  ✅ Simmer Markets            PASS | 8 markets found
  ✅ NOAA Weather API          PASS | Grid: OKX
  ✅ Open-Meteo API            PASS | Sample: 42°F
  ✅ NVIDIA AI API             PASS | Response: READY
  ✅ Wunderground Scraper      PASS | Sample: 43°F
============================================================
🟢 ALL SYSTEMS GO — Bot is starting...
============================================================
🚀 Starting Telegram bot...
```

If any check fails, the bot exits immediately and reports exactly which API failed and why.

## Telegram Commands

- `/start` — Fetches live markets, shows 4 prediction buttons
- `/status` — Shows agent balance and open positions
- **Predict & Bet buttons** — Runs full pipeline: 3 forecasts → AI → trade/skip

## Trade Logic

A trade only executes when **ALL** of these are true simultaneously:

1. All three forecasts (NOAA, Open-Meteo, Wunderground) within ±1°F of each other
2. Consensus temperature falls inside the market's temperature bucket
3. Simmer edge recommendation is "TRADE"
4. DeepSeek AI verdict is "TRADE" with all conditions confirmed
5. Confidence score == 100%

## City Map

Add new cities to `city_map.py` as new markets appear. Each entry needs:
- `lat`, `lon` — coordinates for NOAA and Open-Meteo
- `wunderground` — URL path segment (e.g. `"ny/new-york"`)
- `timezone` — IANA timezone string

## Logs

All activity is logged to `bot.log` with timestamps and to stdout.


path: simmer_weather_bot/ai_analyzer.py
content: #!/usr/bin/env python3
"""
NVIDIA FourcastNet atmospheric model — used as the AI forecast source.
Replaces the DeepSeek LLM with a deterministic global weather model.
Submits a forecast job, polls until complete, extracts 2m temperature
at the target lat/lon, then produces a structured verdict.
"""

import os
import io
import time
import zipfile
import logging
import tempfile
import asyncio
from concurrent.futures import ThreadPoolExecutor
from datetime import date, datetime, timezone

import requests
import numpy as np

from config import (
    NVIDIA_API_KEY,
    FOURCASTNET_URL,
    FOURCASTNET_POLL_URL,
    FOURCASTNET_POLL_SECONDS,
    FOURCASTNET_MAX_POLLS,
)

logger = logging.getLogger(__name__)

_executor = ThreadPoolExecutor(max_workers=2)


def _call_fourcastnet_sync(lat: float, lon: float, target_date: date, min_temp: int, max_temp: int) -> dict:
    """
    Synchronous FourcastNet call — runs in a thread executor.
    Submits job, polls, parses netCDF zip output, returns verdict dict.
    """
    headers = {
        "Authorization": f"Bearer {NVIDIA_API_KEY}",
        "NVCF-POLL-SECONDS": str(FOURCASTNET_POLL_SECONDS),
    }

    now_utc = datetime.now(timezone.utc)
    target_dt = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
    hours_ahead = max(0, (target_dt - now_utc).total_seconds() / 3600)
    simulation_length = max(1, min(int(hours_ahead / 6) + 1, 20))

    payload = {
        "input_id": 0,
        "variables": "t2m",
        "simulation_length": simulation_length,
        "ensemble_size": 1,
        "noise_amplitude": 0,
    }

    logger.info(
        f"FourcastNet: submitting job for {lat},{lon} on {target_date} "
        f"(hours_ahead={hours_ahead:.1f}, steps={simulation_length})"
    )

    session = requests.Session()
    response = session.post(FOURCASTNET_URL, headers=headers, json=payload, timeout=60)
    response.raise_for_status()

    if response.status_code == 200:
        logger.info("FourcastNet: synchronous response received")
        return _parse_and_verdict(response.content, lat, lon, min_temp, max_temp)

    if response.status_code != 202:
        raise Exception(f"FourcastNet unexpected initial status {response.status_code}")

    request_id = response.headers.get("nvcf-reqid")
    if not request_id:
        raise Exception("FourcastNet 202 response missing nvcf-reqid header")

    logger.info(f"FourcastNet: polling job {request_id}")
    status_url = f"{FOURCASTNET_POLL_URL}/{request_id}"

    for poll_num in range(FOURCASTNET_MAX_POLLS):
        response = session.get(status_url, headers=headers, allow_redirects=False, timeout=30)
        response.raise_for_status()

        if response.status_code == 200:
            logger.info(f"FourcastNet: job complete after {poll_num + 1} polls")
            return _parse_and_verdict(response.content, lat, lon, min_temp, max_temp)

        elif response.status_code == 302:
            asset_url = response.headers.get("Location")
            logger.info(f"FourcastNet: downloading large asset from {asset_url}")
            with requests.get(asset_url, stream=True, timeout=120) as r:
                r.raise_for_status()
                return _parse_and_verdict(r.content, lat, lon, min_temp, max_temp)

        elif response.status_code == 202:
            logger.info(f"FourcastNet: job still running (poll {poll_num + 1}/{FOURCASTNET_MAX_POLLS})")
            time.sleep(FOURCASTNET_POLL_SECONDS)

        else:
            raise Exception(f"FourcastNet unexpected poll status {response.status_code}")

    raise Exception(f"FourcastNet job timed out after {FOURCASTNET_MAX_POLLS} polls")


def _parse_and_verdict(content: bytes, lat: float, lon: float, min_temp: int, max_temp: int) -> dict:
    """
    Parses the FourcastNet zip output. Extracts 2m temperature (t2m) at the
    nearest grid point to lat/lon and converts Kelvin → Fahrenheit.
    Returns a structured verdict dict matching the original AI analyzer interface.
    """
    import netCDF4 as nc

    if len(content) < 100:
        raise Exception(f"FourcastNet response too small to be valid: {len(content)} bytes")

    try:
        zf = zipfile.ZipFile(io.BytesIO(content))
    except zipfile.BadZipFile:
        raise Exception(
            f"FourcastNet response is not a valid zip file. "
            f"First 200 bytes: {content[:200]}"
        )

    nc_files = sorted([f for f in zf.namelist() if f.endswith(".nc") or f.endswith(".nc4")])
    logger.info(f"FourcastNet zip contains {len(nc_files)} netCDF files: {nc_files}")

    if not nc_files:
        all_files = zf.namelist()
        raise Exception(
            f"No .nc files found in FourcastNet zip. "
            f"Zip contents: {all_files}"
        )

    nc_file = nc_files[-1]
    nc_data = zf.read(nc_file)

    with tempfile.NamedTemporaryFile(suffix=".nc", delete=False) as tmp:
        tmp.write(nc_data)
        tmp_path = tmp.name

    try:
        ds = nc.Dataset(tmp_path)
        logger.info(f"FourcastNet netCDF variables: {list(ds.variables.keys())}")
        logger.info(f"FourcastNet netCDF dimensions: {list(ds.dimensions.keys())}")

        lat_candidates = ["lat", "latitude", "y", "LAT", "XLAT"]
        lon_candidates = ["lon", "longitude", "x", "LON", "XLONG"]

        lats = None
        for name in lat_candidates:
            if name in ds.variables:
                lats = ds.variables[name][:]
                break

        lons = None
        for name in lon_candidates:
            if name in ds.variables:
                lons = ds.variables[name][:]
                break

        if lats is None:
            n_lat = ds.dimensions.get("lat", ds.dimensions.get("latitude", ds.dimensions.get("y"))).size
            lats = np.linspace(-90, 90, n_lat)
        if lons is None:
            n_lon = ds.dimensions.get("lon", ds.dimensions.get("longitude", ds.dimensions.get("x"))).size
            lons = np.linspace(0, 360, n_lon)

        target_lon = lon if lon >= 0 else lon + 360
        lat_idx = int(np.argmin(np.abs(np.array(lats) - lat)))
        lon_idx = int(np.argmin(np.abs(np.array(lons) - target_lon)))

        t2m_candidates = ["t2m", "T2m", "T2M", "temperature", "2m_temperature", "tas", "temp", "T"]
        t2m_var = None
        for name in t2m_candidates:
            if name in ds.variables:
                t2m_var = ds.variables[name]
                break

        if t2m_var is None:
            raise Exception(
                f"No 2m temperature variable found. "
                f"Available: {list(ds.variables.keys())}"
            )

        shape = t2m_var.shape
        logger.info(f"FourcastNet t2m shape: {shape}")

        if len(shape) == 3:
            t_raw = float(t2m_var[-1, lat_idx, lon_idx])
        elif len(shape) == 2:
            t_raw = float(t2m_var[lat_idx, lon_idx])
        elif len(shape) == 4:
            t_raw = float(t2m_var[-1, 0, lat_idx, lon_idx])
        else:
            t_raw = float(t2m_var.flatten()[-1])

        ds.close()

        if t_raw > 200:
            t_celsius = t_raw - 273.15
        else:
            t_celsius = t_raw

        t_fahrenheit = int(round((t_celsius * 9 / 5) + 32))
        logger.info(
            f"FourcastNet: extracted t2m={t_raw:.2f} → {t_celsius:.1f}°C → {t_fahrenheit}°F "
            f"at lat_idx={lat_idx}, lon_idx={lon_idx}"
        )

    finally:
        os.unlink(tmp_path)

    inside_bucket = min_temp <= t_fahrenheit <= max_temp

    verdict = "TRADE" if inside_bucket else "SKIP"
    if inside_bucket:
        reason = (
            f"FourcastNet predicts {t_fahrenheit}°F at this location, "
            f"which falls inside the market bucket [{min_temp}–{max_temp}°F]."
        )
    else:
        diff = min(abs(t_fahrenheit - min_temp), abs(t_fahrenheit - max_temp))
        reason = (
            f"FourcastNet predicts {t_fahrenheit}°F at this location, "
            f"which is {diff}°F outside the market bucket [{min_temp}–{max_temp}°F]."
        )

    return {
        "predicted_temp": t_fahrenheit,
        "sources_agree": None,
        "inside_bucket": inside_bucket,
        "simmer_edge_confirms": None,
        "verdict": verdict,
        "reason": reason,
        "model": "nvidia/fourcastnet",
    }


async def analyze_with_ai(
    market: dict,
    context: dict,
    noaa_temp: int,
    openmeteo_temp: int,
    wunderground_temp: int,
) -> dict:
    """
    Calls NVIDIA FourcastNet atmospheric model for a 4th independent temperature forecast.
    Returns a verdict dict with predicted_temp, inside_bucket, verdict, reason.
    Raises an exception if the API call fails or response cannot be parsed.
    """
    from city_map import resolve_city

    city_name = market.get("city", "")
    try:
        city_data = resolve_city(city_name)
    except ValueError:
        raise Exception(f"Cannot resolve city '{city_name}' for FourcastNet coordinates")

    lat = city_data["lat"]
    lon = city_data["lon"]
    target_date = market.get("date")
    if target_date is None:
        from datetime import date as date_cls
        target_date = date_cls.today()

    min_temp = market["min_temp"]
    max_temp = market["max_temp"]

    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        _executor,
        _call_fourcastnet_sync,
        lat, lon, target_date, min_temp, max_temp
    )

    consensus = round((noaa_temp + openmeteo_temp + wunderground_temp) / 3)
    spread_3 = max(noaa_temp, openmeteo_temp, wunderground_temp) - min(noaa_temp, openmeteo_temp, wunderground_temp)

    fc_temp = result["predicted_temp"]
    all_four = [noaa_temp, openmeteo_temp, wunderground_temp, fc_temp]
    spread_4 = max(all_four) - min(all_four)
    sources_agree = spread_3 <= 1
    simmer_edge = context.get("edge", {}).get("recommendation")
    simmer_edge_confirms = simmer_edge == "TRADE"

    result["sources_agree"] = sources_agree
    result["simmer_edge_confirms"] = simmer_edge_confirms

    if not sources_agree:
        result["verdict"] = "SKIP"
        result["reason"] = (
            f"FourcastNet: {fc_temp}°F. "
            f"Source spread={spread_3}°F exceeds ±1°F limit "
            f"(NOAA={noaa_temp}, OpenMeteo={openmeteo_temp}, Wunderground={wunderground_temp}). "
            f"All sources must agree within ±1°F."
        )
    elif not result["inside_bucket"]:
        pass
    else:
        result["verdict"] = "TRADE"

    logger.info(
        f"FourcastNet verdict: {result['verdict']} | "
        f"fc_temp={fc_temp}°F | sources_agree={sources_agree} | "
        f"inside_bucket={result['inside_bucket']} | "
        f"reason={result['reason']}"
    )
    return result


path: simmer_weather_bot/city_map.py
content: CITIES = {
    "New York": {
        "lat": 40.7128, "lon": -74.0060,
        "wunderground": "ny/new-york",
        "timezone": "America/New_York"
    },
    "New York City": {
        "lat": 40.7128, "lon": -74.0060,
        "wunderground": "ny/new-york",
        "timezone": "America/New_York"
    },
    "NYC": {
        "lat": 40.7128, "lon": -74.0060,
        "wunderground": "ny/new-york",
        "timezone": "America/New_York"
    },
    "Los Angeles": {
        "lat": 34.0522, "lon": -118.2437,
        "wunderground": "ca/los-angeles",
        "timezone": "America/Los_Angeles"
    },
    "Chicago": {
        "lat": 41.8781, "lon": -87.6298,
        "wunderground": "il/chicago",
        "timezone": "America/Chicago"
    },
    "Miami": {
        "lat": 25.7617, "lon": -80.1918,
        "wunderground": "fl/miami",
        "timezone": "America/New_York"
    },
    "Houston": {
        "lat": 29.7604, "lon": -95.3698,
        "wunderground": "tx/houston",
        "timezone": "America/Chicago"
    },
    "Phoenix": {
        "lat": 33.4484, "lon": -112.0740,
        "wunderground": "az/phoenix",
        "timezone": "America/Phoenix"
    },
    "Philadelphia": {
        "lat": 39.9526, "lon": -75.1652,
        "wunderground": "pa/philadelphia",
        "timezone": "America/New_York"
    },
    "San Francisco": {
        "lat": 37.7749, "lon": -122.4194,
        "wunderground": "ca/san-francisco",
        "timezone": "America/Los_Angeles"
    },
    "Seattle": {
        "lat": 47.6062, "lon": -122.3321,
        "wunderground": "wa/seattle",
        "timezone": "America/Los_Angeles"
    },
    "Denver": {
        "lat": 39.7392, "lon": -104.9903,
        "wunderground": "co/denver",
        "timezone": "America/Denver"
    },
    "Boston": {
        "lat": 42.3601, "lon": -71.0589,
        "wunderground": "ma/boston",
        "timezone": "America/New_York"
    },
    "Atlanta": {
        "lat": 33.7490, "lon": -84.3880,
        "wunderground": "ga/atlanta",
        "timezone": "America/New_York"
    },
    "Dallas": {
        "lat": 32.7767, "lon": -96.7970,
        "wunderground": "tx/dallas",
        "timezone": "America/Chicago"
    },
    "Minneapolis": {
        "lat": 44.9778, "lon": -93.2650,
        "wunderground": "mn/minneapolis",
        "timezone": "America/Chicago"
    },
    "Las Vegas": {
        "lat": 36.1699, "lon": -115.1398,
        "wunderground": "nv/las-vegas",
        "timezone": "America/Los_Angeles"
    },
    "Detroit": {
        "lat": 42.3314, "lon": -83.0458,
        "wunderground": "mi/detroit",
        "timezone": "America/Detroit"
    },
    "Portland": {
        "lat": 45.5051, "lon": -122.6750,
        "wunderground": "or/portland",
        "timezone": "America/Los_Angeles"
    },
    "San Antonio": {
        "lat": 29.4241, "lon": -98.4936,
        "wunderground": "tx/san-antonio",
        "timezone": "America/Chicago"
    },
    "San Diego": {
        "lat": 32.7157, "lon": -117.1611,
        "wunderground": "ca/san-diego",
        "timezone": "America/Los_Angeles"
    },
    "Milan": {
        "lat": 45.4654, "lon": 9.1859,
        "wunderground": "italy/milan",
        "timezone": "Europe/Rome",
        "unit": "C"
    },
    "Madrid": {
        "lat": 40.4168, "lon": -3.7038,
        "wunderground": "spain/madrid",
        "timezone": "Europe/Madrid",
        "unit": "C"
    },
    "Tel Aviv": {
        "lat": 32.0853, "lon": 34.7818,
        "wunderground": "israel/tel-aviv",
        "timezone": "Asia/Jerusalem",
        "unit": "C"
    },
    "London": {
        "lat": 51.5074, "lon": -0.1278,
        "wunderground": "england/london",
        "timezone": "Europe/London",
        "unit": "C"
    },
    "Paris": {
        "lat": 48.8566, "lon": 2.3522,
        "wunderground": "france/paris",
        "timezone": "Europe/Paris",
        "unit": "C"
    },
    "Berlin": {
        "lat": 52.5200, "lon": 13.4050,
        "wunderground": "germany/berlin",
        "timezone": "Europe/Berlin",
        "unit": "C"
    },
    "Tokyo": {
        "lat": 35.6762, "lon": 139.6503,
        "wunderground": "japan/tokyo",
        "timezone": "Asia/Tokyo",
        "unit": "C"
    },
}


def resolve_city(city_name: str) -> dict:
    """Returns city data dict or raises ValueError if not found."""
    city_lower = city_name.lower().strip()
    for key, data in CITIES.items():
        if key.lower() == city_lower:
            return data
    for key, data in CITIES.items():
        if key.lower() in city_lower or city_lower in key.lower():
            return data
    raise ValueError(
        f"City '{city_name}' not found in city map. "
        f"Available: {', '.join(CITIES.keys())}. Add it to city_map.py."
    )


def celsius_to_fahrenheit(c: float) -> int:
    return int(round((c * 9 / 5) + 32))


path: simmer_weather_bot/config.py
content: import os
from dotenv import load_dotenv

load_dotenv()

TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]

SIMMER_API_KEY = os.environ["SIMMER_API_KEY"]
SIMMER_BASE_URL = os.environ.get("SIMMER_BASE_URL", "https://api.simmer.markets")
SIMMER_VENUE = os.environ.get("SIMMER_VENUE", "sim")

NVIDIA_API_KEY = os.environ["NVIDIA_API_KEY"]
FOURCASTNET_URL = "https://climate.api.nvidia.com/v1/nvidia/fourcastnet"
FOURCASTNET_POLL_URL = "https://api.nvcf.nvidia.com/v2/nvcf/pexec/status"
FOURCASTNET_POLL_SECONDS = 5

NOAA_API_BASE = os.environ.get("NOAA_API_BASE", "https://api.weather.gov")
OPEN_METEO_API = os.environ.get("OPEN_METEO_API", "https://api.open-meteo.com/v1/forecast")
WUNDERGROUND_BASE = os.environ.get("WUNDERGROUND_BASE", "https://www.wunderground.com/forecast/us")

TRADE_AMOUNT = float(os.environ.get("TRADE_AMOUNT", "10.0"))
CONFIDENCE_THRESHOLD = int(os.environ.get("CONFIDENCE_THRESHOLD", "100"))

NOAA_USER_AGENT = "SimmerWeatherBot/1.0 ([REDACTED_SECRET])"

HTTP_TIMEOUT = 30.0
PLAYWRIGHT_TIMEOUT = 20000
FOURCASTNET_MAX_POLLS = 40


path: simmer_weather_bot/health_check.py
content: import httpx
import sys
import time
import logging
import requests
from config import (
    SIMMER_API_KEY, SIMMER_BASE_URL,
    NOAA_API_BASE, NOAA_USER_AGENT,
    OPEN_METEO_API,
    NVIDIA_API_KEY, FOURCASTNET_URL, FOURCASTNET_POLL_URL,
    HTTP_TIMEOUT,
)

logger = logging.getLogger(__name__)

SIMMER_HEADERS = {"Authorization": f"Bearer {SIMMER_API_KEY}"}


async def check_simmer(client: httpx.AsyncClient) -> dict:
    url = f"{SIMMER_BASE_URL}/api/sdk/agents/me"
    response = await client.get(url, headers=SIMMER_HEADERS)
    response.raise_for_status()
    data = response.json()

    return {
        "status": "PASS",
        "agent_id": data.get("agent_id", data.get("id", "unknown")),
        "balance": data.get("balance", "N/A"),
        "trading_enabled": data.get("limits", {}).get("simmer", True),
        "rate_limits": data.get("rate_limits", {}),
    }


async def check_simmer_markets(client: httpx.AsyncClient) -> dict:
    url = f"{SIMMER_BASE_URL}/api/sdk/markets"
    params = {"tags": "weather", "status": "active", "limit": 10}
    response = await client.get(url, headers=SIMMER_HEADERS, params=params)
    response.raise_for_status()

    raw = response.json()
    if isinstance(raw, dict):
        markets = raw.get("markets", raw.get("data", raw.get("results", [])))
    else:
        markets = raw

    if len(markets) == 0:
        raise Exception("No active weather markets found on Simmer")

    return {
        "status": "PASS",
        "markets_found": len(markets),
        "sample_question": markets[0].get("question", "N/A")[:80],
    }


async def check_noaa(client: httpx.AsyncClient) -> dict:
    url = f"{NOAA_API_BASE}/points/40.7128,-74.0060"
    headers = {"User-Agent": NOAA_USER_AGENT}
    response = await client.get(url, headers=headers)
    response.raise_for_status()

    data = response.json()
    props = data.get("properties", {})
    forecast_url = props.get("forecast")

    if not forecast_url:
        raise Exception("NOAA returned no forecast URL in properties.forecast")

    return {
        "status": "PASS",
        "forecast_url": forecast_url,
        "grid_id": props.get("gridId", "N/A"),
    }


async def check_openmeteo(client: httpx.AsyncClient) -> dict:
    params = {
        "latitude": 40.7128,
        "longitude": -74.0060,
        "daily": "temperature_2m_max",
        "temperature_unit": "fahrenheit",
        "timezone": "America/New_York",
        "forecast_days": 3,
    }
    response = await client.get(OPEN_METEO_API, params=params)
    response.raise_for_status()

    data = response.json()
    temps = data.get("daily", {}).get("temperature_2m_max", [])

    if not temps:
        raise Exception("Open-Meteo returned no temperature data in daily.temperature_2m_max")

    return {
        "status": "PASS",
        "sample_temp": f"{round(temps[0])}°F",
        "timezone": data.get("timezone", "N/A"),
    }


async def check_wunderground() -> dict:
    from playwright.async_api import async_playwright

    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=["--no-sandbox", "--disable-dev-shm-usage"]
        )
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        )
        page = await context.new_page()

        try:
            await page.goto(
                "https://www.wunderground.com/forecast/us/ny/new-york",
                timeout=20000, wait_until="domcontentloaded"
            )
            await page.wait_for_timeout(4000)

            temp_text = None
            selectors = [
                ".wu-unit-temperature .wu-value",
                "[data-testid='TemperatureValue']",
                ".today_nowcard-temp span",
                "span.temp",
            ]
            for sel in selectors:
                els = await page.query_selector_all(sel)
                for el in els:
                    t = (await el.inner_text()).strip().replace("°", "").replace("F", "").strip()
                    if t.lstrip("-").isdigit():
                        temp_text = t
                        break
                if temp_text:
                    break

            if not temp_text:
                import re
                content = await page.content()
                matches = re.findall(r'"temperature":\s*(\d+)', content)
                if matches:
                    temp_text = matches[0]

            if not temp_text:
                raise Exception(
                    "Wunderground page loaded but no temperature element found. "
                    "Page may have anti-scraping measures or changed DOM."
                )

            return {"status": "PASS", "sample_temp": f"{temp_text}°F"}
        finally:
            await browser.close()


def check_fourcastnet_sync() -> dict:
    """
    Validates FourcastNet API key by submitting a minimal job and confirming 202 acceptance.
    Does NOT wait for completion — just verifies the key is valid and job is accepted.
    """
    headers = {
        "Authorization": f"Bearer {NVIDIA_API_KEY}",
        "NVCF-POLL-SECONDS": "5",
    }
    payload = {
        "input_id": 0,
        "variables": "t2m",
        "simulation_length": 1,
        "ensemble_size": 1,
        "noise_amplitude": 0,
    }

    session = requests.Session()
    response = session.post(FOURCASTNET_URL, headers=headers, json=payload, timeout=30)

    if response.status_code == 401:
        raise Exception("FourcastNet: API key rejected (401 Unauthorized)")
    if response.status_code == 403:
        raise Exception("FourcastNet: API key forbidden (403). Check NVIDIA_API_KEY permissions.")
    if response.status_code == 404:
        raise Exception(f"FourcastNet: endpoint not found (404). URL: {FOURCASTNET_URL}")

    if response.status_code == 200:
        return {
            "status": "PASS",
            "note": "Synchronous response (job completed immediately)",
            "job_id": "sync",
        }

    if response.status_code == 202:
        job_id = response.headers.get("nvcf-reqid", "unknown")
        return {
            "status": "PASS",
            "note": f"Job accepted and queued",
            "job_id": job_id,
        }

    raise Exception(
        f"FourcastNet unexpected status {response.status_code}: {response.text[:200]}"
    )


async def run_all_health_checks() -> bool:
    print("\n" + "=" * 60)
    print("🔍 SIMMER WEATHER BOT — STARTUP HEALTH CHECK")
    print("=" * 60)

    results = {}
    all_passed = True

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        checks = [
            ("Simmer API Auth", lambda: check_simmer(client)),
            ("Simmer Markets", lambda: check_simmer_markets(client)),
            ("NOAA Weather API", lambda: check_noaa(client)),
            ("Open-Meteo API", lambda: check_openmeteo(client)),
        ]

        for name, check_fn in checks:
            try:
                result = await check_fn()
                results[name] = result
                extras = ""
                if name == "Simmer API Auth":
                    extras = f" | Agent: {result.get('agent_id')} | Balance: {result.get('balance')}"
                elif name == "Simmer Markets":
                    extras = f" | {result.get('markets_found')} markets found"
                elif name == "NOAA Weather API":
                    extras = f" | Grid: {result.get('grid_id')}"
                elif name == "Open-Meteo API":
                    extras = f" | Sample: {result.get('sample_temp')}"
                print(f"  ✅ {name:<25} PASS{extras}")
            except Exception as e:
                results[name] = {"status": "FAIL", "error": str(e)}
                print(f"  ❌ {name:<25} FAIL — {str(e)[:120]}")
                all_passed = False

    try:
        result = await check_wunderground()
        results["Wunderground Scraper"] = result
        print(f"  ✅ {'Wunderground Scraper':<25} PASS | Sample: {result.get('sample_temp')}")
    except Exception as e:
        results["Wunderground Scraper"] = {"status": "FAIL", "error": str(e)}
        print(f"  ❌ {'Wunderground Scraper':<25} FAIL — {str(e)[:120]}")
        all_passed = False

    try:
        result = check_fourcastnet_sync()
        results["NVIDIA FourcastNet"] = result
        print(f"  ✅ {'NVIDIA FourcastNet':<25} PASS | {result.get('note')} | Job: {result.get('job_id', 'N/A')[:16]}")
    except Exception as e:
        results["NVIDIA FourcastNet"] = {"status": "FAIL", "error": str(e)}
        print(f"  ❌ {'NVIDIA FourcastNet':<25} FAIL — {str(e)[:120]}")
        all_passed = False

    print("=" * 60)

    if all_passed:
        print("🟢 ALL SYSTEMS GO — Bot is starting...")
        print("=" * 60 + "\n")
    else:
        failed = [k for k, v in results.items() if v.get("status") == "FAIL"]
        print(f"🔴 BOT STARTUP ABORTED — {len(failed)} API(s) failed:")
        for f in failed:
            print(f"   • {f}: {results[f]['error']}")
        print("=" * 60)
        print("Fix the above APIs before restarting the bot.")
        sys.exit(1)

    return True


path: simmer_weather_bot/main.py
content: import asyncio
import logging
import sys
import os

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
    handlers=[
        logging.FileHandler("bot.log"),
        logging.StreamHandler(sys.stdout),
    ],
)

logger = logging.getLogger(__name__)

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from health_check import run_all_health_checks
from telegram.ext import ApplicationBuilder, CallbackQueryHandler, CommandHandler
from telegram_ui import handle_start, handle_button, handle_status
from config import TELEGRAM_BOT_TOKEN


def main():
    logger.info("Simmer Weather Bot starting — running health checks")

    asyncio.get_event_loop().run_until_complete(run_all_health_checks())

    logger.info("All health checks passed — starting Telegram bot")
    print("🚀 Starting Telegram bot...")

    app = (
        ApplicationBuilder()
        .token(TELEGRAM_BOT_TOKEN)
        .build()
    )

    app.add_handler(CommandHandler("start", handle_start))
    app.add_handler(CommandHandler("status", handle_status))
    app.add_handler(CallbackQueryHandler(handle_button, pattern="^(predict_|status)"))

    logger.info("Bot is polling for updates")
    app.run_polling(drop_pending_updates=True)


if __name__ == "__main__":
    main()


path: simmer_weather_bot/noaa.py
content: import httpx
import logging
from datetime import date
from config import NOAA_API_BASE, NOAA_USER_AGENT, HTTP_TIMEOUT

logger = logging.getLogger(__name__)


async def get_noaa_forecast(lat: float, lon: float, target_date: date) -> int:
    """
    Fetches the NOAA high temperature forecast for the given lat/lon and target date.
    Returns an integer temperature in Fahrenheit.
    Raises an exception if the forecast cannot be retrieved.
    """
    headers = {"User-Agent": NOAA_USER_AGENT}

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        points_url = f"{NOAA_API_BASE}/points/{lat},{lon}"
        logger.info(f"NOAA: fetching grid point from {points_url}")

        resp = await client.get(points_url, headers=headers)
        if resp.status_code != 200:
            raise Exception(
                f"NOAA points API returned HTTP {resp.status_code}: {resp.text[:200]}"
            )

        points_data = resp.json()
        props = points_data.get("properties", {})
        forecast_url = props.get("forecast")

        if not forecast_url:
            raise Exception("NOAA /points response missing 'properties.forecast' URL")

        logger.info(f"NOAA: fetching forecast from {forecast_url}")
        resp2 = await client.get(forecast_url, headers=headers)
        if resp2.status_code != 200:
            raise Exception(
                f"NOAA forecast URL returned HTTP {resp2.status_code}: {resp2.text[:200]}"
            )

        forecast_data = resp2.json()
        periods = forecast_data.get("properties", {}).get("periods", [])

        if not periods:
            raise Exception("NOAA forecast returned zero periods")

        target_str = target_date.isoformat()
        logger.info(f"NOAA: searching {len(periods)} periods for date {target_str}")

        daytime_temp = None
        for period in periods:
            start = period.get("startTime", "")
            is_daytime = period.get("isDaytime", False)
            temp = period.get("temperature")
            temp_unit = period.get("temperatureUnit", "F")

            if target_str in start and is_daytime and temp is not None:
                if temp_unit == "C":
                    temp = round((temp * 9 / 5) + 32)
                daytime_temp = int(round(temp))
                logger.info(f"NOAA: found daytime high {daytime_temp}°F for {start}")
                return daytime_temp

        for period in periods:
            start = period.get("startTime", "")
            temp = period.get("temperature")
            temp_unit = period.get("temperatureUnit", "F")

            if target_str in start and temp is not None:
                if temp_unit == "C":
                    temp = round((temp * 9 / 5) + 32)
                daytime_temp = int(round(temp))
                logger.info(f"NOAA: found period temp {daytime_temp}°F for {start}")
                return daytime_temp

        available_dates = list({p.get("startTime", "")[:10] for p in periods})
        raise Exception(
            f"NOAA: no forecast period found for date {target_str}. "
            f"Available dates: {available_dates}"
        )


path: simmer_weather_bot/openmeteo.py
content: import httpx
import logging
from datetime import date
from config import OPEN_METEO_API, HTTP_TIMEOUT

logger = logging.getLogger(__name__)


async def get_openmeteo_forecast(lat: float, lon: float, target_date: date, timezone: str = "auto") -> int:
    """
    Fetches the Open-Meteo daily high temperature for the given lat/lon and target date.
    Returns an integer temperature in Fahrenheit.
    Raises an exception if the forecast cannot be retrieved.
    """
    date_str = target_date.isoformat()
    params = {
        "latitude": lat,
        "longitude": lon,
        "daily": "temperature_2m_max",
        "temperature_unit": "fahrenheit",
        "timezone": timezone,
        "start_date": date_str,
        "end_date": date_str,
    }

    logger.info(f"Open-Meteo: fetching forecast for {lat},{lon} on {date_str}")

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        resp = await client.get(OPEN_METEO_API, params=params)
        if resp.status_code != 200:
            raise Exception(
                f"Open-Meteo returned HTTP {resp.status_code}: {resp.text[:200]}"
            )

        data = resp.json()

        if "error" in data and data["error"]:
            raise Exception(f"Open-Meteo error: {data.get('reason', 'unknown error')}")

        daily = data.get("daily", {})
        temps = daily.get("temperature_2m_max", [])
        dates = daily.get("time", [])

        if not temps:
            raise Exception(
                f"Open-Meteo returned no temperature data for {date_str}. "
                f"Full response keys: {list(data.keys())}"
            )

        if date_str not in dates:
            raise Exception(
                f"Open-Meteo: date {date_str} not in response dates: {dates}"
            )

        idx = dates.index(date_str)
        raw_temp = temps[idx]

        if raw_temp is None:
            raise Exception(f"Open-Meteo returned null temperature for {date_str}")

        temp_f = int(round(float(raw_temp)))
        logger.info(f"Open-Meteo: {temp_f}°F for {date_str} at {lat},{lon}")
        return temp_f


path: simmer_weather_bot/requirements.txt
content: python-telegram-bot==20.7
httpx==0.25.2
playwright==1.43.0
python-dotenv==1.0.1


path: simmer_weather_bot/simmer_client.py
content: import httpx
import re
import logging
from datetime import date, datetime, timezone
from config import SIMMER_API_KEY, SIMMER_BASE_URL, SIMMER_VENUE, HTTP_TIMEOUT
from city_map import resolve_city, celsius_to_fahrenheit

logger = logging.getLogger(__name__)

SIMMER_HEADERS = {
    "Authorization": f"Bearer {SIMMER_API_KEY}",
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def _parse_time_to_resolution(s) -> float:
    """Parse '1d 0h' or '2d 12h' or raw number into hours float."""
    if s is None:
        return 999.0
    if isinstance(s, (int, float)):
        return float(s)
    s = str(s)
    days = re.search(r'(\d+)\s*d', s)
    hours = re.search(r'(\d+)\s*h', s)
    total = 0.0
    if days:
        total += int(days.group(1)) * 24
    if hours:
        total += int(hours.group(1))
    return total if total > 0 else 999.0


def _parse_market_question(question: str) -> dict:
    """
    Parses a Simmer weather market question to extract city, date, min_temp, max_temp.
    Handles both Fahrenheit and Celsius markets.
    Returns temps always in Fahrenheit.
    """
    result = {"city": None, "date": None, "min_temp": None, "max_temp": None, "unit": "F"}

    is_celsius = bool(re.search(r'°C\b', question, re.IGNORECASE))
    if is_celsius:
        result["unit"] = "C"

    range_match = re.search(r'(\d+)\s*[-–to]+\s*(\d+)\s*°?[FC]', question, re.IGNORECASE)
    if range_match:
        lo = int(range_match.group(1))
        hi = int(range_match.group(2))
        if is_celsius:
            result["min_temp"] = celsius_to_fahrenheit(lo)
            result["max_temp"] = celsius_to_fahrenheit(hi)
        else:
            result["min_temp"] = lo
            result["max_temp"] = hi

    if result["min_temp"] is None:
        single_match = re.search(r'(\d+)\s*°?([FC])\b', question)
        if single_match:
            t = int(single_match.group(1))
            unit = single_match.group(2).upper()
            if unit == "C":
                result["min_temp"] = celsius_to_fahrenheit(t)
                result["max_temp"] = celsius_to_fahrenheit(t)
            else:
                result["min_temp"] = t
                result["max_temp"] = t

    if result["min_temp"] is None:
        below_match = re.search(r'(\d+)\s*°?F\s+or\s+below', question, re.IGNORECASE)
        if below_match:
            t = int(below_match.group(1))
            result["min_temp"] = 0
            result["max_temp"] = t

    date_iso = re.search(r'\d{4}-\d{2}-\d{2}', question)
    if date_iso:
        result["date"] = date.fromisoformat(date_iso.group(0))
    else:
        current_year = datetime.now().year
        date_words = re.search(
            r'(January|February|March|April|May|June|July|August|September|October|November|December)'
            r'\s+(\d{1,2})(?:,\s*(\d{4}))?',
            question, re.IGNORECASE
        )
        if date_words:
            month_str = date_words.group(1)
            day = int(date_words.group(2))
            year = int(date_words.group(3)) if date_words.group(3) else current_year
            try:
                result["date"] = datetime.strptime(f"{month_str} {day} {year}", "%B %d %Y").date()
            except ValueError:
                pass

    city_patterns = [
        r'\bin\s+([A-Z][a-zA-Z\s]+?)\s+(?:be\s|between\s|above\s|below\s|exceed)',
        r'\bin\s+([A-Z][a-zA-Z\s]+?)\s+(?:on\s)',
        r'\bin\s+([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)?)\s',
    ]
    for pattern in city_patterns:
        match = re.search(pattern, question)
        if match:
            candidate = match.group(1).strip().rstrip("'s")
            try:
                resolve_city(candidate)
                result["city"] = candidate
                break
            except ValueError:
                continue

    return result


def _parse_context(ctx: dict) -> dict:
    """Normalise the Simmer context response into a flat dict for our pipeline."""
    market = ctx.get("market", {})
    edge = ctx.get("edge", {})
    slippage_info = ctx.get("slippage", {})
    warnings_list = ctx.get("warnings", [])

    spread_pct = slippage_info.get("spread_pct", None)
    if spread_pct is not None:
        spread_pct = round(spread_pct * 100, 2)

    time_to_res_raw = market.get("time_to_resolution", None)
    time_to_res_hours = _parse_time_to_resolution(time_to_res_raw)

    recommendation = edge.get("recommendation", None)

    return {
        "edge": {
            "recommendation": recommendation,
            "edge_pct": edge.get("user_edge", None),
        },
        "slippage_estimate": spread_pct,
        "time_to_resolution": time_to_res_hours,
        "warnings": warnings_list,
        "position": ctx.get("position", {}),
        "resolves_at": market.get("resolves_at", ""),
    }


async def fetch_weather_markets(limit: int = 20) -> list[dict]:
    """
    Fetches active weather markets from Simmer API.
    Returns a list of enriched market dicts with parsed city, date, min/max temp.
    """
    url = f"{SIMMER_BASE_URL}/api/sdk/markets"
    params = {"tags": "weather", "status": "active", "limit": limit}

    logger.info(f"Simmer: fetching weather markets from {url}")

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        resp = await client.get(url, headers=SIMMER_HEADERS, params=params)
        if resp.status_code != 200:
            raise Exception(f"Simmer markets API returned HTTP {resp.status_code}: {resp.text[:300]}")
        raw = resp.json()

    if isinstance(raw, list):
        markets_raw = raw
    else:
        markets_raw = raw.get("markets", raw.get("data", raw.get("results", [])))

    if len(markets_raw) == 0:
        raise Exception("Simmer returned 0 active weather markets")

    enriched = []
    for m in markets_raw:
        question = m.get("question", "")
        parsed = _parse_market_question(question)

        enriched_market = {
            "id": m.get("id"),
            "question": question,
            "status": m.get("status", "unknown"),
            "current_probability": m.get("current_probability", m.get("probability", None)),
            "url": m.get("url", ""),
            "resolves_at": m.get("resolves_at", ""),
            "tags": m.get("tags", []),
            "city": parsed["city"],
            "date": parsed["date"],
            "min_temp": parsed["min_temp"],
            "max_temp": parsed["max_temp"],
            "unit": parsed["unit"],
        }
        enriched.append(enriched_market)
        logger.info(
            f"Market: {question[:80]} | "
            f"city={parsed['city']} date={parsed['date']} "
            f"temps=[{parsed['min_temp']}-{parsed['max_temp']}°F] unit={parsed['unit']}"
        )

    return enriched


async def get_market_context(market_id: str) -> dict:
    """Fetches and normalises Simmer market context."""
    url = f"{SIMMER_BASE_URL}/api/sdk/context/{market_id}"
    logger.info(f"Simmer: fetching context for market {market_id}")

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        resp = await client.get(url, headers=SIMMER_HEADERS)
        if resp.status_code != 200:
            raise Exception(f"Simmer context API returned HTTP {resp.status_code}: {resp.text[:300]}")
        raw_ctx = resp.json()

    context = _parse_context(raw_ctx)
    logger.info(
        f"Simmer context: recommendation={context['edge']['recommendation']} "
        f"slippage={context['slippage_estimate']}% "
        f"time_to_resolution={context['time_to_resolution']}h "
        f"warnings={len(context['warnings'])}"
    )
    return context


async def execute_trade(market_id: str, reasoning: str) -> dict:
    """Executes a YES trade on the given market via Simmer SDK endpoint."""
    from config import TRADE_AMOUNT
    url = f"{SIMMER_BASE_URL}/api/sdk/trade"

    payload = {
        "market_id": market_id,
        "side": "yes",
        "amount": TRADE_AMOUNT,
        "venue": SIMMER_VENUE,
        "reasoning": reasoning,
        "source": "sdk:weather-bot",
    }

    logger.info(f"Simmer: executing trade on market {market_id} venue={SIMMER_VENUE} amount={TRADE_AMOUNT}")

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        resp = await client.post(url, json=payload, headers=SIMMER_HEADERS)
        if resp.status_code not in (200, 201):
            raise Exception(f"Simmer trade API returned HTTP {resp.status_code}: {resp.text[:300]}")
        result = resp.json()

    logger.info(f"Simmer trade result: {result}")
    return result


async def get_positions() -> list:
    """Fetches current agent positions."""
    url = f"{SIMMER_BASE_URL}/api/sdk/positions"
    params = {"venue": SIMMER_VENUE}

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        resp = await client.get(url, headers=SIMMER_HEADERS, params=params)
        if resp.status_code != 200:
            raise Exception(f"Simmer positions API returned HTTP {resp.status_code}: {resp.text[:200]}")
        data = resp.json()

    if isinstance(data, list):
        return data
    return data.get("positions", data.get("data", []))


async def get_agent_info() -> dict:
    """Fetches agent info including balance and limits."""
    url = f"{SIMMER_BASE_URL}/api/sdk/agents/me"

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        resp = await client.get(url, headers=SIMMER_HEADERS)
        if resp.status_code != 200:
            raise Exception(f"Simmer agent API returned HTTP {resp.status_code}: {resp.text[:200]}")
        return resp.json()


async def get_briefing() -> dict:
    """Fetches full agent briefing."""
    url = f"{SIMMER_BASE_URL}/api/sdk/briefing"

    async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
        resp = await client.get(url, headers=SIMMER_HEADERS)
        if resp.status_code != 200:
            raise Exception(f"Simmer briefing API returned HTTP {resp.status_code}: {resp.text[:200]}")
        return resp.json()


path: simmer_weather_bot/strategy.py
content: import logging

logger = logging.getLogger(__name__)


def compute_confidence(
    noaa: int,
    openmeteo: int,
    wunderground: int,
    min_temp: int,
    max_temp: int,
    context: dict,
    ai_result: dict,
) -> int:
    """
    Computes a confidence score 0–100.
    ai_result now comes from NVIDIA FourcastNet (a 4th atmospheric model forecast).
    Trade only executes if score == 100.
    """
    score = 0
    breakdown = []

    if abs(noaa - openmeteo) <= 1:
        score += 12
        breakdown.append(f"NOAA vs OpenMeteo agree (|{noaa}-{openmeteo}|<=1): +12")
    else:
        breakdown.append(f"NOAA vs OpenMeteo disagree (|{noaa}-{openmeteo}|={abs(noaa-openmeteo)}): +0")

    if abs(noaa - wunderground) <= 1:
        score += 12
        breakdown.append(f"NOAA vs Wunderground agree (|{noaa}-{wunderground}|<=1): +12")
    else:
        breakdown.append(f"NOAA vs Wunderground disagree (|{noaa}-{wunderground}|={abs(noaa-wunderground)}): +0")

    if abs(openmeteo - wunderground) <= 1:
        score += 11
        breakdown.append(f"OpenMeteo vs Wunderground agree (|{openmeteo}-{wunderground}|<=1): +11")
    else:
        breakdown.append(f"OpenMeteo vs Wunderground disagree (|{openmeteo}-{wunderground}|={abs(openmeteo-wunderground)}): +0")

    consensus = round((noaa + openmeteo + wunderground) / 3)
    if min_temp <= consensus <= max_temp:
        score += 25
        breakdown.append(f"Consensus {consensus}°F inside [{min_temp}–{max_temp}°F]: +25")
    else:
        breakdown.append(f"Consensus {consensus}°F outside [{min_temp}–{max_temp}°F]: +0")

    edge_rec = context.get("edge", {}).get("recommendation", None)
    if edge_rec == "TRADE":
        score += 20
        breakdown.append(f"Simmer edge recommendation=TRADE: +20")
    else:
        breakdown.append(f"Simmer edge recommendation={edge_rec} (no user probability set): +0")

    hours = context.get("time_to_resolution", 999)
    if isinstance(hours, (int, float)) and hours <= 24:
        score += 10
        breakdown.append(f"Time to resolution {hours}h <=24h: +10")
    else:
        breakdown.append(f"Time to resolution {hours}h >24h: +0")

    pre_ai_score = score
    breakdown.append(f"Pre-FourcastNet score: {pre_ai_score}")

    fc_verdict = ai_result.get("verdict") == "TRADE"
    fc_inside = ai_result.get("inside_bucket") is True
    fc_sources = ai_result.get("sources_agree") is True

    if fc_verdict and fc_inside and fc_sources:
        score = 100
        breakdown.append("FourcastNet full confirmation (all conditions true): score locked to 100")
    else:
        breakdown.append(
            f"FourcastNet did NOT fully confirm: verdict={ai_result.get('verdict')}, "
            f"inside_bucket={ai_result.get('inside_bucket')}, "
            f"sources_agree={ai_result.get('sources_agree')}"
        )

    final_score = min(score, 100)
    logger.info(f"Confidence score: {final_score}\n" + "\n".join(f"  {b}" for b in breakdown))
    return final_score


path: simmer_weather_bot/telegram_ui.py
content: import logging
import asyncio
from datetime import date, datetime
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes
from telegram.constants import ParseMode

from simmer_client import (
    fetch_weather_markets, get_market_context, execute_trade,
    get_agent_info, get_positions,
)
from noaa import get_noaa_forecast
from openmeteo import get_openmeteo_forecast
from wunderground import get_wunderground_forecast
from ai_analyzer import analyze_with_ai
from strategy import compute_confidence
from city_map import resolve_city
from config import TRADE_AMOUNT, CONFIDENCE_THRESHOLD, SIMMER_VENUE

logger = logging.getLogger(__name__)

NUM_MARKETS_SHOWN = 4


def build_main_keyboard(markets: list[dict]) -> InlineKeyboardMarkup:
    keyboard = []
    for i, market in enumerate(markets[:NUM_MARKETS_SHOWN]):
        question_short = market.get("question", f"Market {i+1}")
        if len(question_short) > 45:
            question_short = question_short[:42] + "..."
        keyboard.append([
            InlineKeyboardButton(
                f"🔮 Predict {i+1} & Bet — {question_short}",
                callback_data=f"predict_{i}"
            )
        ])
    keyboard.append([InlineKeyboardButton("📊 Agent Status", callback_data="status")])
    return InlineKeyboardMarkup(keyboard)


async def handle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    try:
        markets = await fetch_weather_markets(limit=NUM_MARKETS_SHOWN + 5)
        active = [m for m in markets if m.get("status") == "active"]
        if not active:
            active = markets

        context.bot_data["cached_markets"] = active

        msg = "🤖 *Simmer Weather Trading Bot*\n\n"
        msg += f"Found *{len(active)}* active weather markets.\n"
        msg += "Select a market to analyze and trade:\n\n"

        for i, m in enumerate(active[:NUM_MARKETS_SHOWN]):
            prob = m.get("current_probability")
            prob_str = f"{prob:.0%}" if isinstance(prob, float) else "N/A"
            msg += f"*{i+1}.* {m.get('question', 'Unknown')[:80]}\n"
            msg += f"   YES price: {prob_str}\n\n"

        keyboard = build_main_keyboard(active)
        await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

    except Exception as e:
        logger.exception("Error in /start")
        await update.message.reply_text(
            f"❌ Failed to fetch markets from Simmer:\n`{str(e)[:300]}`",
            parse_mode=ParseMode.MARKDOWN
        )


async def handle_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text("⏳ Fetching agent status...")
    try:
        agent = await get_agent_info()
        positions = await get_positions()

        balance = agent.get("balance", "N/A")
        agent_id = agent.get("agent_id", agent.get("id", "N/A"))

        msg = "📊 *Agent Status*\n\n"
        msg += f"🆔 Agent ID: `{agent_id}`\n"
        msg += f"💰 Balance: `{balance}`\n"
        msg += f"🏦 Venue: `{SIMMER_VENUE}`\n\n"

        if positions:
            msg += f"📈 *Open Positions ({len(positions)}):*\n"
            for pos in positions[:5]:
                mkt = pos.get("market_question", pos.get("question", "Unknown"))[:50]
                shares = pos.get("shares", pos.get("quantity", "?"))
                side = pos.get("side", "YES")
                msg += f"  • {mkt}\n    {side}: {shares} shares\n"
        else:
            msg += "📈 *Open Positions:* None\n"

        await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)

    except Exception as e:
        logger.exception("Error in /status")
        await update.message.reply_text(
            f"❌ Error fetching status:\n`{str(e)[:300]}`",
            parse_mode=ParseMode.MARKDOWN
        )


async def handle_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    query = update.callback_query
    await query.answer()

    data = query.data

    if data == "status":
        await query.message.reply_text("⏳ Fetching agent status...")
        try:
            agent = await get_agent_info()
            positions = await get_positions()

            balance = agent.get("balance", "N/A")
            agent_id = agent.get("agent_id", agent.get("id", "N/A"))

            msg = "📊 *Agent Status*\n\n"
            msg += f"🆔 Agent ID: `{agent_id}`\n"
            msg += f"💰 Balance: `{balance}`\n"
            msg += f"🏦 Venue: `{SIMMER_VENUE}`\n\n"

            if positions:
                msg += f"📈 *Open Positions ({len(positions)}):*\n"
                for pos in positions[:5]:
                    mkt = pos.get("market_question", pos.get("question", "Unknown"))[:50]
                    shares = pos.get("shares", pos.get("quantity", "?"))
                    side = pos.get("side", "YES")
                    msg += f"  • {mkt}\n    {side}: {shares} shares\n"
            else:
                msg += "📈 *Open Positions:* None\n"

            await query.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)
        except Exception as e:
            await query.message.reply_text(f"❌ Error: `{str(e)[:200]}`", parse_mode=ParseMode.MARKDOWN)
        return

    if not data.startswith("predict_"):
        return

    try:
        market_index = int(data.split("_")[1])
    except (IndexError, ValueError):
        await query.message.reply_text("❌ Invalid button data.")
        return

    await query.message.reply_text(
        f"⏳ Fetching fresh markets and running full analysis for market #{market_index + 1}...\n"
        "This may take 1–2 minutes (3 live forecasts + NVIDIA FourcastNet atmospheric model)."
    )

    await _run_prediction_pipeline(query, market_index)


async def _run_prediction_pipeline(query, market_index: int) -> None:
    try:
        markets = await fetch_weather_markets(limit=NUM_MARKETS_SHOWN + 5)
        active = [m for m in markets if m.get("status") == "active"]
        if not active:
            active = markets
    except Exception as e:
        await query.message.reply_text(
            f"❌ Failed to fetch markets from Simmer API.\n\nError: `{str(e)[:300]}`",
            parse_mode=ParseMode.MARKDOWN
        )
        return

    if market_index >= len(active):
        await query.message.reply_text(
            f"❌ Not enough markets available. Only {len(active)} active markets found, "
            f"but you selected #{market_index + 1}."
        )
        return

    market = active[market_index]
    question = market.get("question", "Unknown")
    logger.info(f"Pipeline: processing market index {market_index}: {question}")

    if not market.get("id"):
        await query.message.reply_text(
            f"❌ Market #{market_index + 1} has no ID. Cannot proceed.\n\nMarket: {question}"
        )
        return

    if market.get("min_temp") is None or market.get("max_temp") is None:
        await query.message.reply_text(
            f"❌ Could not parse temperature bucket from market question.\n\n"
            f"Question: `{question}`\n\n"
            f"Parsed min_temp={market.get('min_temp')} max_temp={market.get('max_temp')}.\n"
            f"The market question format may not be supported yet.",
            parse_mode=ParseMode.MARKDOWN
        )
        return

    if market.get("city") is None:
        await query.message.reply_text(
            f"❌ Could not identify a city from market question.\n\n"
            f"Question: `{question}`\n\n"
            f"Add the city to city_map.py if needed.",
            parse_mode=ParseMode.MARKDOWN
        )
        return

    try:
        city_data = resolve_city(market["city"])
    except ValueError as e:
        await query.message.reply_text(
            f"❌ City resolution failed: {str(e)}",
            parse_mode=ParseMode.MARKDOWN
        )
        return

    target_date = market.get("date")
    if target_date is None:
        target_date = date.today()
        logger.warning(f"No date parsed from market question, defaulting to today: {target_date}")

    await query.message.reply_text(
        f"📍 Market: {question[:80]}\n"
        f"🏙️ City: {market['city']} | 📅 Date: {target_date} | 🌡️ Bucket: {market['min_temp']}–{market['max_temp']}°F\n\n"
        f"⏳ Step 1/4: Fetching Simmer context..."
    )

    try:
        simmer_context = await get_market_context(market["id"])
    except Exception as e:
        await query.message.reply_text(
            f"❌ *Simmer Context API failed.*\n\nError: `{str(e)[:300]}`",
            parse_mode=ParseMode.MARKDOWN
        )
        return

    await query.message.reply_text("⏳ Step 2/4: Fetching 3 live weather forecasts (NOAA + Open-Meteo + Wunderground)...")

    noaa_temp = None
    openmeteo_temp = None
    wunderground_temp = None
    forecast_errors = []

    try:
        noaa_temp = await get_noaa_forecast(city_data["lat"], city_data["lon"], target_date)
        logger.info(f"NOAA result: {noaa_temp}°F")
    except Exception as e:
        forecast_errors.append(f"NOAA: {str(e)[:200]}")
        logger.error(f"NOAA failed: {e}")

    try:
        openmeteo_temp = await get_openmeteo_forecast(
            city_data["lat"], city_data["lon"], target_date, timezone=city_data.get("timezone", "auto")
        )
        logger.info(f"Open-Meteo result: {openmeteo_temp}°F")
    except Exception as e:
        forecast_errors.append(f"Open-Meteo: {str(e)[:200]}")
        logger.error(f"Open-Meteo failed: {e}")

    try:
        wunderground_temp = await get_wunderground_forecast(city_data["wunderground"], target_date)
        logger.info(f"Wunderground result: {wunderground_temp}°F")
    except Exception as e:
        forecast_errors.append(f"Wunderground: {str(e)[:200]}")
        logger.error(f"Wunderground failed: {e}")

    if forecast_errors:
        errors_formatted = "\n".join(f"• {e}" for e in forecast_errors)
        await query.message.reply_text(
            f"❌ *TRADE SKIPPED — Forecast API failure(s)*\n\n"
            f"📍 Market: {question[:80]}\n\n"
            f"The following APIs failed:\n{errors_formatted}\n\n"
            f"All three forecasts (NOAA, Open-Meteo, Wunderground) are required.\n"
            f"No trade was placed.",
            parse_mode=ParseMode.MARKDOWN
        )
        return

    await query.message.reply_text(
        f"📡 Forecasts received:\n"
        f"  • NOAA: {noaa_temp}°F\n"
        f"  • Open-Meteo: {openmeteo_temp}°F\n"
        f"  • Wunderground: {wunderground_temp}°F\n\n"
        f"⏳ Step 3/4: Submitting to NVIDIA FourcastNet atmospheric model (may take ~1 min)..."
    )

    try:
        ai_result = await analyze_with_ai(
            market=market,
            context=simmer_context,
            noaa_temp=noaa_temp,
            openmeteo_temp=openmeteo_temp,
            wunderground_temp=wunderground_temp,
        )
    except Exception as e:
        await query.message.reply_text(
            f"❌ *TRADE SKIPPED — FourcastNet analysis failed*\n\n"
            f"📍 Market: {question[:80]}\n\n"
            f"NVIDIA FourcastNet error: `{str(e)[:300]}`\n\n"
            f"No trade was placed.",
            parse_mode=ParseMode.MARKDOWN
        )
        return

    await query.message.reply_text("⏳ Step 4/4: Computing confidence score and making final decision...")

    confidence = compute_confidence(
        noaa=noaa_temp,
        openmeteo=openmeteo_temp,
        wunderground=wunderground_temp,
        min_temp=market["min_temp"],
        max_temp=market["max_temp"],
        context=simmer_context,
        ai_result=ai_result,
    )

    consensus = round((noaa_temp + openmeteo_temp + wunderground_temp) / 3)
    spread = max(noaa_temp, openmeteo_temp, wunderground_temp) - min(noaa_temp, openmeteo_temp, wunderground_temp)
    inside_bucket = market["min_temp"] <= consensus <= market["max_temp"]
    sources_agree = spread <= 1
    prob = market.get("current_probability")
    prob_str = f"{prob:.0%}" if isinstance(prob, float) else "N/A"
    edge_pct = simmer_context.get("edge", {}).get("edge_pct", "N/A")
    slippage = simmer_context.get("slippage_estimate", "N/A")
    time_to_res = simmer_context.get("time_to_resolution", "N/A")

    if confidence == CONFIDENCE_THRESHOLD and ai_result.get("verdict") == "TRADE":
        reasoning = (
            f"NOAA={noaa_temp}°F, OpenMeteo={openmeteo_temp}°F, Wunderground={wunderground_temp}°F — "
            f"all within ±1°F. Bucket [{market['min_temp']}-{market['max_temp']}°F]. "
            f"Confidence 100%. FourcastNet verdict: TRADE. Reason: {ai_result.get('reason', '')}"
        )

        try:
            trade_result = await execute_trade(
                market_id=market["id"],
                reasoning=reasoning,
            )
        except Exception as e:
            await query.message.reply_text(
                f"⚠️ *TRADE FAILED — API error during execution*\n\n"
                f"📍 Market: {question[:80]}\n\n"
                f"Conditions were met (confidence=100%, AI=TRADE) but the trade API failed:\n"
                f"`{str(e)[:300]}`\n\n"
                f"No trade was placed.",
                parse_mode=ParseMode.MARKDOWN
            )
            return

        success = trade_result.get("success", True)
        trade_id = trade_result.get("trade_id", trade_result.get("id", "N/A"))
        shares = trade_result.get("shares_bought", trade_result.get("shares", "N/A"))
        cost = trade_result.get("cost", TRADE_AMOUNT)
        new_price = trade_result.get("new_price", trade_result.get("price", "N/A"))
        fully_filled = trade_result.get("fully_filled", True)
        trade_warnings = trade_result.get("warnings", [])
        error_msg = trade_result.get("error", None)

        if not success or error_msg:
            await query.message.reply_text(
                f"⚠️ *TRADE REJECTED BY SIMMER*\n\n"
                f"📍 Market: {question[:80]}\n\n"
                f"Simmer rejected the trade:\n`{error_msg or 'No error message returned'}`\n\n"
                f"No shares were purchased.",
                parse_mode=ParseMode.MARKDOWN
            )
            return

        bucket_icon = "✅" if inside_bucket else "❌"
        agree_icon = "✅" if sources_agree else "❌"

        msg = (
            f"🚨 *TRADE EXECUTED*\n\n"
            f"📍 *Market:* {question[:80]}\n"
            f"🔗 *Market ID:* `{market['id']}`\n"
            f"🌐 *Venue:* Simmer ($SIM)\n\n"
            f"📡 *Live Forecast:*\n"
            f"   • NOAA:          `{noaa_temp}°F`\n"
            f"   • Open-Meteo:   `{openmeteo_temp}°F`\n"
            f"   • Wunderground: `{wunderground_temp}°F`\n"
            f"   • Consensus:    `{consensus}°F` "
            f"{bucket_icon} {'Inside' if inside_bucket else 'Outside'} bucket [{market['min_temp']}–{market['max_temp']}°F]\n\n"
            f"🤖 *NVIDIA FourcastNet Verdict:* TRADE\n"
            f"   Predicted Temp:   `{ai_result.get('predicted_temp')}°F`\n"
            f"   Sources Agree:    {agree_icon} (spread={spread}°F)\n"
            f"   Bucket Match:     ✅ ({consensus} inside [{market['min_temp']}–{market['max_temp']}])\n"
            f"   Simmer Edge:      ✅ TRADE recommended\n"
            f"   FC Reason:        {ai_result.get('reason', 'N/A')}\n\n"
            f"📊 *Confidence Score:* 100%\n\n"
            f"💵 *Action:* BUY YES\n"
            f"💰 *Amount:* ${cost} SIM\n"
            f"📈 *Shares Bought:* {shares}\n"
            f"💲 *New Market Price:* {new_price}\n"
            f"🧾 *Trade ID:* `{trade_id}`\n"
            f"✔️ *Fully Filled:* {'Yes' if fully_filled else 'No'}\n"
        )

        if trade_warnings:
            msg += f"\n⚠️ *Warnings:* {', '.join(str(w) for w in trade_warnings)}"

        await query.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)

    else:
        verdict = ai_result.get("verdict", "SKIP")
        reason = ai_result.get("reason", "No reason provided by AI")
        bucket_icon = "✅" if inside_bucket else "❌"
        agree_icon = "✅" if sources_agree else "❌"

        skip_reasons = []
        if not sources_agree:
            skip_reasons.append(f"Forecast spread is {spread}°F (maximum allowed: 1°F)")
        if not inside_bucket:
            skip_reasons.append(
                f"Consensus {consensus}°F is NOT inside bucket [{market['min_temp']}–{market['max_temp']}°F] "
                f"(off by {min(abs(consensus - market['min_temp']), abs(consensus - market['max_temp']))}°F)"
            )
        edge_rec = simmer_context.get("edge", {}).get("recommendation", "SKIP")
        if edge_rec != "TRADE":
            skip_reasons.append(f"Simmer edge recommendation is {edge_rec}, not TRADE")
        if confidence < CONFIDENCE_THRESHOLD:
            skip_reasons.append(f"Confidence score is {confidence}%, required: {CONFIDENCE_THRESHOLD}%")
        if verdict != "TRADE":
            skip_reasons.append(f"AI verdict is {verdict}: {reason}")

        reasons_formatted = "\n".join(f"   • {r}" for r in skip_reasons) if skip_reasons else f"   • {reason}"

        msg = (
            f"❌ *TRADE SKIPPED*\n\n"
            f"📍 *Market:* {question[:80]}\n\n"
            f"📡 *Live Forecast:*\n"
            f"   • NOAA:          `{noaa_temp}°F`\n"
            f"   • Open-Meteo:   `{openmeteo_temp}°F`\n"
            f"   • Wunderground: `{wunderground_temp}°F`\n"
            f"   • Consensus:    `{consensus}°F` "
            f"{bucket_icon} {'Inside' if inside_bucket else 'NOT inside'} bucket [{market['min_temp']}–{market['max_temp']}°F]\n\n"
            f"🤖 *NVIDIA FourcastNet Verdict:* {verdict}\n"
            f"   Predicted Temp:   `{ai_result.get('predicted_temp')}°F`\n"
            f"   Sources Agree:    {agree_icon} (spread={spread}°F)\n"
            f"   Bucket Match:     {bucket_icon} ({consensus}°F {'inside' if inside_bucket else 'outside'} [{market['min_temp']}–{market['max_temp']}])\n"
            f"   FC Reason:        {reason}\n\n"
            f"📊 *Confidence Score:* {confidence}%\n"
            f"🚫 *Required:* {CONFIDENCE_THRESHOLD}%\n\n"
            f"*Exact reasons for skip:*\n{reasons_formatted}\n\n"
            f"No trade was placed."
        )

        await query.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)


path: simmer_weather_bot/wunderground.py
content: import logging
import re
from datetime import date, datetime
from config import PLAYWRIGHT_TIMEOUT

logger = logging.getLogger(__name__)


async def get_wunderground_forecast(wunderground_path: str, target_date: date) -> int:
    """
    Uses Playwright (headless Chromium) to scrape the Wunderground 10-day forecast page.
    For today's date: returns today's high temperature.
    For future dates: scrapes the 10-day forecast table to find the correct date.
    Returns temperature in Fahrenheit.

    wunderground_path: e.g. "ny/new-york"  (no leading slash, no /forecast/us/)
    """
    from playwright.async_api import async_playwright

    is_international = "/" not in wunderground_path or wunderground_path.startswith(("italy", "spain", "israel", "england", "france", "germany", "japan"))

    if is_international:
        url = f"https://www.wunderground.com/forecast/{wunderground_path}"
    else:
        url = f"https://www.wunderground.com/forecast/us/{wunderground_path}"

    logger.info(f"Wunderground: loading {url} for date {target_date}")

    from datetime import date as date_cls
    today = date_cls.today()
    days_ahead = (target_date - today).days

    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=["--no-sandbox", "--disable-dev-shm-usage"]
        )
        context = await browser.new_context(
            user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        )
        page = await context.new_page()

        try:
            await page.goto(url, timeout=PLAYWRIGHT_TIMEOUT, wait_until="domcontentloaded")
            await page.wait_for_timeout(5000)

            temp_text = None

            if days_ahead <= 0:
                selectors = [
                    ".wu-unit-temperature .wu-value",
                    "[data-testid='TemperatureValue']",
                    ".today_nowcard-temp span",
                    "span.temp",
                    "div.high-temp",
                ]
                for selector in selectors:
                    els = await page.query_selector_all(selector)
                    for el in els:
                        t = (await el.inner_text()).strip().replace("°", "").replace("F", "").replace("C", "").strip()
                        if t.lstrip("-").isdigit():
                            temp_text = t
                            logger.info(f"Wunderground: today high '{t}°' via selector '{selector}'")
                            break
                    if temp_text:
                        break

            else:
                logger.info(f"Wunderground: looking for {days_ahead} days ahead in 10-day forecast")

                forecast_selectors = [
                    "table.forecast-table",
                    ".seven-day-forecast",
                    "lib-forecast-chart",
                    ".tenday",
                ]

                content = await page.content()

                date_formats = [
                    target_date.strftime("%a %b %-d"),
                    target_date.strftime("%A, %B %-d"),
                    target_date.strftime("%b %-d"),
                    target_date.strftime("%-m/%-d"),
                ]

                for fmt in date_formats:
                    pattern = re.escape(fmt) + r'.*?(\d{1,3})\s*°?[FC]?'
                    match = re.search(pattern, content, re.IGNORECASE | re.DOTALL)
                    if match:
                        temp_text = match.group(1)
                        logger.info(f"Wunderground: found future date temp '{temp_text}' via date pattern '{fmt}'")
                        break

                if not temp_text:
                    json_pattern = re.findall(
                        r'"high"\s*:\s*\{[^}]*"fahrenheit"\s*:\s*(\d+)',
                        content
                    )
                    if json_pattern and len(json_pattern) > days_ahead:
                        temp_text = json_pattern[days_ahead]
                        logger.info(f"Wunderground: future high from JSON[{days_ahead}]: {temp_text}°F")
                    elif json_pattern:
                        temp_text = json_pattern[-1]
                        logger.info(f"Wunderground: future high from JSON (last): {temp_text}°F")

                if not temp_text:
                    highs_celsius = re.findall(r'"high_celsius"\s*:\s*(\d+)', content)
                    highs_f = re.findall(r'"high_fahrenheit"\s*:\s*(\d+)', content)
                    if highs_f and len(highs_f) > days_ahead:
                        temp_text = highs_f[days_ahead]
                    elif highs_f:
                        temp_text = highs_f[-1]
                    elif highs_celsius and len(highs_celsius) > days_ahead:
                        c = int(highs_celsius[days_ahead])
                        temp_text = str(round((c * 9 / 5) + 32))

                if not temp_text:
                    selectors = [
                        ".wu-unit-temperature .wu-value",
                        "[data-testid='TemperatureValue']",
                        "span.temp",
                    ]
                    for selector in selectors:
                        els = await page.query_selector_all(selector)
                        for el in els:
                            t = (await el.inner_text()).strip().replace("°", "").replace("F", "").replace("C", "").strip()
                            if t.lstrip("-").isdigit():
                                temp_text = t
                                logger.info(f"Wunderground: fallback to current temp '{t}' via selector '{selector}'")
                                break
                        if temp_text:
                            break

            if not temp_text:
                raise Exception(
                    f"Wunderground: page loaded at {url} but no temperature found for {target_date}. "
                    f"The page structure may have changed or anti-scraping blocked the request."
                )

            raw_temp = float(temp_text)

            page_content = await page.content()
            is_celsius_page = "°C" in page_content[:2000] and "°F" not in page_content[:2000]
            if raw_temp < 32 and is_celsius_page:
                raw_temp = round((raw_temp * 9 / 5) + 32)
                logger.info(f"Wunderground: converted Celsius to {raw_temp}°F")

            temp_f = int(round(raw_temp))
            logger.info(f"Wunderground: final temp = {temp_f}°F for {target_date}")
            return temp_f

        finally:
            await browser.close()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: stale | SkillSpector: suspicious (CRITICAL)