Skill: richducat/dolph-weather-trader (version 1.0.0)

--- name: polymarket-weather-trader description: Trade Polymarket weather markets using NOAA (US) and Open-Meteo (international) forecasts via Simmer API. Inspired by gopfan2's $2M+ strategy. Use when user wants to trade temperature markets, automate weather bets, check forecasts, or run gopfan2-style trading. metadata: author: Simmer (@simmer_markets) version: "1.17.0" displayName: Polymarket Weather Trader difficulty: beginner attribution: Strategy inspired by gopfan2 --- # Polymarket Weather Trader Trade temperature markets on Polymarket using NOAA forecast data. > **This is a template.** The default signal is NOAA temperature forecasts — remix it with other weather APIs, different forecast models, or additional market types (precipitation, wind, etc.). The skill handles all the plumbing (market discovery, NOAA parsing, trade execution, safeguards). Your agent provides the alpha. ## When to Use This Skill Use this skill when the user wants to: - Trade weather markets automatically - Set up gopfan2-style temperature trading - Buy low on weather predictions - Check their weather trading positions - Configure trading thresholds or locations ## What's New in v1.17.0 - **Volatility Targeting**: Dynamic position sizing based on realized market volatility. Uses EWMA of log returns from price history. High vol → smaller positions, low vol → larger positions. Enable with `--vol-targeting` flag or [REDACTED_SECRET]. - `SIMMER_WEATHER_TARGET_VOL` — target annualized vol (default 20%) - `SIMMER_WEATHER_VOL_MAX_LEVERAGE` — max scale-up multiplier (default 2.0x) - `SIMMER_WEATHER_VOL_MIN_ALLOC` — min allocation floor (default 20%) - `SIMMER_WEATHER_VOL_SPAN` — EWMA responsiveness (default 10) ### v1.14.0 - **Fixed env var names** to match autotune registry (old names still work as aliases): - `SIMMER_WEATHER_ENTRY` → `SIMMER_WEATHER_ENTRY_THRESHOLD` - `SIMMER_WEATHER_EXIT` → `SIMMER_WEATHER_EXIT_THRESHOLD` - `SIMMER_WEATHER_MAX_POSITION` → `SIMMER_WEATHER_MAX_POSITION_USD` - `SIMMER_WEATHER_MAX_TRADES` → [REDACTED_SECRET] - **New tunable: `SIMMER_WEATHER_SLIPPAGE_MAX`** — adjustable slippage safeguard (default 15%). Set higher for research mode on illiquid markets. - **New tunable: `SIMMER_WEATHER_MIN_LIQUIDITY`** — skip markets with liquidity below this USD threshold (default 0 = disabled). Pre-filters thin markets before execution. - **`SIMMER_WEATHER_LOCATIONS` and `SIMMER_WEATHER_BINARY_ONLY` now exposed as autotune tunables.** ### v1.13.0 - **Binary Only Mode**: New `SIMMER_WEATHER_BINARY_ONLY` config to skip range-bucket events (e.g., "NYC 34-35°F") and only trade binary yes/no weather markets ### v1.2.0 - **Max Trades Per Run**: New `SIMMER_WEATHER_MAX_TRADES` config to limit trades per scan cycle (default: 5) ### v1.1.1 - **Status Script**: New `scripts/status.py` for quick balance and position checks - **API Reference**: Added Quick Commands section with API endpoints ### v1.1.0 - **Source Tagging**: All trades tagged with `sdk:weather` for portfolio tracking - **Smart Sizing**: Position sizing based on available balance (`--smart-sizing`) - **Context Safeguards**: Checks for flip-flop warnings, slippage, time decay - **Price Trend Detection**: Detects recent price drops for stronger signals ## Setup Flow When user asks to install or configure this skill: 1. **Ask for Simmer API key** - They can get it from simmer.markets/dashboard → SDK tab - Store in environment as `SIMMER_API_KEY` 2. **Ask for wallet private key** (required for live trading) - This is the private key for their Polymarket wallet (the wallet that holds USDC) - Store in environment as `WALLET_PRIVATE_KEY` - The SDK uses this to sign orders client-side automatically — no manual signing needed 3. **Ask about settings** (or confirm defaults) - Entry threshold: When to buy (default 15¢) - Exit threshold: When to sell (default 45¢) - Max position: Amount per trade (default $2.00) - Locations: Which cities to trade (default NYC) 4. **Save settings to environment variables** 5. **Set up cron** (disabled by default — user must enable scheduling) ## Configuration | Setting | Environment Variable | Default | Description | |---------|---------------------|---------|-------------| | Entry threshold | `SIMMER_WEATHER_ENTRY_THRESHOLD` | 0.15 | Buy when price below this | | Exit threshold | `SIMMER_WEATHER_EXIT_THRESHOLD` | 0.45 | Sell when price above this | | Max position | `SIMMER_WEATHER_MAX_POSITION_USD` | 2.00 | Maximum USD per trade | | Max trades/run | [REDACTED_SECRET] | 5 | Maximum trades per scan cycle | | Locations | `SIMMER_WEATHER_LOCATIONS` | NYC | Comma-separated cities (NYC, Chicago, Seattle, Atlanta, Dallas, Miami) | | Binary only | `SIMMER_WEATHER_BINARY_ONLY` | false | Skip range-bucket events (e.g., "34-35°F"), only trade binary yes/no markets | | Smart sizing % | `SIMMER_WEATHER_SIZING_PCT` | 0.05 | % of balance per trade | | Slippage max | `SIMMER_WEATHER_SLIPPAGE_MAX` | 0.15 | Skip trades with slippage above this (0.15 = 15%) | | Min liquidity | `SIMMER_WEATHER_MIN_LIQUIDITY` | 0 | Skip markets with liquidity below this USD amount (0 = disabled) | | Vol targeting | `SIMMER_WEATHER_VOL_TARGETING` | false | Enable volatility targeting for dynamic position sizing | | Target vol | `SIMMER_WEATHER_TARGET_VOL` | 0.20 | Target annualized volatility (0.20 = 20%) | | Vol max leverage | `SIMMER_WEATHER_VOL_MAX_LEVERAGE` | 2.0 | Max scale-up multiplier in calm markets | | Vol min alloc | `SIMMER_WEATHER_VOL_MIN_ALLOC` | 0.2 | Min allocation floor in volatile markets (0.2 = 20%) | | Vol EWMA span | `SIMMER_WEATHER_VOL_SPAN` | 10 | EWMA span for vol calculation (lower = more responsive) | **Legacy env var aliases** (still accepted for backwards compatibility): `SIMMER_WEATHER_ENTRY`, `SIMMER_WEATHER_EXIT`, `SIMMER_WEATHER_MAX_POSITION`, `SIMMER_WEATHER_MAX_TRADES` **Supported locations:** NYC, Chicago, Seattle, Atlanta, Dallas, Miami ## Quick Commands ```bash # Check account balance and positions python scripts/status.py # Detailed position list python scripts/status.py --positions ``` **API Reference:** - Base URL: `https://api.simmer.markets` - Auth: `Authorization: Bearer $SIMMER_API_KEY` - Portfolio: `GET /api/sdk/portfolio` - Positions: `GET /api/sdk/positions` ## Running the Skill ```bash # Dry run (default — shows opportunities, no trades) python weather_trader.py # Execute real trades python weather_trader.py --live # With smart position sizing (uses portfolio balance) python weather_trader.py --live --smart-sizing # Check positions only python weather_trader.py --positions # View config python weather_trader.py --config # Disable safeguards (not recommended) python weather_trader.py --no-safeguards # Disable trend detection python weather_trader.py --no-trends # Enable volatility targeting (dynamic sizing based on market vol) python weather_trader.py --live --smart-sizing --vol-targeting # Quiet mode — only output on trades/errors (ideal for high-frequency runs) python weather_trader.py --live --quiet # Combine: frequent scanning, minimal noise python weather_trader.py --live --smart-sizing --quiet ``` ## How It Works Each cycle the script: 1. Fetches active weather markets from Simmer API 2. Groups markets by event (each temperature day is one event) 3. Parses event names to get location and date 4. Fetches NOAA forecast for that location/date 5. Finds the temperature bucket that matches the forecast 6. **Safeguards**: Checks context for flip-flop warnings, slippage, time decay 7. **Trend Detection**: Looks for recent price drops (stronger buy signal) 8. **Entry**: If bucket price < threshold and safeguards pass → BUY 9. **Exit**: Checks open positions, sells if price > exit threshold 10. **Tagging**: All trades tagged with `sdk:weather` for tracking ## Smart Sizing With `--smart-sizing`, position size is calculated as: - 5% of available USDC balance (configurable via `SIMMER_WEATHER_SIZING_PCT`) - Capped at max position setting ($2.00 default) - Falls back to fixed size if portfolio unavailable This prevents over-deployment and scales with your account size. ## Volatility Targeting With `--vol-targeting`, position sizes are dynamically adjusted based on realized market volatility: ``` position_size = base_size × clamp(target_vol / realized_vol, min_alloc, max_leverage) ``` - **High volatility** (price swinging): positions scale down → less risk - **Low volatility** (price stable): positions scale up → more alpha capture - Falls back to base size if insufficient price history (< 15 data points) Combines with smart sizing: first calculate base size from portfolio %, then apply the vol multiplier. ## Safeguards Before trading, the skill checks: - **Flip-flop warning**: Skips if you've been reversing too much - **Slippage**: Skips if estimated slippage > 15% - **Time decay**: Skips if market resolves in < 2 hours - **Market status**: Skips if market already resolved Disable with `--no-safeguards` (not recommended). ## Source Tagging All trades are tagged with `source: "sdk:weather"`. This means: - Portfolio shows breakdown by strategy - Copytrading skill won't sell your weather positions - You can track weather P&L separately ## Example Output ``` 🌤️ Simmer Weather Trading Skill ================================================== ⚙️ Configuration: Entry threshold: 15% (buy below this) Exit threshold: 45% (sell above this) Max position: $2.00 Locations: NYC Smart sizing: ✓ Enabled Safeguards: ✓ Enabled Trend detection: ✓ Enabled 💰 Portfolio: Balance: $150.00 Exposure: $45.00 Positions: 8 📍 NYC 2026-01-28 (high temp) NOAA forecast: 34°F Matching bucket: 34-35°F @ $0.12 💡 Smart sizing: $2.00 (capped at max position) ✅ Below threshold ($0.15) - BUY opportunity! 📉 (dropped 15% in 24h) Executing trade... ✅ Bought 62.5 shares @ $0.12 📊 Summary: Events scanned: 12 Entry opportunities: 1 Trades executed: 1 ``` ## Troubleshooting **"Safeguard blocked: Severe flip-flop warning"** - You've been changing direction too much on this market - Wait before trading again **"Slippage too high"** - Market is illiquid, reduce position size or skip **"Resolves in Xh - too soon"** - Market resolving soon, risk is elevated **"No weather markets found"** - Weather markets may not be active (seasonal) **"External wallet requires a pre-signed order"** - `WALLET_PRIVATE_KEY` is not set in the environment - The SDK signs orders automatically when this env var is present — no manual signing code needed - Fix: `export WALLET_PRIVATE_KEY=0x<your-polymarket-wallet-private-key>` - Do NOT attempt to sign orders manually or modify the skill code — the SDK handles it **"Balance shows $0 but I have USDC on Polygon"** - Polymarket uses **USDC.e** (bridged USDC, contract [REDACTED_SECRET]) — not native USDC - If you bridged USDC to Polygon recently, you likely received native USDC - Swap native USDC to USDC.e, then retry **"API key invalid"** - Get new key from simmer.markets/dashboard → SDK tab

path: _meta.json
content: ownerId: kn7axnp7bzqsf5fkx0z8px7han7zyq1x
slug: polymarket-weather-trader
version: 1.17.0
publishedAt: 1774791662242

path: clawhub.json
content: {
  "emoji": "\ud83c\udf21\ufe0f",
  "requires": {
    "env": [
      "SIMMER_API_KEY"
    ],
    "pip": [
      "simmer-sdk"
    ]
  },
  "cron": null,
  "autostart": false,
  "automaton": {
    "managed": true,
    "entrypoint": "weather_trader.py"
  },
  "tunables": [
    {
      "env": "SIMMER_WEATHER_ENTRY_THRESHOLD",
      "type": "number",
      "default": 0.05,
      "range": [
        0.01,
        0.3
      ],
      "step": 0.01,
      "label": "Entry edge threshold"
    },
    {
      "env": "SIMMER_WEATHER_EXIT_THRESHOLD",
      "type": "number",
      "default": 0.85,
      "range": [
        0.5,
        0.99
      ],
      "step": 0.01,
      "label": "Exit probability threshold"
    },
    {
      "env": "SIMMER_WEATHER_MAX_POSITION_USD",
      "type": "number",
      "default": 5,
      "range": [
        1,
        200
      ],
      "step": 5,
      "label": "Max position size (USD)"
    },
    {
      "env": "SIMMER_WEATHER_SIZING_PCT",
      "type": "number",
      "default": 0.1,
      "range": [
        0.01,
        1.0
      ],
      "step": 0.01,
      "label": "Position sizing percentage"
    },
    {
      "env": "SIMMER_WEATHER_MAX_TRADES_PER_RUN",
      "type": "number",
      "default": 5,
      "range": [
        1,
        20
      ],
      "step": 1,
      "label": "Max trades per run"
    }
  ]
}


path: config.json
content: max_position_usd: 0.16
max_position: 0.16
kelly_fraction: 0.1
max_usd_per_trade: 0.16
stop_loss_pct: 0.20
take_profit_pct: 0.25
max_trades_per_run: 3
max_usd: 0.16
max_per_market: 0.16
max_position_pct: 0.05
max_bet: 0.16

path: scripts/status.py
content: #!/usr/bin/env python3
"""
Simmer Account Status

Shows wallet balance, positions, and recent activity.

Usage:
    python scripts/status.py
    python scripts/status.py --positions  # Detailed position list
"""

import os
import sys
import json
import argparse
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError

# Force line-buffered stdout so output is visible in non-TTY environments (cron, Docker, OpenClaw)
sys.stdout.reconfigure(line_buffering=True)

SIMMER_API_BASE = "https://api.simmer.markets"

def api_request(api_key: str, endpoint: str) -> dict:
    """Make authenticated request to Simmer API."""
    url = f"{SIMMER_API_BASE}{endpoint}"
    req = Request(url, headers={
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json"
    })
    try:
        with urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode())
    except HTTPError as e:
        error_body = e.read().decode() if e.fp else ""
        print(f"❌ API Error {e.code}: {error_body}")
        sys.exit(1)
    except URLError as e:
        print(f"❌ Connection error: {e.reason}")
        sys.exit(1)

def format_usd(amount: float) -> str:
    """Format as USD."""
    return f"${amount:,.2f}"

def main():
    parser = argparse.ArgumentParser(description="Check Simmer account status")
    parser.add_argument("--positions", action="store_true", help="Show detailed positions")
    args = parser.parse_args()

    [REDACTED_SECRET]"SIMMER_API_KEY")
    if not [REDACTED_SECRET]"❌ SIMMER_API_KEY environment variable not set")
        print("   Get your API key from: https://simmer.markets/dashboard")
        sys.exit(1)

    # Get portfolio summary
    print("📊 Fetching account status...\n")
    portfolio = api_request(api_key, "/api/sdk/portfolio")

    balance = portfolio.get("balance_usdc", 0)
    exposure = portfolio.get("total_exposure", 0)
    positions_count = portfolio.get("positions_count", 0)
    pnl_total = portfolio.get("pnl_total")
    pnl_24h = portfolio.get("pnl_24h")

    # Display summary
    print("=" * 50)
    print("💰 ACCOUNT SUMMARY")
    print("=" * 50)
    print(f"  Available Balance:  {format_usd(balance)}")
    print(f"  Total Exposure:     {format_usd(exposure)}")
    print(f"  Open Positions:     {positions_count}")
    
    if pnl_total is not None:
        pnl_emoji = "📈" if pnl_total >= 0 else "📉"
        print(f"  Total PnL:          {pnl_emoji} {format_usd(pnl_total)}")
    
    if pnl_24h is not None:
        pnl_24h_emoji = "📈" if pnl_24h >= 0 else "📉"
        print(f"  24h PnL:            {pnl_24h_emoji} {format_usd(pnl_24h)}")
    
    # Concentration warning
    concentration = portfolio.get("concentration", {})
    top_market_pct = concentration.get("top_market_pct", 0)
    if top_market_pct > 0.5:
        print(f"\n  ⚠️  High concentration: {top_market_pct:.0%} in top market")

    # By source breakdown
    by_source = portfolio.get("by_source", {})
    if by_source:
        print("\n  📁 By Source:")
        for source, data in by_source.items():
            src_positions = data.get("positions", 0)
            src_exposure = data.get("exposure", 0)
            print(f"      {source}: {src_positions} positions, {format_usd(src_exposure)}")

    print("=" * 50)

    # Detailed positions if requested
    if args.positions:
        print("\n📋 OPEN POSITIONS")
        print("=" * 50)
        result = api_request(api_key, "/api/sdk/positions")
        positions = result.get("positions", []) if isinstance(result, dict) else result
        
        if not positions:
            print("  No open positions")
        else:
            for pos in positions:
                question = pos.get("question", pos.get("market_id", "Unknown"))
                # Truncate long questions
                if len(question) > 50:
                    question = question[:47] + "..."
                
                shares_yes = pos.get("shares_yes", 0)
                shares_no = pos.get("shares_no", 0)
                current_price = pos.get("current_price", 0)
                cost_basis = pos.get("cost_basis", 0)
                pnl = pos.get("pnl", 0)
                
                # Determine position side
                if shares_yes > 0:
                    side = "YES"
                    shares = shares_yes
                elif shares_no > 0:
                    side = "NO"
                    shares = shares_no
                else:
                    continue  # Skip empty positions
                
                pnl_indicator = "🟢" if pnl >= 0 else "🔴"
                print(f"\n  {question}")
                print(f"    {side}: {shares:.2f} shares, cost ${cost_basis:.2f}")
                print(f"    Current: {current_price:.1%} | {pnl_indicator} PnL: {format_usd(pnl)}")
        
        print("\n" + "=" * 50)

    # Helpful tips
    if balance == 0:
        print("\n💡 Tip: Deposit funds at https://simmer.markets/dashboard")
    
    print()

if __name__ == "__main__":
    main()


path: weather_trader.py
content: #!/usr/bin/env python3
"""
Simmer Weather Trading Skill

Trades Polymarket weather markets using NOAA forecasts.
Inspired by gopfan2's $2M+ weather trading strategy.

Usage:
    python weather_trader.py              # Dry run (show opportunities, no trades)
    python weather_trader.py --live       # Execute real trades
    python weather_trader.py --positions  # Show current positions only
    python weather_trader.py --smart-sizing  # Use portfolio-based position sizing

Requires:
    SIMMER_API_KEY environment variable (get from simmer.markets/dashboard)
"""

import os
import sys
import re
import json
import argparse
from datetime import datetime, timezone, timedelta
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode

# Force line-buffered stdout so output is visible in non-TTY environments (cron, Docker, OpenClaw)
sys.stdout.reconfigure(line_buffering=True)

# Optional: Trade Journal integration for tracking
try:
    from tradejournal import log_trade
    JOURNAL_AVAILABLE = True
except ImportError:
    try:
        # Try relative import within skills package
        from skills.tradejournal import log_trade
        JOURNAL_AVAILABLE = True
    except ImportError:
        JOURNAL_AVAILABLE = False
        def log_trade(*args, **kwargs):
            pass  # No-op if tradejournal not installed

# =============================================================================
# Configuration (config.json > env vars > defaults)
# =============================================================================

from simmer_sdk.skill import load_config, update_config, get_config_path

# Configuration schema
# Note: env var names match autotune registry. Legacy aliases (SIMMER_WEATHER_ENTRY,
# SIMMER_WEATHER_EXIT, SIMMER_WEATHER_MAX_POSITION, SIMMER_WEATHER_MAX_TRADES) are
# resolved as fallbacks below for backwards compatibility.
CONFIG_SCHEMA = {
    "entry_threshold":   {"env": "SIMMER_WEATHER_ENTRY_THRESHOLD",   "default": 0.15,  "type": float},
    "exit_threshold":    {"env": "SIMMER_WEATHER_EXIT_THRESHOLD",    "default": 0.45,  "type": float},
    "max_position_usd":  {"env": "SIMMER_WEATHER_MAX_POSITION_USD",  "default": 2.00,  "type": float},
    "sizing_pct":        {"env": "SIMMER_WEATHER_SIZING_PCT",        "default": 0.05,  "type": float},
    "max_trades_per_run":{"env": [REDACTED_SECRET],"default": 5,     "type": int},
    "locations":         {"env": "SIMMER_WEATHER_LOCATIONS",         "default": "NYC", "type": str},
    "binary_only":       {"env": "SIMMER_WEATHER_BINARY_ONLY",       "default": False, "type": bool},
    "slippage_max":      {"env": "SIMMER_WEATHER_SLIPPAGE_MAX",      "default": 0.15,  "type": float},
    "min_liquidity":     {"env": "SIMMER_WEATHER_MIN_LIQUIDITY",     "default": 0.0,   "type": float},
    "order_type":        {"env": "SIMMER_WEATHER_ORDER_TYPE",        "default": "GTC", "type": str,
                          "help": "Order type: GTC (default, limit order that waits for fill) or FAK (cancel if not filled immediately). GTC recommended for illiquid weather markets."},
    "vol_targeting":     {"env": "SIMMER_WEATHER_VOL_TARGETING",     "default": False, "type": bool,
                          "help": "Enable volatility targeting: scale position sizes by target_vol / realized_vol."},
    "target_vol":        {"env": "SIMMER_WEATHER_TARGET_VOL",        "default": 0.20,  "type": float,
                          "help": "Target annualized volatility (0.20 = 20%). Used when vol_targeting is enabled."},
    "vol_max_leverage":  {"env": "SIMMER_WEATHER_VOL_MAX_LEVERAGE",  "default": 2.0,   "type": float,
                          "help": "Max leverage multiplier from vol targeting (caps scale-up in calm markets)."},
    "vol_min_allocation":{"env": "SIMMER_WEATHER_VOL_MIN_ALLOC",     "default": 0.2,   "type": float,
                          "help": "Min allocation floor from vol targeting (stay in market during high vol)."},
    "vol_span":          {"env": "SIMMER_WEATHER_VOL_SPAN",          "default": 10,    "type": int,
                          "help": "EWMA span for volatility calculation (lower = more responsive)."},
}

# Backwards-compatible env var aliases (old name -> new name)
_LEGACY_ENV_ALIASES = {
    "SIMMER_WEATHER_ENTRY":        "SIMMER_WEATHER_ENTRY_THRESHOLD",
    "SIMMER_WEATHER_EXIT":         "SIMMER_WEATHER_EXIT_THRESHOLD",
    "SIMMER_WEATHER_MAX_POSITION": "SIMMER_WEATHER_MAX_POSITION_USD",
    "SIMMER_WEATHER_MAX_TRADES":   "SIMMER_WEATHER_MAX_TRADES_PER_RUN",
}
for _old, _new in _LEGACY_ENV_ALIASES.items():
    if _old in os.environ and _new not in os.environ:
        os.environ[_new] = os.environ[_old]

# Load configuration
_config = load_config(CONFIG_SCHEMA, __file__, slug="polymarket-weather-trader")

NOAA_API_BASE = "https://api.weather.gov"
ORDER_TYPE = (_config.get("order_type") or "GTC").upper()

# SimmerClient singleton
_client = None

def get_client(live=True):
    """Lazy-init SimmerClient singleton."""
    global _client
    if _client is None:
        try:
            from simmer_sdk import SimmerClient
        except ImportError:
            print("Error: simmer-sdk not installed. Run: pip install simmer-sdk")
            sys.exit(1)
        [REDACTED_SECRET]"SIMMER_API_KEY")
        if not [REDACTED_SECRET]"Error: SIMMER_API_KEY environment variable not set")
            print("Get your API key from: simmer.markets/dashboard -> SDK tab")
            sys.exit(1)
        venue = os.environ.get("TRADING_VENUE", "polymarket")
        _client = SimmerClient([REDACTED_SECRET], venue=venue, live=live)
    return _client

# Source tag for tracking
TRADE_SOURCE = "sdk:weather"
SKILL_SLUG = "polymarket-weather-trader"
_automaton_reported = False

# Polymarket constraints
MIN_SHARES_PER_ORDER = 5.0  # Polymarket requires minimum 5 shares
MIN_TICK_SIZE = 0.01        # Minimum tradeable price

# Strategy parameters - from config
ENTRY_THRESHOLD = _config["entry_threshold"]
EXIT_THRESHOLD = _config["exit_threshold"]
MAX_POSITION_USD = _config["max_position_usd"]
_automaton_max = os.environ.get("AUTOMATON_MAX_BET")
if _automaton_max:
    MAX_POSITION_USD = min(MAX_POSITION_USD, float(_automaton_max))

# Smart sizing parameters
SMART_SIZING_PCT = _config["sizing_pct"]

# Rate limiting
MAX_TRADES_PER_RUN = _config["max_trades_per_run"]

# Market type filter
BINARY_ONLY = _config["binary_only"]

# Volatility targeting parameters
VOL_TARGETING = _config["vol_targeting"]
TARGET_VOL = _config["target_vol"]
VOL_MAX_LEVERAGE = _config["vol_max_leverage"]
VOL_MIN_ALLOCATION = _config["vol_min_allocation"]
VOL_SPAN = _config["vol_span"]

# Context safeguard thresholds
SLIPPAGE_MAX_PCT = _config["slippage_max"]  # Skip if slippage exceeds this (tunable)
MIN_LIQUIDITY_USD = _config["min_liquidity"]  # Skip markets with liquidity below this (0 = disabled)
TIME_TO_RESOLUTION_MIN_HOURS = 2  # Skip if resolving in < 2 hours

# Price trend detection
PRICE_DROP_THRESHOLD = 0.10  # 10% drop in last 24h = stronger signal

# Supported locations (matching Polymarket resolution sources)
LOCATIONS = {
    "NYC": {"lat": 40.7769, "lon": -73.8740, "name": "New York City (LaGuardia)", "station": "KLGA"},
    "Chicago": {"lat": 41.9742, "lon": -87.9073, "name": "Chicago (O'Hare)", "station": "KORD"},
    "Seattle": {"lat": 47.4502, "lon": -122.3088, "name": "Seattle (Sea-Tac)", "station": "KSEA"},
    "Atlanta": {"lat": 33.6407, "lon": -84.4277, "name": "Atlanta (Hartsfield)", "station": "KATL"},
    "Dallas": {"lat": 32.8998, "lon": -97.0403, "name": "Dallas (DFW)", "station": "KDFW"},
    "Miami": {"lat": 25.7959, "lon": -80.2870, "name": "Miami (MIA)", "station": "KMIA"},
}

# Active locations - from config
_locations_str = _config["locations"]
ACTIVE_LOCATIONS = [loc.strip().upper() for loc in _locations_str.split(",") if loc.strip()]

# =============================================================================
# NOAA Weather API
# =============================================================================

# International city coordinates for Open-Meteo fallback
# Keyed by the city name as it appears in market questions
INTERNATIONAL_LOCATIONS = {
    "Tel Aviv":   {"lat": 32.0853, "lon": 34.7818, "tz": "Asia/Jerusalem"},
    "Munich":     {"lat": 48.1351, "lon": 11.5820, "tz": "Europe/Berlin"},
    "London":     {"lat": 51.5074, "lon": -0.1278, "tz": "Europe/London"},
    "Tokyo":      {"lat": 35.6762, "lon": 139.6503, "tz": "Asia/Tokyo"},
    "Seoul":      {"lat": 37.5665, "lon": 126.9780, "tz": "Asia/Seoul"},
    "Ankara":     {"lat": 39.9334, "lon": 32.8597,  "tz": "Europe/Istanbul"},
    "Lucknow":    {"lat": 26.8467, "lon": 80.9462,  "tz": "Asia/Kolkata"},
    "Wellington": {"lat": -41.2866, "lon": 174.7756, "tz": "Pacific/Auckland"},
}

OPEN_METEO_BASE = "https://api.open-meteo.com/v1/forecast"

def get_openmeteo_forecast(city: str) -> dict:
    """Get Open-Meteo forecast for an international city.
    Returns dict with date -> {"high_c": temp, "low_c": temp} in Celsius.
    """
    loc = INTERNATIONAL_LOCATIONS.get(city)
    if not loc:
        return {}

    params = (
        f"?latitude={loc['lat']}&longitude={loc['lon']}"
        f"&daily=temperature_2m_max,temperature_2m_min"
        f"&temperature_unit=celsius"
        f"&timezone={loc['tz'].replace('/', '%2F')}"
        f"&forecast_days=10"
    )
    url = OPEN_METEO_BASE + params
    try:
        from urllib.request import urlopen
        import json as _json
        with urlopen(url, timeout=15) as resp:
            data = _json.loads(resp.read().decode())
    except Exception as e:
        print(f"  Open-Meteo error for {city}: {e}")
        return {}

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

    forecasts = {}
    for d, h, l in zip(dates, highs, lows):
        forecasts[d] = {
            "high_c": round(h) if h is not None else None,
            "low_c":  round(l) if l is not None else None,
        }
    return forecasts


def fetch_json(url, headers=None):
    """Fetch JSON from URL with error handling."""
    try:
        req = Request(url, headers=headers or {})
        with urlopen(req, timeout=30) as response:
            return json.loads(response.read().decode())
    except HTTPError as e:
        print(f"  HTTP Error {e.code}: {url}")
        return None
    except URLError as e:
        print(f"  URL Error: {e.reason}")
        return None
    except Exception as e:
        print(f"  Error fetching {url}: {e}")
        return None


def get_noaa_forecast(location: str) -> dict:
    """Get NOAA forecast for a location. Returns dict with date -> {"high": temp, "low": temp}"""
    if location not in LOCATIONS:
        print(f"  Unknown location: {location}")
        return {}

    loc = LOCATIONS[location]
    headers = {
        "User-Agent": "SimmerWeatherSkill/1.0 (https://simmer.markets)",
        "Accept": "application/geo+json",
    }

    points_url = f"{NOAA_API_BASE}/points/{loc['lat']},{loc['lon']}"
    points_data = fetch_json(points_url, headers)

    if not points_data or "properties" not in points_data:
        print(f"  Failed to get NOAA grid for {location}")
        return {}

    forecast_url = points_data["properties"].get("forecast")
    if not forecast_url:
        print(f"  No forecast URL for {location}")
        return {}

    forecast_data = fetch_json(forecast_url, headers)
    if not forecast_data or "properties" not in forecast_data:
        print(f"  Failed to get NOAA forecast for {location}")
        return {}

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

    for period in periods:
        start_time = period.get("startTime", "")
        if not start_time:
            continue

        date_str = start_time[:10]
        temp = period.get("temperature")
        is_daytime = period.get("isDaytime", True)

        if date_str not in forecasts:
            forecasts[date_str] = {"high": None, "low": None}

        if is_daytime:
            forecasts[date_str]["high"] = temp
        else:
            forecasts[date_str]["low"] = temp

    # Supplement with NOAA observations for today (D+0)
    # /forecast often starts from the next period, missing today's daytime high
    today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    if today_str not in forecasts or forecasts[today_str].get("high") is None:
        station_id = loc.get("station")
        if station_id:
            try:
                obs_url = f"{NOAA_API_BASE}/stations/{station_id}/observations/latest"
                obs_data = fetch_json(obs_url, headers)
                if obs_data and "properties" in obs_data:
                    temp_c = obs_data["properties"].get("temperature", {}).get("value")
                    if temp_c is not None:
                        temp_f = round(temp_c * 9 / 5 + 32)
                        if today_str not in forecasts:
                            forecasts[today_str] = {"high": None, "low": None}
                        if forecasts[today_str]["high"] is None:
                            forecasts[today_str]["high"] = temp_f
                        if forecasts[today_str]["low"] is None:
                            forecasts[today_str]["low"] = temp_f
            except Exception:
                pass  # Observation fetch is best-effort

    return forecasts


# =============================================================================
# Market Parsing
# =============================================================================

def parse_weather_event(event_name: str) -> dict:
    """Parse weather event name to extract location, date, metric."""
    if not event_name:
        return None

    event_lower = event_name.lower()

    if 'highest' in event_lower or 'high temp' in event_lower:
        metric = 'high'
    elif 'lowest' in event_lower or 'low temp' in event_lower:
        metric = 'low'
    else:
        metric = 'high'

    location = None
    location_aliases = {
        'nyc': 'NYC', 'new york': 'NYC', 'laguardia': 'NYC', 'la guardia': 'NYC',
        'chicago': 'Chicago', "o'hare": 'Chicago', 'ohare': 'Chicago',
        'seattle': 'Seattle', 'sea-tac': 'Seattle',
        'atlanta': 'Atlanta', 'hartsfield': 'Atlanta',
        'dallas': 'Dallas', 'dfw': 'Dallas',
        'miami': 'Miami',
        # International cities (Open-Meteo)
        'tel aviv': 'Tel Aviv',
        'munich': 'Munich',
        'london': 'London',
        'tokyo': 'Tokyo',
        'seoul': 'Seoul',
        'ankara': 'Ankara',
        'lucknow': 'Lucknow',
        'wellington': 'Wellington',
    }

    for alias, loc in location_aliases.items():
        if alias in event_lower:
            location = loc
            break

    if not location:
        return None

    # Detect temperature unit from event name
    temp_unit = 'C' if '°c' in event_lower or re.search(r'\d+°?c\b', event_lower, re.IGNORECASE) else 'F'

    month_day_match = re.search(r'on\s+([a-zA-Z]+)\s+(\d{1,2})', event_name, re.IGNORECASE)
    if not month_day_match:
        return None

    month_name = month_day_match.group(1).lower()
    day = int(month_day_match.group(2))

    month_map = {
        'january': 1, 'jan': 1, 'february': 2, 'feb': 2, 'march': 3, 'mar': 3,
        'april': 4, 'apr': 4, 'may': 5, 'june': 6, 'jun': 6, 'july': 7, 'jul': 7,
        'august': 8, 'aug': 8, 'september': 9, 'sep': 9, 'october': 10, 'oct': 10,
        'november': 11, 'nov': 11, 'december': 12, 'dec': 12,
    }

    month = month_map.get(month_name)
    if not month:
        return None

    now = datetime.now(timezone.utc)
    year = now.year
    try:
        target_date = datetime(year, month, day, tzinfo=timezone.utc)
        if target_date < now - timedelta(days=7):
            year += 1
        date_str = f"{year}-{month:02d}-{day:02d}"
    except ValueError:
        return None

    return {"location": location, "date": date_str, "metric": metric, "unit": temp_unit}


def parse_temperature_bucket(outcome_name: str) -> tuple:
    """Parse temperature bucket from outcome name. Works for both °F and °C markets,
    including single-degree exact buckets (e.g. '22°C') and ranges (e.g. '54-55°F')."""
    if not outcome_name:
        return None

    below_match = re.search(r'(\d+)\s*°?[fFcC]?\s*(or below|or less)', outcome_name, re.IGNORECASE)
    if below_match:
        return (-999, int(below_match.group(1)))

    above_match = re.search(r'(\d+)\s*°?[fFcC]?\s*(or higher|or above|or more)', outcome_name, re.IGNORECASE)
    if above_match:
        return (int(above_match.group(1)), 999)

    range_match = re.search(r'(\d+)\s*(?:°?\s*[fFcC])?\s*(?:-|–|to)\s*(\d+)', outcome_name)
    if range_match:
        low, high = int(range_match.group(1)), int(range_match.group(2))
        return (min(low, high), max(low, high))

    # Single exact-degree bucket: "be 22°C on" or "22°F"
    exact_match = re.search(r'\b(\d+)\s*°[fFcC]\b', outcome_name)
    if exact_match:
        t = int(exact_match.group(1))
        return (t, t)

    # Bare integer in short outcome names like "22°C"
    bare_match = re.match(r'^\s*(\d+)\s*°?[cCfF]?\s*$', outcome_name.strip())
    if bare_match:
        t = int(bare_match.group(1))
        return (t, t)

    return None


# =============================================================================
# Simmer API - Core
# =============================================================================

# =============================================================================
# Simmer API - Portfolio & Context
# =============================================================================

def get_portfolio() -> dict:
    """Get portfolio summary from SDK."""
    try:
        return get_client().get_portfolio()
    except Exception as e:
        print(f"  ⚠️  Portfolio fetch failed: {e}")
        return None


def get_market_context(market_id: str, my_probability: float = None) -> dict:
    """Get market context with safeguards and optional edge analysis."""
    try:
        if my_probability is not None:
            return get_client()._request("GET", f"/api/sdk/context/{market_id}",
                                         params={"my_probability": my_probability})
        return get_client().get_market_context(market_id)
    except Exception:
        return None


def get_price_history(market_id: str) -> list:
    """Get price history for trend detection."""
    try:
        return get_client().get_price_history(market_id)
    except Exception:
        return []


def check_context_safeguards(context: dict, use_edge: bool = True) -> tuple:
    """
    Check context for safeguards. Returns (should_trade, reasons).
    
    Args:
        context: Context response from SDK
        use_edge: If True, respect edge recommendation (TRADE/HOLD/SKIP)
    """
    if not context:
        return True, []  # No context = proceed (fail open)

    reasons = []
    market = context.get("market", {})
    warnings = context.get("warnings", [])
    discipline = context.get("discipline", {})
    slippage = context.get("slippage", {})
    edge = context.get("edge", {})

    # Check for deal-breakers in warnings
    for warning in warnings:
        if "MARKET RESOLVED" in str(warning).upper():
            return False, ["Market already resolved"]

    # Check flip-flop warning
    warning_level = discipline.get("warning_level", "none")
    if warning_level == "severe":
        return False, [f"Severe flip-flop warning: {discipline.get('flip_flop_warning', '')}"]
    elif warning_level == "mild":
        reasons.append("Mild flip-flop warning (proceed with caution)")

    # Check time to resolution
    time_str = market.get("time_to_resolution", "")
    if time_str:
        try:
            hours = 0
            if "d" in time_str:
                days = int(time_str.split("d")[0].strip())
                hours += days * 24
            if "h" in time_str:
                h_part = time_str.split("h")[0]
                if "d" in h_part:
                    h_part = h_part.split("d")[-1].strip()
                hours += int(h_part)

            if hours < TIME_TO_RESOLUTION_MIN_HOURS:
                return False, [f"Resolves in {hours}h - too soon"]
        except (ValueError, IndexError):
            pass

    # Check liquidity (pre-filter before slippage, avoids wasting a context call)
    if MIN_LIQUIDITY_USD > 0:
        liquidity = market.get("liquidity", 0) or 0
        if liquidity < MIN_LIQUIDITY_USD:
            return False, [f"Liquidity too low: ${liquidity:.0f} < ${MIN_LIQUIDITY_USD:.0f} min"]

    # Check slippage
    estimates = slippage.get("estimates", []) if slippage else []
    if estimates:
        slippage_pct = estimates[0].get("slippage_pct", 0)
        if slippage_pct > SLIPPAGE_MAX_PCT:
            return False, [f"Slippage too high: {slippage_pct:.1%} (max {SLIPPAGE_MAX_PCT:.0%})"]

    # Check edge recommendation (if available and use_edge=True)
    if use_edge and edge:
        recommendation = edge.get("recommendation")
        user_edge = edge.get("user_edge")
        threshold = edge.get("suggested_threshold", 0)
        
        if recommendation == "SKIP":
            return False, ["Edge analysis: SKIP (market resolved or invalid)"]
        elif recommendation == "HOLD":
            if user_edge is not None and threshold:
                reasons.append(f"Edge {user_edge:.1%} below threshold {threshold:.1%} - marginal opportunity")
            else:
                reasons.append("Edge analysis recommends HOLD")
        elif recommendation == "TRADE":
            reasons.append(f"Edge {user_edge:.1%} ≥ threshold {threshold:.1%} - good opportunity")

    return True, reasons


def detect_price_trend(history: list) -> dict:
    """
    Analyze price history for trends.
    Returns: {direction: "up"/"down"/"flat", change_24h: float, is_opportunity: bool}
    """
    if not history or len(history) < 2:
        return {"direction": "unknown", "change_24h": 0, "is_opportunity": False}

    # Get recent and older prices
    recent_price = history[-1].get("price_yes", 0.5)
    
    # Find price ~24h ago (assuming 15-min intervals, ~96 points)
    lookback = min(96, len(history) - 1)
    old_price = history[-lookback].get("price_yes", recent_price)

    if old_price == 0:
        return {"direction": "unknown", "change_24h": 0, "is_opportunity": False}

    change = (recent_price - old_price) / old_price

    if change < -PRICE_DROP_THRESHOLD:
        return {"direction": "down", "change_24h": change, "is_opportunity": True}
    elif change > PRICE_DROP_THRESHOLD:
        return {"direction": "up", "change_24h": change, "is_opportunity": False}
    else:
        return {"direction": "flat", "change_24h": change, "is_opportunity": False}


# =============================================================================
# Volatility Targeting
# =============================================================================

import math

def calculate_ewma_vol(history: list, span: int = 10) -> float | None:
    """
    Calculate annualized EWMA volatility from price history points.

    Uses log returns of YES prices with exponentially weighted moving average.
    Returns annualized volatility as a decimal (e.g. 0.25 = 25%), or None if
    insufficient data.

    Args:
        history: List of dicts with 'price_yes' key (from get_price_history)
        span: EWMA span — lower values weight recent data more heavily
    """
    prices = [p.get("price_yes") or 0 for p in history]
    # Filter out zero/near-zero prices that would break log returns
    prices = [p for p in prices if p > 0.001]
    if len(prices) < span + 5:
        return None

    # Log returns
    log_returns = [math.log(prices[i] / prices[i - 1]) for i in range(1, len(prices))]
    if not log_returns:
        return None

    # EWMA variance (exponentially weighted moving average of squared deviations)
    alpha = 2.0 / (span + 1)
    ewma_var = log_returns[0] ** 2  # seed with first squared return
    for r in log_returns[1:]:
        ewma_var = alpha * (r ** 2) + (1 - alpha) * ewma_var

    ewma_std = math.sqrt(ewma_var)

    # Annualize: price history is ~15-min intervals, ~96 per day, 365 days
    # sqrt(96 * 365) ≈ 187.2
    intervals_per_day = 96
    annualized = ewma_std * math.sqrt(intervals_per_day * 365)
    return annualized


def apply_vol_targeting(base_size: float, current_vol: float | None,
                        target_vol: float = TARGET_VOL,
                        max_leverage: float = VOL_MAX_LEVERAGE,
                        min_allocation: float = VOL_MIN_ALLOCATION) -> tuple:
    """
    Apply volatility targeting multiplier to base position size.

    Returns (adjusted_size, metadata_dict).
    Falls back to base_size if vol data is unavailable.
    """
    meta = {"vol_targeting": True, "base_size": base_size, "current_vol": current_vol,
            "target_vol": target_vol}

    if current_vol is None or current_vol <= 0:
        meta["adjusted_for"] = "no_vol_data"
        meta["leverage"] = 1.0
        return base_size, meta

    raw_leverage = target_vol / current_vol
    leverage = max(min_allocation, min(raw_leverage, max_leverage))

    if leverage == min_allocation:
        meta["adjusted_for"] = "min_allocation_floor"
    elif leverage == max_leverage:
        meta["adjusted_for"] = "max_leverage_cap"
    else:
        meta["adjusted_for"] = "volatility_target"

    meta["raw_leverage"] = round(raw_leverage, 3)
    meta["leverage"] = round(leverage, 3)

    return round(base_size * leverage, 2), meta


# =============================================================================
# Market Discovery - Auto-import from Polymarket
# =============================================================================
# NOTE: Unlike fastloop (which queries Gamma API directly with tag=crypto),
# weather uses Simmer's list_importable_markets (Dome-backed keyword search).
# Gamma API has no weather/temperature tag and no public text search endpoint
# (/search requires auth). Tested Feb 2026: 600+ events paginated, zero weather.
# This path is slower but is the only way to discover weather markets by keyword.
# Trading does NOT depend on discovery — v1.10.1+ trades from already-imported
# markets via GET /api/sdk/markets?tags=weather.
# =============================================================================

# Search terms per location (matching Polymarket event naming)
LOCATION_SEARCH_TERMS = {
    "NYC": ["temperature new york", "temperature nyc"],
    "Chicago": ["temperature chicago"],
    "Seattle": ["temperature seattle"],
    "Atlanta": ["temperature atlanta"],
    "Dallas": ["temperature dallas"],
    "Miami": ["temperature miami"],
}


def discover_and_import_weather_markets(log=print):
    """Discover weather markets on Polymarket and auto-import to Simmer.

    Searches the importable markets endpoint for weather events matching
    ACTIVE_LOCATIONS, then imports any that aren't already in Simmer.

    Returns count of newly imported markets.
    """
    client = get_client()
    imported_count = 0
    seen_urls = set()

    for location in ACTIVE_LOCATIONS:
        search_terms = LOCATION_SEARCH_TERMS.get(location, [f"temperature {location.lower()}"])

        for term in search_terms:
            try:
                results = client.list_importable_markets(
                    q=term, venue="polymarket", min_volume=1000, limit=20
                )
            except Exception as e:
                log(f"  Discovery search failed for '{term}': {e}")
                continue

            for m in results:
                url = m.get("url", "")
                question = (m.get("question") or "").lower()
                if not url or url in seen_urls:
                    continue
                seen_urls.add(url)

                # Filter: must be a temperature market on Polymarket
                if "temperature" not in question:
                    continue
                if not url.startswith("https://polymarket.com/"):
                    continue

                # Try to import
                try:
                    result = client.import_market(url)
                    status = result.get("status", "") if result else ""
                    if status == "imported":
                        imported_count += 1
                        log(f"  Imported: {m.get('question', url)[:70]}")
                    elif status == "already_exists":
                        pass  # Expected for most
                except Exception as e:
                    err_str = str(e)
                    if "rate limit" in err_str.lower() or "429" in err_str:
                        log(f"  Import rate limit reached — stopping discovery")
                        return imported_count
                    log(f"  Import failed for {url[:50]}: {e}")

    return imported_count


# =============================================================================
# Simmer API - Trading
# =============================================================================

def fetch_weather_markets():
    """Fetch weather-tagged markets from Simmer API."""
    try:
        result = get_client()._request("GET", "/api/sdk/markets",
                                       params={"tags": "weather", "status": "active", "limit": 100})
        return result.get("markets", [])
    except Exception:
        print("  Failed to fetch markets from Simmer API")
        return []


def execute_trade(market_id: str, side: str, amount: float, reasoning: str = None, signal_data: dict = None) -> dict:
    """Execute a buy trade via Simmer SDK with source tagging."""
    try:
        result = get_client().trade(
            market_id=market_id, side=side, amount=amount, source=TRADE_SOURCE, skill_slug=SKILL_SLUG,
            reasoning=reasoning, signal_data=signal_data, order_type=ORDER_TYPE,
        )
        out = {
            "success": result.success, "trade_id": result.trade_id,
            "shares_bought": result.shares_bought, "shares": result.shares_bought,
            "error": result.error, "simulated": result.simulated,
            "order_status": result.order_status,
        }
        if result.order_status == "live":
            print(f"  [GTC] Order placed on book — waiting for fill (trade {result.trade_id})")
        return out
    except Exception as e:
        return {"error": str(e)}


def execute_sell(market_id: str, shares: float) -> dict:
    """Execute a sell trade via Simmer SDK with source tagging."""
    try:
        result = get_client().trade(
            market_id=market_id, side="yes", action="sell",
            shares=shares, source=TRADE_SOURCE, skill_slug=SKILL_SLUG,
            order_type=ORDER_TYPE,
        )
        out = {
            "success": result.success, "trade_id": result.trade_id,
            "error": result.error, "simulated": result.simulated,
            "order_status": result.order_status,
        }
        if result.order_status == "live":
            print(f"  [GTC] Sell order placed on book — waiting for fill (trade {result.trade_id})")
        return out
    except Exception as e:
        return {"error": str(e)}


def get_positions(venue: str = None) -> list:
    """Get current positions as list of dicts, filtered by venue."""
    try:
        client = get_client()
        # Default to the client's configured venue to avoid cross-venue positions
        effective_venue = venue or client.venue
        positions = client.get_positions(venue=effective_venue)
        from dataclasses import asdict
        return [asdict(p) for p in positions]
    except Exception as e:
        print(f"  Error fetching positions: {e}")
        return []


def calculate_position_size(default_size: float, smart_sizing: bool) -> float:
    """Calculate position size based on portfolio or fall back to default."""
    if not smart_sizing:
        return default_size

    portfolio = get_portfolio()
    if not portfolio:
        print(f"  ⚠️  Smart sizing failed, using default ${default_size:.2f}")
        return default_size

    balance = portfolio.get("balance_usdc", 0)
    if balance <= 0:
        print(f"  ⚠️  No available balance, using default ${default_size:.2f}")
        return default_size

    smart_size = balance * SMART_SIZING_PCT
    smart_size = min(smart_size, MAX_POSITION_USD)
    smart_size = max(smart_size, 1.0)

    print(f"  💡 Smart sizing: ${smart_size:.2f} ({SMART_SIZING_PCT:.0%} of ${balance:.2f} balance)")
    return smart_size


# =============================================================================
# Exit Strategy
# =============================================================================

def check_exit_opportunities(dry_run: bool = False, use_safeguards: bool = True) -> tuple:
    """Check open positions for exit opportunities. Returns: (exits_found, exits_executed)"""
    positions = get_positions()

    if not positions:
        return 0, 0

    weather_positions = []
    for pos in positions:
        question = pos.get("question", "").lower()
        sources = pos.get("sources", [])
        # Check if from weather skill OR has weather keywords
        if TRADE_SOURCE in sources or any(kw in question for kw in ["temperature", "°f", "highest temp", "lowest temp"]):
            weather_positions.append(pos)

    if not weather_positions:
        return 0, 0

    print(f"\n📈 Checking {len(weather_positions)} weather positions for exit...")

    exits_found = 0
    exits_executed = 0

    for pos in weather_positions:
        market_id = pos.get("market_id")
        current_price = pos.get("current_price") or pos.get("price_yes") or 0
        shares = pos.get("shares_yes") or pos.get("shares") or 0
        question = pos.get("question", "Unknown")[:50]

        if shares < MIN_SHARES_PER_ORDER:
            continue

        if current_price >= EXIT_THRESHOLD:
            exits_found += 1
            print(f"  📤 {question}...")
            print(f"     Price ${current_price:.2f} >= exit threshold ${EXIT_THRESHOLD:.2f}")

            # Check safeguards before selling
            if use_safeguards:
                context = get_market_context(market_id)
                should_trade, reasons = check_context_safeguards(context)
                if not should_trade:
                    print(f"     ⏭️  Skipped: {'; '.join(reasons)}")
                    continue
                if reasons:
                    print(f"     ⚠️  Warnings: {'; '.join(reasons)}")

            # Re-fetch fresh share count to avoid selling more than available
            fresh_positions = get_positions()
            fresh_pos = next((p for p in fresh_positions if p.get("market_id") == market_id), None)
            if fresh_pos:
                fresh_shares = fresh_pos.get("shares_yes") or fresh_pos.get("shares") or 0
                if fresh_shares < MIN_SHARES_PER_ORDER:
                    print(f"     ⏭️  Skipped: fresh share count {fresh_shares:.1f} below minimum")
                    continue
                if fresh_shares != shares:
                    print(f"     ℹ️  Share count updated: {shares:.1f} → {fresh_shares:.1f}")
                    shares = fresh_shares

            tag = "SIMULATED" if dry_run else "LIVE"
            print(f"     Selling {shares:.1f} shares ({tag})...")
            result = execute_sell(market_id, shares)

            if result.get("success"):
                exits_executed += 1
                trade_id = result.get("trade_id")
                print(f"     ✅ {'[PAPER] ' if result.get('simulated') else ''}Sold {shares:.1f} shares @ ${current_price:.2f}")

                # Log sell trade context for journal (skip for paper trades)
                if trade_id and JOURNAL_AVAILABLE and not result.get("simulated"):
                    log_trade(
                        trade_id=trade_id,
                        source=TRADE_SOURCE, skill_slug=SKILL_SLUG,
                        thesis=f"Exit: price ${current_price:.2f} reached exit threshold ${EXIT_THRESHOLD:.2f}",
                        action="sell",
                    )
            else:
                error = result.get("error", "Unknown error")
                print(f"     ❌ Sell failed: {error}")
        else:
            print(f"  📊 {question}...")
            print(f"     Price ${current_price:.2f} < exit threshold ${EXIT_THRESHOLD:.2f} - hold")

    return exits_found, exits_executed


# =============================================================================
# Main Strategy Logic
# =============================================================================

def run_weather_strategy(dry_run: bool = True, positions_only: bool = False,
                         show_config: bool = False, smart_sizing: bool = False,
                         use_safeguards: bool = True, use_trends: bool = True,
                         quiet: bool = False, vol_targeting: bool = VOL_TARGETING):
    """Run the weather trading strategy."""
    def log(msg, force=False):
        """Print unless quiet mode is on. force=True always prints."""
        if not quiet or force:
            print(msg)

    log("🌤️  Simmer Weather Trading Skill")
    log("=" * 50)

    if dry_run:
        log("\n  [PAPER MODE] Trades will be simulated with real prices. Use --live for real trades.")

    log(f"\n⚙️  Configuration:")
    log(f"  Entry threshold: {ENTRY_THRESHOLD:.0%} (buy below this)")
    log(f"  Exit threshold:  {EXIT_THRESHOLD:.0%} (sell above this)")
    log(f"  Max position:    ${MAX_POSITION_USD:.2f}")
    log(f"  Max trades/run:  {MAX_TRADES_PER_RUN}")
    log(f"  Locations:       {', '.join(ACTIVE_LOCATIONS)}")
    log(f"  Smart sizing:    {'✓ Enabled' if smart_sizing else '✗ Disabled'}")
    log(f"  Safeguards:      {'✓ Enabled' if use_safeguards else '✗ Disabled'}")
    log(f"  Trend detection: {'✓ Enabled' if use_trends else '✗ Disabled'}")
    log(f"  Vol targeting:   {'✓ Enabled' if vol_targeting else '✗ Disabled'}")
    if vol_targeting:
        log(f"    Target vol:    {TARGET_VOL:.0%} annualized")
        log(f"    Max leverage:  {VOL_MAX_LEVERAGE:.1f}x")
        log(f"    Min alloc:     {VOL_MIN_ALLOCATION:.0%}")
        log(f"    EWMA span:     {VOL_SPAN}")

    if show_config:
        config_path = get_config_path(__file__)
        log(f"\n  Config file: {config_path}")
        log(f"  Config exists: {'Yes' if config_path.exists() else 'No'}")
        log("\n  To change settings, either:")
        log("  1. Create/edit config.json in skill directory:")
        log('     {"entry_threshold": 0.20, "exit_threshold": 0.50, "locations": "NYC,Chicago"}')
        log("  2. Or use --set flag:")
        log("     python weather_trader.py --set entry_threshold=0.20")
        log("  3. Or set environment variables (lowest priority):")
        log("     SIMMER_WEATHER_ENTRY=0.20")
        return

    # Initialize client early to validate API key
    get_client(live=not dry_run)

    # Show portfolio if smart sizing enabled
    if smart_sizing:
        log("\n💰 Portfolio:")
        portfolio = get_portfolio()
        if portfolio:
            log(f"  Balance: ${portfolio.get('balance_usdc', 0):.2f}")
            log(f"  Exposure: ${portfolio.get('total_exposure', 0):.2f}")
            log(f"  Positions: {portfolio.get('positions_count', 0)}")
            by_source = portfolio.get('by_source', {})
            if by_source:
                log(f"  By source: {json.dumps(by_source, indent=4)}")

    if positions_only:
        log("\n📊 Current Positions:")
        positions = get_positions()
        if not positions:
            log("  No open positions")
        else:
            for pos in positions:
                log(f"  • {pos.get('question', 'Unknown')[:50]}...")
                sources = pos.get('sources', [])
                log(f"    YES: {pos.get('shares_yes', 0):.1f} | NO: {pos.get('shares_no', 0):.1f} | P&L: ${pos.get('pnl', 0):.2f} | Sources: {sources}")
        return

    log("\n🔍 Discovering new weather markets on Polymarket...")
    newly_imported = discover_and_import_weather_markets(log=log)
    if newly_imported:
        log(f"  Auto-imported {newly_imported} new market(s)")
    else:
        log("  No new markets to import")

    log("\n📡 Fetching weather markets...")
    markets = fetch_weather_markets()
    log(f"  Found {len(markets)} weather markets")

    if not markets:
        log("  No weather markets available")
        return

    events = {}
    for market in markets:
        # Group by event_id if available, otherwise derive from question
        event_key = market.get("event_id")
        if not event_key:
            # Fall back: parse question to derive (location, date) grouping key
            info = parse_weather_event(market.get("event_name") or market.get("question", ""))
            event_key = f"{info['location']}_{info['date']}" if info else "unknown"
        if event_key not in events:
            events[event_key] = []
        events[event_key].append(market)

    log(f"  Grouped into {len(events)} events")

    forecast_cache = {}
    trades_executed = 0
    total_usd_spent = 0.0
    opportunities_found = 0
    skip_reasons = []
    execution_errors = []

    for event_id, event_markets in events.items():
        # Use event_name from API if available, otherwise parse from question
        event_name = event_markets[0].get("event_name") or event_markets[0].get("question", "")
        event_info = parse_weather_event(event_name)

        if not event_info:
            continue

        location = event_info["location"]
        date_str = event_info["date"]
        metric = event_info["metric"]

        if location.upper() not in ACTIVE_LOCATIONS:
            continue

        # Skip range-bucket events (multi-outcome) if binary_only is set
        if BINARY_ONLY and len(event_markets) > 2:
            log(f"  ⏭️  Skipping range event ({len(event_markets)} outcomes) — binary_only=true")
            continue

        log(f"\n📍 {location} {date_str} ({metric} temp)")

        # Determine forecast source: NOAA for US cities, Open-Meteo for international
        is_international = location in INTERNATIONAL_LOCATIONS
        temp_unit = event_info.get("unit", "F")

        if location not in forecast_cache:
            if is_international:
                log(f"  Fetching Open-Meteo forecast...")
                raw = get_openmeteo_forecast(location)
                # Normalise to {"high": temp, "low": temp} using Celsius keys
                forecast_cache[location] = {
                    d: {"high": v.get("high_c"), "low": v.get("low_c")}
                    for d, v in raw.items()
                }
            else:
                log(f"  Fetching NOAA forecast...")
                forecast_cache[location] = get_noaa_forecast(location)

        forecasts = forecast_cache[location]
        day_forecast = forecasts.get(date_str, {})
        forecast_temp = day_forecast.get(metric)

        if forecast_temp is None:
            log(f"  ⚠️  No forecast available for {date_str}")
            continue

        unit_label = "°C" if is_international else "°F"
        source_label = "Open-Meteo" if is_international else "NOAA"
        log(f"  {source_label} forecast: {forecast_temp}{unit_label}")

        matching_market = None
        for market in event_markets:
            outcome_name = market.get("outcome_name") or market.get("question", "")
            bucket = parse_temperature_bucket(outcome_name)

            if bucket and bucket[0] <= forecast_temp <= bucket[1]:
                matching_market = market
                break

        if not matching_market:
            log(f"  ⚠️  No bucket found for {forecast_temp}{unit_label}")
            continue

        outcome_name = matching_market.get("outcome_name", "")
        price = matching_market.get("external_price_yes") or 0.5
        market_id = matching_market.get("id")

        log(f"  Matching bucket: {outcome_name} @ ${price:.2f}")

        if price < MIN_TICK_SIZE:
            log(f"  ⏸️  Price ${price:.4f} below min tick ${MIN_TICK_SIZE} - skip (market at extreme)")
            skip_reasons.append("price at extreme")
            continue
        if price > (1 - MIN_TICK_SIZE):
            log(f"  ⏸️  Price ${price:.4f} above max tradeable - skip (market at extreme)")
            skip_reasons.append("price at extreme")
            continue

        # Check safeguards with edge analysis
        # NOAA forecasts are ~85% accurate for 1-2 day predictions when in-bucket
        noaa_probability = 0.85
        if use_safeguards:
            context = get_market_context(market_id, my_probability=noaa_probability)
            should_trade, reasons = check_context_safeguards(context)
            if not should_trade:
                log(f"  ⏭️  Safeguard blocked: {'; '.join(reasons)}")
                skip_reasons.append(f"safeguard: {reasons[0]}")
                continue
            if reasons:
                log(f"  ⚠️  Warnings: {'; '.join(reasons)}")

        # Fetch price history once — used for both trend detection and vol targeting
        history = []
        if use_trends or vol_targeting:
            history = get_price_history(market_id)

        # Check price trend
        trend_bonus = ""
        if use_trends and history:
            trend = detect_price_trend(history)
            if trend["is_opportunity"]:
                trend_bonus = f" 📉 (dropped {abs(trend['change_24h']):.0%} in 24h - stronger signal!)"
            elif trend["direction"] == "up":
                trend_bonus = f" 📈 (up {trend['change_24h']:.0%} in 24h)"

        if price < ENTRY_THRESHOLD:
            position_size = calculate_position_size(MAX_POSITION_USD, smart_sizing)

            # Apply volatility targeting
            vol_meta = None
            if vol_targeting and history:
                current_vol = calculate_ewma_vol(history, span=VOL_SPAN)
                position_size, vol_meta = apply_vol_targeting(
                    position_size, current_vol,
                    target_vol=TARGET_VOL,
                    max_leverage=VOL_MAX_LEVERAGE,
                    min_allocation=VOL_MIN_ALLOCATION,
                )
                if current_vol is not None:
                    log(f"  📊 Vol targeting: realized={current_vol:.0%} target={TARGET_VOL:.0%} → {vol_meta['leverage']:.2f}x (${position_size:.2f})")
                else:
                    log(f"  📊 Vol targeting: insufficient price data — using base size")

            min_cost_for_shares = MIN_SHARES_PER_ORDER * price
            if min_cost_for_shares > position_size:
                log(f"  ⚠️  Position size ${position_size:.2f} too small for {MIN_SHARES_PER_ORDER} shares at ${price:.2f}")
                skip_reasons.append("position too small")
                continue

            opportunities_found += 1
            log(f"  ✅ Below threshold (${ENTRY_THRESHOLD:.2f}) - BUY opportunity!{trend_bonus}")

            # Check rate limit
            if trades_executed >= MAX_TRADES_PER_RUN:
                log(f"  ⏸️  Max trades per run ({MAX_TRADES_PER_RUN}) reached - skipping")
                skip_reasons.append("max trades reached")
                continue

            tag = "SIMULATED" if dry_run else "LIVE"
            log(f"  Executing trade ({tag})...", force=True)
            edge = noaa_probability - price
            signal = {
                    "edge": round(edge, 4),
                    "confidence": noaa_probability,
                    "signal_source": "noaa_forecast",
                    "forecast_temp": forecast_temp,
                    "bucket_range": outcome_name,
                    "market_price": round(price, 4),
                    "threshold": ENTRY_THRESHOLD,
            }
            if vol_meta:
                signal["vol_targeting"] = vol_meta
            result = execute_trade(
                market_id, "yes", position_size,
                reasoning=f"NOAA forecasts {forecast_temp}{unit_label} → bucket {outcome_name} underpriced at {price:.0%}",
                signal_data=signal,
            )

            if result.get("success"):
                trades_executed += 1
                total_usd_spent += position_size
                shares = result.get("shares_bought") or result.get("shares") or 0
                trade_id = result.get("trade_id")
                log(f"  ✅ {'[PAPER] ' if result.get('simulated') else ''}Bought {shares:.1f} shares @ ${price:.2f}", force=True)

                # Log trade context for journal (skip for paper trades)
                if trade_id and JOURNAL_AVAILABLE and not result.get("simulated"):
                    # Confidence based on price gap from threshold (guard against div by zero)
                    if ENTRY_THRESHOLD > 0:
                        confidence = min(0.95, (ENTRY_THRESHOLD - price) / ENTRY_THRESHOLD + 0.5)
                    else:
                        confidence = 0.7  # Default confidence if threshold is zero
                    log_trade(
                        trade_id=trade_id,
                        source=TRADE_SOURCE, skill_slug=SKILL_SLUG,
                        thesis=f"{'Open-Meteo' if is_international else 'NOAA'} forecasts {forecast_temp}{unit_label} for {location} on {date_str}, "
                               f"bucket '{outcome_name}' underpriced at ${price:.2f}",
                        confidence=round(confidence, 2),
                        location=location,
                        forecast_temp=forecast_temp,
                        target_date=date_str,
                        metric=metric,
                    )
                # Risk monitors are now auto-set via SDK settings (dashboard)
            else:
                error = result.get("error", "Unknown error")
                log(f"  ❌ Trade failed: {error}", force=True)
                execution_errors.append(error[:120])
        else:
            log(f"  ⏸️  Price ${price:.2f} above threshold ${ENTRY_THRESHOLD:.2f} - skip")

    exits_found, exits_executed = check_exit_opportunities(dry_run, use_safeguards)

    log("\n" + "=" * 50)
    total_trades = trades_executed + exits_executed
    show_summary = not quiet or total_trades > 0
    if show_summary:
        print("📊 Summary:")
        print(f"  Events scanned: {len(events)}")
        print(f"  Entry opportunities: {opportunities_found}")
        print(f"  Exit opportunities:  {exits_found}")
        print(f"  Trades executed:     {total_trades}")

    # Structured report for automaton
    if os.environ.get("AUTOMATON_MANAGED"):
        global _automaton_reported
        report = {"signals": opportunities_found + exits_found, "trades_attempted": opportunities_found + exits_found, "trades_executed": total_trades, "amount_usd": round(total_usd_spent, 2)}
        if (opportunities_found + exits_found) > 0 and total_trades == 0 and skip_reasons:
            report["skip_reason"] = ", ".join(dict.fromkeys(skip_reasons))
        if execution_errors:
            report["execution_errors"] = execution_errors
        print(json.dumps({"automaton": report}))
        _automaton_reported = True

    if dry_run and show_summary:
        print("\n  [PAPER MODE - trades simulated with real prices]")


# =============================================================================
# CLI Entry Point
# =============================================================================

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Simmer Weather Trading Skill")
    parser.add_argument("--live", action="store_true", help="Execute real trades (default is dry-run)")
    parser.add_argument("--dry-run", action="store_true", help="(Default) Show opportunities without trading")
    parser.add_argument("--positions", action="store_true", help="Show current positions only")
    parser.add_argument("--config", action="store_true", help="Show current config")
    parser.add_argument("--set", action="append", metavar="KEY=VALUE",
                        help="Set config value (e.g., --set entry_threshold=0.20)")
    parser.add_argument("--smart-sizing", action="store_true", help="Use portfolio-based position sizing")
    parser.add_argument("--no-safeguards", action="store_true", help="Disable context safeguards")
    parser.add_argument("--no-trends", action="store_true", help="Disable price trend detection")
    parser.add_argument("--vol-targeting", action="store_true", help="Enable volatility targeting (dynamic position sizing based on realized vol)")
    parser.add_argument("--quiet", "-q", action="store_true", help="Only output when trades execute or errors occur (ideal for high-frequency runs)")
    args = parser.parse_args()

    # Handle --set config updates
    if args.set:
        updates = {}
        for item in args.set:
            if "=" in item:
                key, value = item.split("=", 1)
                # Try to convert to appropriate type
                if key in CONFIG_SCHEMA:
                    type_fn = CONFIG_SCHEMA[key].get("type", str)
                    try:
                        value = type_fn(value)
                    except (ValueError, TypeError):
                        pass
                updates[key] = value
        if updates:
            updated = update_config(updates, __file__)
            print(f"✅ Config updated: {updates}")
            print(f"   Saved to: {get_config_path(__file__)}")
            # Reload config
            _config = load_config(CONFIG_SCHEMA, __file__, slug="polymarket-weather-trader")
            # Update module-level vars
            globals()["ENTRY_THRESHOLD"] = _config["entry_threshold"]
            globals()["EXIT_THRESHOLD"] = _config["exit_threshold"]
            globals()["MAX_POSITION_USD"] = _config["max_position_usd"]
            globals()["SMART_SIZING_PCT"] = _config["sizing_pct"]
            globals()["MAX_TRADES_PER_RUN"] = _config["max_trades_per_run"]
            globals()["BINARY_ONLY"] = _config["binary_only"]
            globals()["VOL_TARGETING"] = _config["vol_targeting"]
            globals()["TARGET_VOL"] = _config["target_vol"]
            globals()["VOL_MAX_LEVERAGE"] = _config["vol_max_leverage"]
            globals()["VOL_MIN_ALLOCATION"] = _config["vol_min_allocation"]
            globals()["VOL_SPAN"] = _config["vol_span"]
            _locations_str = _config["locations"]
            globals()["ACTIVE_LOCATIONS"] = [loc.strip().upper() for loc in _locations_str.split(",") if loc.strip()]

    # Default to dry-run unless --live is explicitly passed
    dry_run = not args.live

    run_weather_strategy(
        dry_run=dry_run,
        positions_only=args.positions,
        show_config=args.config,
        smart_sizing=args.smart_sizing,
        use_safeguards=not args.no_safeguards,
        use_trends=not args.no_trends,
        quiet=args.quiet,
        vol_targeting=args.vol_targeting or VOL_TARGETING,
    )

    # Fallback report for automaton if the strategy returned early (no signal)
    if os.environ.get("AUTOMATON_MANAGED") and not _automaton_reported:
        print(json.dumps({"automaton": {"signals": 0, "trades_attempted": 0, "trades_executed": 0, "skip_reason": "no_signal"}}))


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