Skill: yhlorra/yh-polymarket-weather-trader (version 1.16.0)

--- name: polymarket-weather-trader description: >- Weather temperature prediction market trading on Polymarket via Simmer SDK. Uses NOAA (US) and Open-Meteo ensemble (international) forecasts as signal, EV/Kelly framework for sizing, Bayesian probability updates for open positions, and dynamic Maker/Taker strategy switching. Use when the user wants to: scan weather markets, execute temperature arbitrage trades, configure locations/thresholds, or view weather trading performance. Trigger words: weather market, temperature trade, NOAA forecast, simmer weather. --- # Polymarket Weather Trader ## When to Use Use this skill when the user wants to: - Scan active weather markets on Polymarket - Execute temperature forecast arbitrage trades (US via NOAA, international via Open-Meteo) - View or configure weather trading parameters - Check open positions or trading performance - Switch between Simmer virtual ($SIM) and Polymarket real (USDC) trading ## Architecture ``` weather-trader/ ├── weather_trader.py # Main entry point (2732 lines) ├── ev_calculator.py # EV + Kelly Criterion + longtail bias ├── bayesian_update.py # Position probability management ├── maker_taker_arbiter.py # Dynamic Maker/Taker mode switching ├── trade_performance.py # Logging, circuit breaker, health score ├── performance_report.py # Performance reporting CLI ├── smart_money_signal.py # Whale tracking signal ├── config.json # Trading parameters ├── test_ev_calculator.py # Unit tests ├── test_noaa_calibration.py └── trades.jsonl # Append-only trade log ``` ## Quick Start ```bash cd weather-trader python weather_trader.py # Dry run python weather_trader.py --live # Real trades (venue from .env) python weather_trader.py --positions # Show open positions python weather_trader.py --config # Show current config python weather_trader.py --set entry_threshold=0.20 python weather_trader.py --live --quiet # Silent for cron python weather_trader.py --smart-sizing # Portfolio-based sizing python weather_trader.py --no-safeguards # Disable safeguards (not recommended) python weather_trader.py --resume # Clear circuit breaker ``` ## Venue and Live Mode | Mode | Flag | Effect | |------|------|--------| | Dry run | (default) | No API calls, just analysis | | Live | `--live` | Real API calls to `TRADING_VENUE` | | Simmer $SIM | `.env: TRADING_VENUE=sim` | Virtual trading, $10k starting balance | | Polymarket USDC | `.env: TRADING_VENUE=polymarket` | Real USDC | ## Supported Locations **US cities (NOAA + MADIS, 16 cities):** NYC, Chicago, Seattle, Atlanta, Dallas, Miami, Houston, Phoenix, Philadelphia, Boston, Denver, Las Vegas, San Francisco, Los Angeles, San Diego, Washington DC, Nashville **International cities (Open-Meteo ensemble, 20+ cities):** Tel Aviv, Munich, London, Tokyo, Seoul, Ankara, Lucknow, Wellington, Taipei, Shanghai, Beijing, Hong Kong, Singapore, Sydney, Melbourne, Paris, Berlin, Madrid, Rome, Amsterdam, Toronto, Vancouver, Shenzhen, Guangzhou, Chengdu, Mumbai, Delhi, Wuhan Use `config.json` or `--set locations=NYC,London,Tokyo` to configure. ## Strategy Logic ``` Signal = NOAA/Open-Meteo forecast probability vs Polymarket market price EV = p × (1-price)/price - (1-p) × 1 Trade = take if EV > 0 and price < entry_threshold Size = Kelly Criterion (Quarter Kelly = kelly_multiplier × full Kelly) ``` 1. Fetch active weather markets from Simmer/Polymarket API 2. Parse market name → location + temperature bucket 3. Fetch weather forecast (NOAA for US, Open-Meteo ensemble for international) 4. Compute forecast probability → derive YES price estimate 5. Check EV gate, safeguards, slippage, time decay 6. Execute with Kelly sizing; apply longtail EV penalty for <20¢ contracts 7. Track open positions; apply Bayesian update if market moves against position 8. Exit when price > exit_threshold or position P&L drops below threshold ## Core Modules ### `ev_calculator.py` — EV + Kelly Framework ``` EV = (p × (1-price)/price) - ((1-p) × 1) Quarter Kelly = full_Kelly × kelly_multiplier ``` - `calculate_ev(price, win_prob)` → EV per dollar - `should_take_trade(price, win_prob, min_ev=0.0)` → bool gate - `kelly_fraction(price, win_prob)` → full Kelly (0–1) - `quarter_kelly_position(price, win_prob, bankroll, max_pos)` → USD amount - `is_longtail_contract(price, cutoff=0.20)` → flag for <20¢ contracts - `longtail_ev_adjustment(price, raw_ev)` → applies 20% penalty to longtail EV ### `bayesian_update.py` — Position Probability Management - `bayesian_update(prior_prob, likelihood_ratio)` → posterior probability - `compute_likelihood_ratio(new_price, old_price)` → odds ratio - `should_update_probability(position_pnl_pct, threshold=-0.10)` → triggers on -10% PnL - `should_close_position(updated_prob, initial_prob, threshold=-0.15)` → close if prob dropped >15% - `compute_new_kelly_size(initial_prob, updated_prob, initial_kelly_size)` → rescales position ### `maker_taker_arbiter.py` — Strategy Mode Switching | Condition | Mode | Maker / Taker | |-----------|------|---------------| | spread < 50bps AND vol < 50% | MAKER_HEAVY | 65% / 35% | | spread < 50bps XOR vol < 50% | BALANCED | 50% / 50% | | spread ≥ 50bps AND vol ≥ 50% | TAKER_HEAVY | 30% / 70% | ## Configuration (`config.json`) | Parameter | Default | Description | |-----------|---------|-------------| | `entry_threshold` | 0.15 | Buy when price below this | | `exit_threshold` | 0.45 | Sell when price above this | | `max_position_usd` | 2.00 | Max USD per trade | | `sizing_pct` | 0.05 | Smart sizing: % of balance per trade | | `max_trades_per_run` | 5 | Max trades per scan cycle | | `locations` | NYC | Comma-separated cities | | `binary_only` | false | Skip range-bucket events | | `slippage_max` | 0.15 | Skip if slippage exceeds this | | `min_liquidity` | 0.0 | Skip markets below this liquidity | | `use_kelly_sizing` | true | Use Kelly Criterion vs fixed % | | `kelly_multiplier` | 0.25 | Kelly fraction (0.25 = Quarter Kelly) | | `ev_min_threshold` | 0.0 | Min EV to take trade | | `longtail_ev_penalty` | 0.20 | EV penalty for <20¢ contracts | | `noaa_win_probability` | 0.85 | Base NOAA forecast win rate | | `circuit_breaker_threshold` | 3 | Losses before pause | | `circuit_breaker_cooldown` | 6 | Hours before auto-resume | | `use_smart_money` | true | Enable whale signal integration | | `smart_money_min_score` | 7 | Min whale score (0-10) | ## Performance & Monitoring ```bash python trade_performance.py --health-score # 0-100 health score python trade_performance.py --auto-tune # Suggest param adjustments python trade_performance.py --changelog # View param change history python trade_performance.py --resume # Force clear circuit breaker ``` - **Structured logging:** Every trade → `trades.jsonl` (JSONL, append-only) - **Circuit breaker:** 3 consecutive real losses → 6-hour pause - **Health scoring:** 4-component weighted score (0-100) with half-life decay - **Changelog:** Immutable audit trail of parameter changes ## Risk Controls - `TRADING_VENUE=sim` (virtual $SIM) or `polymarket` (real USDC) - `--live` flag required for real API calls - Circuit breaker auto-pause - Max position per trade - Slippage checks (skip if >15%) - Time decay guard (skip if <2h to resolution) - Min liquidity filter ## Changelog ### v2.1 (2026-04-08) — MADIS Bias Correction + Trade Log - **MADIS airport-observation bias correction**: Compare MADIS current observation (airport实测) vs forecast temperature. If forecast is systematically warm or cold, adjust probability accordingly (max ±6% shift). This addresses the core issue identified in @runes_leo's weather trading experience: forecasts can have city-level directional biases. - **Trade log for city-level bias learning**: Every executed trade now writes to `~/.hermes/weather-trade-log.jsonl` with: city, target_date, forecast_temp, bucket, price, noaa_probability (pre-correction), bias_adjustment, MADIS bias message, and signal data. Over time this builds a per-city forecast bias statistics database. ## Known Constraints ### Polymarket MIN_SHARES_PER_ORDER = 5 At $0.10/bucket → $0.50 minimum (OK) At $0.20/bucket → $1.00 minimum (OK) At $0.30/bucket → $1.50 minimum (blocks $1 trades) At $0.50/bucket → $2.50 minimum (blocks $1 trades) For small budgets ($1/position): only buckets ≤ $0.20 are tradeable. ## Troubleshooting | Issue | Fix | |-------|-----| | "No live tradeable markets" | Markets off-hours or seasonal; check Polymarket directly | | "CLOB price unavailable" | Polymarket CLOB down; script uses Simmer API fallback | | "Circuit breaker TRIPPED" | Wait 6h or run `--resume` | | "Balance shows $0" | Polymarket uses USDC.e (bridged), not native USDC | | "Position too small for 5 shares" | Bucket price too high; lower budget or wait for cheaper buckets | | NOAA SSL timeout | Normal; retry logic falls back to Open-Meteo | | Discovery 429 rate limit | Expected during scan; trading continues from cached markets |

path: _meta.json
content: ownerId: kn7axnp7bzqsf5fkx0z8px7han7zyq1x
slug: polymarket-weather-trader
version: 1.15.1
publishedAt: 1773740474137

path: bayesian_update.py
content: """
Bayesian probability update module for position management.

Provides functions to update probability estimates using Bayes' theorem
when positions experience significant PnL drops.
"""

from typing import Tuple


def bayesian_update(prior_prob: float, likelihood_ratio: float) -> float:
    """
    Update probability using Bayes' theorem.

    new_p = (LR × prior) / (LR × prior + (1 - prior))

    Args:
        prior_prob: Initial probability estimate (0 to 1)
        likelihood_ratio: Likelihood ratio from price movement

    Returns:
        Posterior probability after applying likelihood ratio
    """
    numerator = likelihood_ratio * prior_prob
    denominator = (likelihood_ratio * prior_prob) + (1 - prior_prob)
    if denominator == 0:
        return prior_prob
    return numerator / denominator


def compute_likelihood_ratio(new_price: float, old_price: float) -> float:
    """
    Convert price movement into likelihood ratio using odds ratio.

    Returns odds_new / odds_old where odds = p / (1 - p)

    Args:
        new_price: New probability/price estimate (0 to 1)
        old_price: Old probability/price estimate (0 to 1)

    Returns:
        Likelihood ratio (1.0 means no change)
    """
    if old_price <= 0 or old_price >= 1:
        return 1.0
    odds_new = new_price / (1 - new_price)
    odds_old = old_price / (1 - old_price)
    return odds_new / odds_old


def should_update_probability(
    position_pnl_pct: float, threshold: float = -0.10
) -> bool:
    """
    Trigger Bayesian update when PnL drops below threshold.

    Args:
        position_pnl_pct: Position PnL as decimal (e.g., -0.15 for -15%)
        threshold: PnL threshold to trigger update (default: -10%)

    Returns:
        True if PnL drop exceeds threshold
    """
    return position_pnl_pct < threshold


def should_close_position(
    updated_prob: float, initial_prob: float, threshold: float = -0.15
) -> bool:
    """
    Close if updated probability dropped more than threshold from initial.

    Args:
        updated_prob: Updated probability after Bayesian update
        initial_prob: Initial probability before update
        threshold: Maximum allowed drop (negative, e.g., -0.15 for 15%)

    Returns:
        True if probability drop exceeds threshold
    """
    # threshold is negative (e.g., -0.15), so initial_prob + threshold < initial_prob
    return updated_prob < initial_prob + threshold


def compute_new_kelly_size(
    initial_prob: float, updated_prob: float, initial_kelly_size: float
) -> float:
    """
    Compute new Kelly size based on updated probability.

    Args:
        initial_prob: Initial probability estimate
        updated_prob: Updated probability after Bayesian analysis
        initial_kelly_size: Original Kelly position size

    Returns:
        New position size scaled by probability ratio
    """
    if initial_prob <= 0 or initial_prob >= 1:
        return initial_kelly_size
    if updated_prob <= 0 or updated_prob >= 1:
        return 0.0
    # Scale Kelly by the ratio of updated to initial probability
    return initial_kelly_size * (updated_prob / initial_prob)


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": 10.0,
  "longtail_ev_penalty": 0.2,
  "longtail_price_cutoff": 0.2,
  "use_kelly_sizing": true,
  "kelly_multiplier": 0.25,
  "noaa_win_probability": 0.85,
  "bayesian_trigger_pnl_pct": -0.1,
  "bayesian_close_threshold_pct": -0.15,
  "entry_threshold": 0.50,
  "exit_threshold": 0.45,
  "ev_min_threshold": 0.05,
  "locations": "NYC,Chicago,Tokyo",
  "max_trades_per_run": 5,
  "sizing_pct": 0.10,
  "binary_only": false,
  "slippage_max": 0.25,
  "min_liquidity": 0.0,
  "circuit_breaker_threshold": 5,
  "circuit_breaker_cooldown_hours": 6,
  "use_smart_money": true,
  "smart_money_min_score": 7
}


path: ev_calculator.py
content: #!/usr/bin/env python3
"""
Expected Value (EV) Calculator + Kelly Criterion + Longtail Filter

Based on @Movez's Game Theory on Polymarket analysis:
- Taker loses -1.12%/trade without EV calculation
- <20¢ contracts have 16-57% systematic premium (longtail bias)
- Kelly Criterion for mathematically optimal position sizing
"""

from typing import Literal


def calculate_ev(price: float, win_prob: float) -> float:
    """
    Calculate Expected Value per dollar invested.

    EV = p × (1-price)/price - (1-p) × 1
       = p × b - q
    where:
        b = net odds = (1 - price) / price
        p = win probability (our estimate)
        q = 1 - p

    Args:
        price: Current market price (0.0 to 1.0, represents probability)
        win_prob: Our estimated win probability (0.0 to 1.0)

    Returns:
        Expected value per dollar invested. EV > 0 means favorable trade.
        EV = 0 means fair odds, EV < 0 means unfavorable.

    Raises:
        ZeroDivisionError: If price is 0.0 or 1.0
    """
    if price <= 0.0 or price >= 1.0:
        raise ZeroDivisionError("Price must be between 0.0 and 1.0")

    b = (1 - price) / price  # net odds
    p = win_prob
    q = 1 - p
    ev = p * b - q
    return ev


def should_take_trade(price: float, win_prob: float, min_ev: float = 0.0) -> bool:
    """
    Determine if a trade meets the EV threshold.

    Args:
        price: Current market price
        win_prob: Our estimated win probability
        min_ev: Minimum EV threshold to accept trade (default 0.0)

    Returns:
        True if EV > min_ev, False otherwise
    """
    try:
        ev = calculate_ev(price, win_prob)
    except ZeroDivisionError:
        return False
    return ev > min_ev


def kelly_fraction(price: float, win_prob: float) -> float:
    """
    Calculate full Kelly Criterion fraction.

    f* = (b × p - q) / b
    where:
        b = net odds = (1 - price) / price
        p = win probability
        q = 1 - p

    Args:
        price: Current market price
        win_prob: Our estimated win probability

    Returns:
        Full Kelly fraction (0.0 to 1.0). Use quarter Kelly for safety.
    """
    if price <= 0.0 or price >= 1.0:
        return 0.0

    b = (1 - price) / price
    p = win_prob
    q = 1 - p

    if b <= 0:
        return 0.0

    f_star = (b * p - q) / b
    return max(0.0, min(1.0, f_star))  # cap at 100%


def quarter_kelly_position(
    price: float, win_prob: float, bankroll: float, max_pos: float
) -> float:
    """
    Calculate position size using Quarter Kelly Criterion.

    Quarter Kelly = (full Kelly) / 4
    This provides ~2x the growth of fixed sizing while being ~75% less risky
    than full Kelly.

    Args:
        price: Current market price
        win_prob: Our estimated win probability
        bankroll: Total capital available for trading
        max_pos: Maximum position size allowed (from config)

    Returns:
        Recommended position size in USD, capped at max_pos
    """
    kelly = kelly_fraction(price, win_prob)
    if kelly <= 0.0:
        return 0.0

    quarter_kelly = kelly / 4
    position = bankroll * quarter_kelly
    return min(position, max_pos)


def is_longtail_contract(
    price: float, strategy_type: Literal["taker", "maker"] = "taker"
) -> bool:
    """
    Detect longtail contracts with systematic premium.

    Per @Movez analysis: <20¢ contracts have 16-57% systematic premium.
    These are overpriced on average and should be avoided or treated with
    higher EV requirements.

    Args:
        price: Current market price
        strategy_type: 'taker' or 'maker' (maker strategies are less affected)

    Returns:
        True if this is a longtail contract requiring special handling
    """
    return price < 0.20 and strategy_type == "taker"


def longtail_ev_adjustment(price: float, raw_ev: float) -> float:
    """
    Apply longtail bias penalty to EV calculation.

    Per @Movez analysis: <20¢ contracts have 16-57% systematic premium.
    We use a conservative 20% penalty.

    Args:
        price: Current market price
        raw_ev: Raw EV calculated without longtail adjustment

    Returns:
        Adjusted EV with longtail penalty applied
    """
    penalty = 0.20
    return raw_ev - penalty


def derive_win_prob(
    momentum_pct: float, divergence: float, base_prob: float = 0.50
) -> float:
    """
    Derive win probability from momentum strength and divergence from 50¢.

    Used by fastloop trader where win_prob is not fixed but derived from:
    - Momentum strength (stronger momentum = higher probability)
    - Divergence from 50¢ (more mispricing = more opportunity)

    Args:
        momentum_pct: Absolute momentum percentage (e.g., 0.8 for 0.8%)
        divergence: How far price is from 50¢ (absolute, e.g., 0.05 for 5¢)
        base_prob: Base probability (default 50¢ market = 0.50)

    Returns:
        Estimated win probability between 0.01 and 0.99
    """
    momentum_bonus = min(0.25, momentum_pct / 100)
    divergence_bonus = min(0.20, divergence)
    win_prob = base_prob + momentum_bonus + divergence_bonus
    return max(0.01, min(0.99, win_prob))


path: maker_taker_arbiter.py
content: """
Maker/Taker Strategy Arbiter Module.

Dynamically switches between Maker (passive limit orders) and Taker (aggressive market orders)
based on market conditions.

Strategy decision rules:
- spread < 50bps AND vol < 50% annualized → MAKER_HEAVY (65% Maker / 35% Taker)
- spread < 50bps XOR vol < 50% → BALANCED (50% Maker / 50% Taker)
- spread >= 50bps AND vol >= 50% annualized → TAKER_HEAVY (30% Maker / 70% Taker)

On strategy switch: existing positions are NOT closed, only new orders follow current mode.
"""

from enum import Enum
from dataclasses import dataclass


class StrategyMode(Enum):
    """Maker/Taker strategy modes with allocation ratios."""
    MAKER_HEAVY = "maker_heavy"   # 65% Maker / 35% Taker
    BALANCED = "balanced"          # 50% Maker / 50% Taker
    TAKER_HEAVY = "taker_heavy"    # 30% Maker / 70% Taker


@dataclass
class MarketCondition:
    """
    Market condition metrics for strategy determination.
    
    Attributes:
        spread_bps: Bid-ask spread in basis points (e.g., 30.0 = 30 bps = 0.30%)
        volatility: Annualized volatility as decimal (e.g., 0.40 = 40%)
        volume_24h: 24-hour trading volume (optional, for future use)
    """
    spread_bps: float      # Bid-ask spread in basis points
    volatility: float      # Annualized volatility (0.0 to 1.0+)
    volume_24h: float = 0.0  # Optional, for future use


def determine_strategy_mode(condition: MarketCondition) -> StrategyMode:
    """
    Determine strategy mode based on market conditions.
    
    Decision rules:
    - spread < 50bps AND volatility < 50% → MAKER_HEAVY
    - spread < 50bps XOR volatility < 50% → BALANCED  
    - spread >= 50bps AND volatility >= 50% → TAKER_HEAVY
    
    Args:
        condition: MarketCondition with spread_bps and volatility
        
    Returns:
        StrategyMode enum indicating current strategy
    """
    low_spread = condition.spread_bps < 50
    low_vol = condition.volatility < 0.50
    
    if low_spread and low_vol:
        return StrategyMode.MAKER_HEAVY
    elif low_spread != low_vol:  # XOR - one but not both
        return StrategyMode.BALANCED
    else:
        return StrategyMode.TAKER_HEAVY


def get_maker_taker_allocation(mode: StrategyMode) -> tuple[float, float]:
    """
    Get maker/taker allocation percentages for given strategy mode.
    
    Args:
        mode: StrategyMode enum
        
    Returns:
        Tuple of (maker_pct, taker_pct) as floats summing to 1.0
    """
    if mode == StrategyMode.MAKER_HEAVY:
        return (0.65, 0.35)
    elif mode == StrategyMode.BALANCED:
        return (0.50, 0.50)
    else:
        return (0.30, 0.70)


path: performance_report.py
content: #!/usr/bin/env python3
"""Performance report for Simmer trading skill.

Usage:
    python performance_report.py                     # Today's report for this skill
    python performance_report.py --date 2026-03-28   # Specific date
    python performance_report.py --skill all          # All skills in trades.jsonl
    python performance_report.py --json               # JSON output
    python performance_report.py --last 7             # Last 7 days
    python performance_report.py --snapshot            # Write snapshot to performance_snapshot.json
    python performance_report.py --quiet              # Suppress table output
"""

import json
import sys
import argparse
import statistics
from pathlib import Path
from datetime import datetime, timezone, timedelta

SKILL_SLUG = "polymarket-weather-trader"
TRADE_SOURCE = "sdk:weather"

# Resolve trade_performance module path (same directory as this script)
sys.path.insert(0, str(Path(__file__).parent))
from trade_performance import aggregate_trades, compute_health_score, read_changelog


def get_trades_path(skill_slug: str = None) -> Path:
    """Get trades.jsonl path. For 'all' or specific skill, look in each skill dir."""
    return Path(__file__).parent / "trades.jsonl"


def compute_edge_distribution(
    trades_path: str, skill: str = None, date: str = None
) -> dict:
    """Compute min, max, median of edge values from signal data."""
    path = Path(trades_path)
    if not path.exists():
        return {"min": 0.0, "max": 0.0, "median": 0.0}

    edges = []
    with open(path, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
            except json.JSONDecodeError:
                continue

            if skill and entry.get("skill") != skill:
                continue
            if date and entry.get("timestamp", "")[:10] != date:
                continue

            edge = entry.get("signal", {}).get("edge")
            if edge is not None:
                edges.append(edge)

    if not edges:
        return {"min": 0.0, "max": 0.0, "median": 0.0}

    return {
        "min": round(min(edges), 4),
        "max": round(max(edges), 4),
        "median": round(statistics.median(edges), 4),
    }


def format_table(metrics: dict, skill: str, date_label: str) -> str:
    """Format metrics as a human-readable table."""
    lines = [
        "=" * 40,
        f"  Performance: {skill}",
        "=" * 40,
        f"  Date:         {date_label}",
        f"  Total trades: {metrics['total_trades']}",
        f"  Successes:    {metrics['successes']} ({metrics['order_success_rate']:.1f}%)",
        f"  Failures:     {metrics['failures']}",
        f"  Live trades:  {metrics['live_trades']}",
        f"  Paper trades: {metrics['paper_trades']}",
        f"  Invested:     ${metrics['total_invested_usd']:.2f}",
        f"  Avg edge:     {metrics['avg_edge']:.2f}%",
    ]

    if "edge_distribution" in metrics:
        ed = metrics["edge_distribution"]
        lines.append(
            f"  Edge range:   {ed['min']:.2f}% - {ed['max']:.2f}% (median {ed['median']:.2f}%)"
        )

    if "trades_today" in metrics:
        lines.append(f"  Today:        {metrics['trades_today']} trades")

    lines.append("=" * 40)
    return "\n".join(lines)


def build_snapshot(skill: str, date_str: str, metrics: dict) -> dict:
    """Build snapshot object for performance_snapshot.json."""
    return {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "skill": skill,
        "date": date_str,
        "metrics": metrics,
    }


def write_snapshot(snapshot: dict, trades_dir: Path) -> None:
    """Write snapshot to performance_snapshot.json."""
    snapshot_path = trades_dir / "performance_snapshot.json"
    with open(snapshot_path, "w") as f:
        json.dump(snapshot, f, indent=2)


def collect_all_skills(trades_path: Path) -> list:
    """Find all unique skill slugs in trades.jsonl."""
    skills = set()
    if not trades_path.exists():
        return []
    with open(trades_path, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
                if "skill" in entry:
                    skills.add(entry["skill"])
            except json.JSONDecodeError:
                continue
    return sorted(skills)


def main():
    parser = argparse.ArgumentParser(
        description="Performance report for Simmer trading skills",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument(
        "--date",
        metavar="YYYY-MM-DD",
        help="Filter by specific date",
    )
    parser.add_argument(
        "--skill",
        default=SKILL_SLUG,
        help=f"Skill slug to report on (default: {SKILL_SLUG}). Use 'all' for all skills.",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        dest="json_output",
        help="Output raw JSON instead of table",
    )
    parser.add_argument(
        "--last",
        type=int,
        metavar="N",
        help="Show report for last N days",
    )
    parser.add_argument(
        "--snapshot",
        action="store_true",
        help="Write performance_snapshot.json after report",
    )
    parser.add_argument(
        "--quiet",
        action="store_true",
        help="Suppress table output (useful with --snapshot)",
    )
    parser.add_argument(
        "--health",
        action="store_true",
        help="Show health score with component breakdown",
    )

    args = parser.parse_args()

    # Handle --health flag
    if args.health:
        trades_path = Path(__file__).parent / "trades.jsonl"
        if not trades_path.exists():
            print("No trades recorded yet")
            sys.exit(0)

        skills_to_check = []
        if args.skill == "all":
            skills_to_check = collect_all_skills(trades_path)
        else:
            skills_to_check = [args.skill or SKILL_SLUG]

        for skill_slug in skills_to_check:
            result = compute_health_score(str(trades_path), skill_slug)
            if args.json_output:
                print(json.dumps(result, indent=2))
            else:
                score = result.get("health_score", 0)
                stars = "\u2605" * int(score / 20) + "\u2606" * (5 - int(score / 20))
                print(f"=== Health: {skill_slug} ===")
                print(f"  Score:  {score}/100 [{stars}]")
                comps = result.get("components", {})
                print(f"  Win Rate:    {comps.get('win_rate', 0):.0f}/100")
                print(f"  PnL Score:   {comps.get('pnl_score', 0):.0f}/100")
                print(f"  Consistency: {comps.get('consistency', 0):.0f}/100")
                print(f"  Activity:    {comps.get('activity', 0):.0f}/100")
                print(f"  Trades analyzed: {result.get('trades_analyzed', 0)}")
                if "reason" in result:
                    print(f"  Note: {result['reason']}")
                print()

        sys.exit(0)

    trades_dir = Path(__file__).parent
    trades_path = trades_dir / "trades.jsonl"

    # Handle missing/empty trades.jsonl
    if not trades_path.exists() or trades_path.stat().st_size == 0:
        print("No trades recorded yet")
        sys.exit(0)

    today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    date_str = args.date or today_str

    # Multi-day report
    if args.last:
        for day_offset in range(args.last - 1, -1, -1):
            d = (datetime.now(timezone.utc) - timedelta(days=day_offset)).strftime(
                "%Y-%m-%d"
            )
            metrics = aggregate_trades(
                str(trades_path),
                skill=args.skill if args.skill != "all" else None,
                date=d,
            )
            edge_dist = compute_edge_distribution(
                str(trades_path),
                skill=args.skill if args.skill != "all" else None,
                date=d,
            )
            metrics["edge_distribution"] = edge_dist

            if args.json_output:
                print(json.dumps({"date": d, **metrics}))
            else:
                print(format_table(metrics, args.skill, d))
        return

    # Single skill or all skills
    if args.skill == "all":
        skills = collect_all_skills(trades_path)
        if not skills:
            print("No trades recorded yet")
            sys.exit(0)

        for skill_slug in skills:
            metrics = aggregate_trades(
                str(trades_path), skill=skill_slug, date=args.date
            )
            edge_dist = compute_edge_distribution(
                str(trades_path), skill=skill_slug, date=args.date
            )
            metrics["edge_distribution"] = edge_dist

            if args.json_output:
                print(json.dumps({"skill": skill_slug, **metrics}))
            else:
                print(format_table(metrics, skill_slug, date_str))
                print()

        return

    # Single skill report
    metrics = aggregate_trades(str(trades_path), skill=args.skill, date=args.date)
    edge_dist = compute_edge_distribution(
        str(trades_path), skill=args.skill, date=args.date
    )
    metrics["edge_distribution"] = edge_dist

    # Count trades today for this skill
    today_metrics = aggregate_trades(str(trades_path), skill=args.skill, date=today_str)
    metrics["trades_today"] = today_metrics["total_trades"]

    if args.json_output:
        print(json.dumps(metrics, indent=2))
    else:
        print(format_table(metrics, args.skill, date_str))

    # Snapshot
    if args.snapshot or not args.quiet:
        snapshot = build_snapshot(args.skill, date_str, metrics)
        write_snapshot(snapshot, trades_dir)
        if not args.quiet:
            print(f"Snapshot written to {trades_dir / 'performance_snapshot.json'}")


if __name__ == "__main__":
    main()


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: test_autotuning.py
content: #!/usr/bin/env python3
"""
Tests for autotuning functions: compute_health_score, suggest_tuning,
log_param_change, read_changelog.

Uses stdlib unittest only — no external dependencies.
"""

import json
import os
import tempfile
import unittest
from datetime import datetime, timezone, timedelta
from pathlib import Path

# Import functions from trade_performance
import sys

sys.path.insert(0, str(Path(__file__).parent))
from trade_performance import (
    compute_health_score,
    suggest_tuning,
    log_param_change,
    read_changelog,
)


def _make_trades(tmp_path, entries):
    """Helper: write trade entries to a temp trades.jsonl file."""
    p = Path(tmp_path) / "trades.jsonl"
    with open(p, "w") as f:
        for e in entries:
            f.write(json.dumps(e, default=str) + "\n")
    return str(p)


class TestComputeHealthScore(unittest.TestCase):
    """Tests for compute_health_score()"""

    def test_insufficient_data_returns_50(self):
        """With < 3 trades, should return health_score=50 and reason."""
        with tempfile.TemporaryDirectory() as tmp:
            trades = [
                {
                    "timestamp": datetime.now(timezone.utc).isoformat(),
                    "skill": "test-skill",
                    "success": True,
                    "simulated": False,
                    "cost": 1.0,
                },
                {
                    "timestamp": datetime.now(timezone.utc).isoformat(),
                    "skill": "test-skill",
                    "success": False,
                    "simulated": False,
                    "cost": 1.0,
                },
            ]
            path = _make_trades(tmp, trades)
            result = compute_health_score(path, skill="test-skill")
            self.assertEqual(result["health_score"], 50)
            self.assertIn("insufficient", result.get("reason", "").lower())

    def test_empty_file_returns_50(self):
        """With no trades, should return health_score=50."""
        with tempfile.TemporaryDirectory() as tmp:
            path = _make_trades(tmp, [])
            result = compute_health_score(path, skill="test-skill")
            self.assertEqual(result["health_score"], 50)

    def test_score_range_0_100(self):
        """With sufficient data, score should be between 0 and 100."""
        with tempfile.TemporaryDirectory() as tmp:
            now = datetime.now(timezone.utc)
            trades = []
            for i in range(10):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i)).isoformat(),
                        "skill": "test-skill",
                        "success": i % 2 == 0,  # 50% win rate
                        "simulated": False,
                        "cost": 1.0,
                        "signal": {"edge": 0.03},
                    }
                )
            path = _make_trades(tmp, trades)
            result = compute_health_score(path, skill="test-skill")
            self.assertGreaterEqual(result["health_score"], 0)
            self.assertLessEqual(result["health_score"], 100)

    def test_has_four_components(self):
        """Result should have win_rate, pnl_score, consistency, activity components."""
        with tempfile.TemporaryDirectory() as tmp:
            now = datetime.now(timezone.utc)
            trades = []
            for i in range(10):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i)).isoformat(),
                        "skill": "test-skill",
                        "success": True,
                        "simulated": False,
                        "cost": 2.0,
                    }
                )
            path = _make_trades(tmp, trades)
            result = compute_health_score(path, skill="test-skill")
            components = result.get("components", {})
            self.assertIn("win_rate", components)
            self.assertIn("pnl_score", components)
            self.assertIn("consistency", components)
            self.assertIn("activity", components)

    def test_filters_by_skill(self):
        """Should only count trades matching the skill parameter."""
        with tempfile.TemporaryDirectory() as tmp:
            now = datetime.now(timezone.utc)
            trades = []
            # 10 trades for "other-skill" (all success)
            for i in range(10):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i)).isoformat(),
                        "skill": "other-skill",
                        "success": True,
                        "simulated": False,
                        "cost": 1.0,
                    }
                )
            # 3 trades for "test-skill" (all failure)
            for i in range(3):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i)).isoformat(),
                        "skill": "test-skill",
                        "success": False,
                        "simulated": False,
                        "cost": 1.0,
                    }
                )
            path = _make_trades(tmp, trades)
            result = compute_health_score(path, skill="test-skill")
            self.assertEqual(result["trades_analyzed"], 3)

    def test_excludes_simulated(self):
        """Should exclude simulated (paper) trades."""
        with tempfile.TemporaryDirectory() as tmp:
            now = datetime.now(timezone.utc)
            trades = []
            for i in range(5):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i)).isoformat(),
                        "skill": "test-skill",
                        "success": True,
                        "simulated": True,  # paper trades
                        "cost": 1.0,
                    }
                )
            for i in range(3):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i)).isoformat(),
                        "skill": "test-skill",
                        "success": False,
                        "simulated": False,
                        "cost": 1.0,
                    }
                )
            path = _make_trades(tmp, trades)
            result = compute_health_score(path, skill="test-skill")
            self.assertEqual(result["trades_analyzed"], 3)


class TestSuggestTuning(unittest.TestCase):
    """Tests for suggest_tuning()"""

    def test_cold_start_under_20(self):
        """With < 20 trades, should return cold start message."""
        with tempfile.TemporaryDirectory() as tmp:
            now = datetime.now(timezone.utc)
            trades = []
            for i in range(15):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i)).isoformat(),
                        "skill": "test-skill",
                        "success": True,
                        "simulated": False,
                        "cost": 1.0,
                    }
                )
            path = _make_trades(tmp, trades)
            result = suggest_tuning(path, skill="test-skill")
            self.assertIsNone(result["parameter"])
            self.assertIn("insufficient", result.get("reason", "").lower())

    def test_empty_returns_insufficient(self):
        """With 0 trades, should return insufficient."""
        with tempfile.TemporaryDirectory() as tmp:
            path = _make_trades(tmp, [])
            result = suggest_tuning(path, skill="test-skill")
            self.assertIsNone(result["parameter"])

    def test_low_winrate_suggests_threshold(self):
        """Win rate < 40% should suggest increasing entry_threshold."""
        with tempfile.TemporaryDirectory() as tmp:
            now = datetime.now(timezone.utc)
            trades = []
            for i in range(25):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i % 7)).isoformat(),
                        "skill": "test-skill",
                        "success": i < 8,  # ~32% win rate
                        "simulated": False,
                        "cost": 1.0,
                    }
                )
            path = _make_trades(tmp, trades)
            result = suggest_tuning(path, skill="test-skill")
            self.assertIsNotNone(result["parameter"])
            self.assertIn("threshold", result["parameter"].lower())
            self.assertGreater(result["new_value"], result["old_value"])

    def test_result_has_required_fields(self):
        """Result should have parameter, old_value, new_value, reason, confidence."""
        with tempfile.TemporaryDirectory() as tmp:
            now = datetime.now(timezone.utc)
            trades = []
            for i in range(25):
                trades.append(
                    {
                        "timestamp": (now - timedelta(days=i % 7)).isoformat(),
                        "skill": "test-skill",
                        "success": i < 10,
                        "simulated": False,
                        "cost": 1.0,
                    }
                )
            path = _make_trades(tmp, trades)
            result = suggest_tuning(path, skill="test-skill")
            self.assertIn("parameter", result)
            self.assertIn("old_value", result)
            self.assertIn("new_value", result)
            self.assertIn("reason", result)
            self.assertIn("confidence", result)


class TestLogParamChange(unittest.TestCase):
    """Tests for log_param_change() and read_changelog()"""

    def test_creates_changelog_file(self):
        """First call should create changelog.jsonl."""
        with tempfile.TemporaryDirectory() as tmp:
            # Patch __file__ location by using a module in that dir
            entry = {
                "parameter": "entry_threshold",
                "old_value": 0.05,
                "new_value": 0.06,
                "status": "pending",
            }
            # Call with a temp path workaround
            changelog_path = Path(tmp) / "changelog.jsonl"
            self.assertFalse(changelog_path.exists())

            # Directly test the append logic
            if "timestamp" not in entry:
                entry["timestamp"] = datetime.now(timezone.utc).isoformat()
            with open(changelog_path, "a") as f:
                f.write(json.dumps(entry, default=str) + "\n")

            self.assertTrue(changelog_path.exists())
            with open(changelog_path) as f:
                line = f.readline()
            data = json.loads(line)
            self.assertEqual(data["parameter"], "entry_threshold")

    def test_append_only(self):
        """Should append, never overwrite."""
        with tempfile.TemporaryDirectory() as tmp:
            changelog_path = Path(tmp) / "changelog.jsonl"
            for i in range(3):
                entry = {
                    "parameter": f"param_{i}",
                    "status": "pending",
                    "timestamp": datetime.now(timezone.utc).isoformat(),
                }
                with open(changelog_path, "a") as f:
                    f.write(json.dumps(entry, default=str) + "\n")

            with open(changelog_path) as f:
                lines = f.readlines()
            self.assertEqual(len(lines), 3)

    def test_auto_timestamp(self):
        """Should auto-add UTC timestamp if not present."""
        entry = {"parameter": "test", "status": "pending"}
        if "timestamp" not in entry:
            entry["timestamp"] = datetime.now(timezone.utc).isoformat()
        self.assertIn("timestamp", entry)
        # Verify it's a valid ISO format
        datetime.fromisoformat(entry["timestamp"])

    def test_read_changelog_filter(self):
        """read_changelog should filter by skill and status."""
        with tempfile.TemporaryDirectory() as tmp:
            changelog_path = Path(tmp) / "changelog.jsonl"
            entries = [
                {
                    "skill": "skill-a",
                    "status": "pending",
                    "timestamp": "2026-01-01T00:00:00+00:00",
                },
                {
                    "skill": "skill-b",
                    "status": "keep",
                    "timestamp": "2026-01-02T00:00:00+00:00",
                },
                {
                    "skill": "skill-a",
                    "status": "reverted",
                    "timestamp": "2026-01-03T00:00:00+00:00",
                },
            ]
            for e in entries:
                with open(changelog_path, "a") as f:
                    f.write(json.dumps(e) + "\n")

            # Read all
            with open(changelog_path) as f:
                all_lines = [json.loads(l) for l in f.readlines()]
            self.assertEqual(len(all_lines), 3)

            # Filter by skill
            skill_a = [e for e in all_lines if e.get("skill") == "skill-a"]
            self.assertEqual(len(skill_a), 2)

            # Filter by status
            pending = [e for e in all_lines if e.get("status") == "pending"]
            self.assertEqual(len(pending), 1)

            # Last N
            last_2 = all_lines[-2:]
            self.assertEqual(len(last_2), 2)


if __name__ == "__main__":
    unittest.main()


path: test_bayesian_update.py
content: """
Test suite for Bayesian probability update module.
Tests bayesian_update, compute_likelihood_ratio, should_update_probability, and should_close_position.
"""

import pytest
import sys
import os

# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from bayesian_update import (
    bayesian_update,
    compute_likelihood_ratio,
    should_update_probability,
    should_close_position,
)


class TestBayesianUpdate:
    """Tests for bayesian_update function."""

    def test_neutral_likelihood_ratio_returns_same_probability(self):
        """bayesian_update with likelihood_ratio=1.0 returns same probability."""
        result = bayesian_update(0.6, 1.0)
        assert result == 0.6

    def test_likelihood_ratio_greater_than_one_increases_probability(self):
        """bayesian_update with likelihood_ratio > 1.0 increases probability."""
        result = bayesian_update(0.5, 2.0)
        # new_p = (2.0 * 0.5) / (2.0 * 0.5 + 0.5) = 1.0 / 1.5 = 0.666...
        assert result > 0.5
        assert abs(result - 2.0 / 3.0) < 0.0001

    def test_likelihood_ratio_less_than_one_decreases_probability(self):
        """bayesian_update with likelihood_ratio < 1.0 decreases probability."""
        result = bayesian_update(0.6, 0.5)
        # new_p = (0.5 * 0.6) / (0.5 * 0.6 + 0.4) = 0.3 / 0.7 = 0.428...
        assert result < 0.6
        assert abs(result - 0.3 / 0.7) < 0.0001

    def test_denominator_zero_returns_prior(self):
        """bayesian_update with denominator=0 returns prior (edge case)."""
        # This can happen when prior=1 and likelihood_ratio=0
        result = bayesian_update(1.0, 0.0)
        assert result == 1.0


class TestComputeLikelihoodRatio:
    """Tests for compute_likelihood_ratio function."""

    def test_price_drop_returns_less_than_one(self):
        """compute_likelihood_ratio with price drop returns < 1.0."""
        # If price drops from 0.6 to 0.5, the new probability estimate is lower
        result = compute_likelihood_ratio(0.5, 0.6)
        assert result < 1.0
        assert result > 0

    def test_price_rise_returns_greater_than_one(self):
        """compute_likelihood_ratio with price rise returns > 1.0."""
        # If price rises from 0.5 to 0.6, the new probability estimate is higher
        result = compute_likelihood_ratio(0.6, 0.5)
        assert result > 1.0

    def test_old_price_half_returns_one(self):
        """compute_likelihood_ratio with old_price=0.5 returns 1.0 (edge case - odds are 1:1)."""
        result = compute_likelihood_ratio(0.6, 0.5)
        # odds(0.5) = 0.5/0.5 = 1, so ratio should be based on new price only
        assert result > 1.0  # 0.6 has better odds than 0.5


class TestShouldUpdateProbability:
    """Tests for should_update_probability function."""

    def test_negative_fifteen_percent_pnl_returns_true(self):
        """should_update_probability with -15% PnL returns True (threshold=-10%)."""
        result = should_update_probability(-0.15, threshold=-0.10)
        assert result is True

    def test_negative_five_percent_pnl_returns_false(self):
        """should_update_probability with -5% PnL returns False."""
        result = should_update_probability(-0.05, threshold=-0.10)
        assert result is False


class TestShouldClosePosition:
    """Tests for should_close_position function."""

    def test_large_drop_returns_true(self):
        """should_close_position with updated=0.50, initial=0.70 returns True (diff=0.20 > 0.15)."""
        result = should_close_position(0.50, 0.70, threshold=-0.15)
        assert result is True

    def test_small_drop_returns_false(self):
        """should_close_position with updated=0.60, initial=0.70 returns False (diff=0.10 < 0.15)."""
        result = should_close_position(0.60, 0.70, threshold=-0.15)
        assert result is False


class TestIntegration:
    """Integration test for full Bayesian flow."""

    def test_full_bayesian_flow_initial_drop_triggers_update_and_close(self):
        """Integration: initial=0.65, price drops to 0.50, should trigger update and close."""
        initial_prob = 0.65
        old_price = initial_prob  # For simplicity, use prob as price
        new_price = 0.50

        # Step 1: Check if we should update
        pnl_pct = (new_price - old_price) / old_price
        assert should_update_probability(pnl_pct, threshold=-0.10) is True

        # Step 2: Compute likelihood ratio
        lr = compute_likelihood_ratio(new_price, old_price)
        assert lr < 1.0  # Price drop means lower likelihood

        # Step 3: Update probability
        updated_prob = bayesian_update(initial_prob, lr)
        assert updated_prob < initial_prob

        # Step 4: Check if should close
        # Difference: initial - updated = 0.65 - updated_prob
        # We expect this to be > 0.15 for a significant drop
        assert (
            should_close_position(updated_prob, initial_prob, threshold=-0.15) is True
        )


path: test_ev_calculator.py
content: #!/usr/bin/env python3
"""
Tests for ev_calculator: calculate_ev, should_take_trade,
kelly_fraction, quarter_kelly_position, is_longtail_contract,
longtail_ev_adjustment.

Uses stdlib unittest only — no external dependencies.
"""

import unittest
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from ev_calculator import (
    calculate_ev,
    should_take_trade,
    kelly_fraction,
    quarter_kelly_position,
    is_longtail_contract,
    longtail_ev_adjustment,
    derive_win_prob,
)


class TestCalculateEV(unittest.TestCase):
    """Tests for calculate_ev()"""

    def test_fair_odds_returns_zero_ev(self):
        """At fair odds (p=price), EV should be ~0."""
        price = 0.50
        p = 0.50  # 50% win probability = market-implied odds
        ev = calculate_ev(price, p)
        self.assertAlmostEqual(ev, 0.0, places=6)

    def test_positive_ev_favorable_trade(self):
        """When our estimate > market price, EV should be positive."""
        price = 0.50
        p = 0.55  # We think 55% but market says 50%
        ev = calculate_ev(price, p)
        self.assertGreater(ev, 0.0)

    def test_negative_ev_unfavorable_trade(self):
        """When our estimate < market price, EV should be negative."""
        price = 0.50
        p = 0.45  # We think 45% but market says 50%
        ev = calculate_ev(price, p)
        self.assertLess(ev, 0.0)

    def test_high_price_low_prob_negative_ev(self):
        """Expensive contract with low probability = very negative EV."""
        price = 0.90
        p = 0.10
        ev = calculate_ev(price, p)
        self.assertLess(ev, 0.0)
        self.assertLess(ev, -0.5)

    def test_low_price_high_prob_positive_ev(self):
        """Cheap contract with high probability = positive EV."""
        price = 0.10
        p = 0.50
        ev = calculate_ev(price, p)
        self.assertGreater(ev, 0.0)

    def test_extreme_price_bounds(self):
        """Edge cases at price boundaries."""
        with self.assertRaises(ZeroDivisionError):
            calculate_ev(0.0, 0.5)
        with self.assertRaises(ZeroDivisionError):
            calculate_ev(1.0, 0.5)


class TestShouldTakeTrade(unittest.TestCase):
    """Tests for should_take_trade()"""

    def test_positive_ev_returns_true(self):
        """EV > 0 should return True."""
        self.assertTrue(should_take_trade(price=0.50, win_prob=0.55))

    def test_negative_ev_returns_false(self):
        """EV <= 0 should return False."""
        self.assertFalse(should_take_trade(price=0.50, win_prob=0.45))

    def test_zero_ev_returns_false(self):
        """EV == 0 should return False (no edge)."""
        self.assertFalse(should_take_trade(price=0.50, win_prob=0.50))

    def test_custom_min_ev_threshold(self):
        """Custom threshold should be respected."""
        self.assertFalse(should_take_trade(price=0.50, win_prob=0.51, min_ev=0.05))
        self.assertTrue(should_take_trade(price=0.50, win_prob=0.60, min_ev=0.05))


class TestKellyFraction(unittest.TestCase):
    """Tests for kelly_fraction()"""

    def test_fair_odds_returns_zero_kelly(self):
        """At fair odds, Kelly fraction should be 0."""
        price = 0.50
        p = 0.50
        kelly = kelly_fraction(price, p)
        self.assertAlmostEqual(kelly, 0.0, places=6)

    def test_positive_edge_returns_positive_kelly(self):
        """Positive edge should return positive Kelly."""
        price = 0.50
        p = 0.55
        kelly = kelly_fraction(price, p)
        self.assertGreater(kelly, 0.0)
        self.assertLess(kelly, 1.0)

    def test_kelly_never_exceeds_one(self):
        """Kelly fraction should be capped at 1.0 (100%)."""
        price = 0.01
        p = 0.99
        kelly = kelly_fraction(price, p)
        self.assertLessEqual(kelly, 1.0)

    def test_kelly_never_negative(self):
        """Kelly fraction should never be negative."""
        price = 0.90
        p = 0.10
        kelly = kelly_fraction(price, p)
        self.assertGreaterEqual(kelly, 0.0)

    def test_zero_price_returns_zero_kelly(self):
        """Zero price should return 0 Kelly (avoid division by zero)."""
        kelly = kelly_fraction(0.0, 0.5)
        self.assertEqual(kelly, 0.0)


class TestQuarterKellyPosition(unittest.TestCase):
    """Tests for quarter_kelly_position()"""

    def test_position_below_max(self):
        """Quarter Kelly position should be less than max_pos."""
        position = quarter_kelly_position(
            price=0.50, win_prob=0.55, bankroll=100.0, max_pos=5.0
        )
        self.assertLessEqual(position, 5.0)

    def test_position_respects_bankroll(self):
        """Quarter Kelly should scale with bankroll."""
        pos_100 = quarter_kelly_position(
            price=0.50, win_prob=0.55, bankroll=100.0, max_pos=100.0
        )
        pos_200 = quarter_kelly_position(
            price=0.50, win_prob=0.55, bankroll=200.0, max_pos=100.0
        )
        self.assertGreater(pos_200, pos_100)

    def test_zero_ev_returns_zero_position(self):
        """Zero EV should return 0 position (no trade)."""
        position = quarter_kelly_position(
            price=0.50, win_prob=0.50, bankroll=100.0, max_pos=5.0
        )
        self.assertEqual(position, 0.0)

    def test_max_cap_applied(self):
        """Position should be capped at max_pos."""
        position = quarter_kelly_position(
            price=0.01, win_prob=0.99, bankroll=10000.0, max_pos=5.0
        )
        self.assertEqual(position, 5.0)


class TestIsLongtailContract(unittest.TestCase):
    """Tests for is_longtail_contract()"""

    def test_below_20_cents_taker_is_longtail(self):
        """<20¢ taker contracts are longtail."""
        self.assertTrue(is_longtail_contract(price=0.15, strategy_type="taker"))

    def test_above_20_cents_not_longtail(self):
        """>=20¢ contracts are not longtail."""
        self.assertFalse(is_longtail_contract(price=0.25, strategy_type="taker"))

    def test_exactly_20_cents_not_longtail(self):
        """Exactly 20¢ is not longtail (threshold is < 20¢)."""
        self.assertFalse(is_longtail_contract(price=0.20, strategy_type="taker"))

    def test_maker_not_flagged(self):
        """Maker strategy is not flagged as longtail even if <20¢."""
        self.assertFalse(is_longtail_contract(price=0.15, strategy_type="maker"))

    def test_1_cent_contract_is_longtail(self):
        """Very cheap contracts are longtail."""
        self.assertTrue(is_longtail_contract(price=0.01, strategy_type="taker"))


class TestLongtailEVAdjustment(unittest.TestCase):
    """Tests for longtail_ev_adjustment()"""

    def test_penalty_reduces_ev(self):
        """Longtail penalty should reduce raw EV."""
        raw_ev = 0.10
        adjusted = longtail_ev_adjustment(0.15, raw_ev)
        self.assertLess(adjusted, raw_ev)

    def test_positive_ev_can_become_negative(self):
        """Positive EV can become negative after penalty."""
        raw_ev = 0.15
        adjusted = longtail_ev_adjustment(0.15, raw_ev)
        self.assertLess(adjusted, 0.0)

    def test_high_ev_survives_penalty(self):
        """High enough EV survives the penalty."""
        raw_ev = 0.50
        adjusted = longtail_ev_adjustment(0.15, raw_ev)
        self.assertGreater(adjusted, 0.0)


class TestDeriveWinProb(unittest.TestCase):
    """Tests for derive_win_prob()"""

    def test_base_prob_at_zero_momentum(self):
        """Zero momentum and zero divergence should return base probability."""
        prob = derive_win_prob(momentum_pct=0.0, divergence=0.0, base_prob=0.50)
        self.assertAlmostEqual(prob, 0.50, places=2)

    def test_momentum_increases_prob(self):
        """Stronger momentum should increase probability above base."""
        prob = derive_win_prob(momentum_pct=1.0, divergence=0.0, base_prob=0.50)
        self.assertGreater(prob, 0.50)

    def test_divergence_increases_prob(self):
        """Larger divergence should increase probability."""
        prob_low = derive_win_prob(momentum_pct=0.0, divergence=0.05, base_prob=0.50)
        prob_high = derive_win_prob(momentum_pct=0.0, divergence=0.15, base_prob=0.50)
        self.assertGreater(prob_high, prob_low)

    def test_prob_bounded_between_0_01_and_0_99(self):
        """Probability should never go below 0.01 or above 0.99."""
        prob_min = derive_win_prob(momentum_pct=100.0, divergence=1.0, base_prob=0.50)
        self.assertLessEqual(prob_min, 0.99)
        prob_max = derive_win_prob(momentum_pct=-100.0, divergence=-1.0, base_prob=0.50)
        self.assertGreaterEqual(prob_max, 0.01)

    def test_momentum_bonus_capped_at_0_25(self):
        """Momentum bonus should be capped at 0.25."""
        prob_at_cap = derive_win_prob(momentum_pct=50.0, divergence=0.0, base_prob=0.50)
        prob_over_cap = derive_win_prob(
            momentum_pct=100.0, divergence=0.0, base_prob=0.50
        )
        self.assertAlmostEqual(
            prob_at_cap, prob_over_cap, places=2
        )  # Both capped at 0.25


if __name__ == "__main__":
    unittest.main()


path: test_maker_taker_arbiter.py
content: """
Test suite for maker_taker_arbiter module.
TDD RED phase - tests should FAIL until implementation is added.
"""

import pytest
from maker_taker_arbiter import StrategyMode, MarketCondition, determine_strategy_mode, get_maker_taker_allocation


class TestGetMakerTakerAllocation:
    """Test allocation percentages for each strategy mode."""

    def test_maker_heavy_returns_65_35(self):
        """MAKER_HEAVY mode should return 65% maker, 35% taker."""
        maker_pct, taker_pct = get_maker_taker_allocation(StrategyMode.MAKER_HEAVY)
        assert maker_pct == 0.65
        assert taker_pct == 0.35

    def test_balanced_returns_50_50(self):
        """BALANCED mode should return 50% maker, 50% taker."""
        maker_pct, taker_pct = get_maker_taker_allocation(StrategyMode.BALANCED)
        assert maker_pct == 0.50
        assert taker_pct == 0.50

    def test_taker_heavy_returns_30_70(self):
        """TAKER_HEAVY mode should return 30% maker, 70% taker."""
        maker_pct, taker_pct = get_maker_taker_allocation(StrategyMode.TAKER_HEAVY)
        assert maker_pct == 0.30
        assert taker_pct == 0.70


class TestDetermineStrategyMode:
    """Test market condition to strategy mode mapping."""

    def test_both_low_returns_maker_heavy(self):
        """spread < 50bps AND vol < 50% annualized → MAKER_HEAVY."""
        condition = MarketCondition(spread_bps=30.0, volatility=0.30)
        result = determine_strategy_mode(condition)
        assert result == StrategyMode.MAKER_HEAVY

    def test_low_spread_high_vol_returns_balanced(self):
        """spread < 50bps XOR vol < 50% → BALANCED (low spread, high vol)."""
        condition = MarketCondition(spread_bps=30.0, volatility=0.60)
        result = determine_strategy_mode(condition)
        assert result == StrategyMode.BALANCED

    def test_high_spread_low_vol_returns_balanced(self):
        """spread < 50bps XOR vol < 50% → BALANCED (high spread, low vol)."""
        condition = MarketCondition(spread_bps=60.0, volatility=0.30)
        result = determine_strategy_mode(condition)
        assert result == StrategyMode.BALANCED

    def test_both_high_returns_taker_heavy(self):
        """spread >= 50bps AND vol >= 50% annualized → TAKER_HEAVY."""
        condition = MarketCondition(spread_bps=60.0, volatility=0.60)
        result = determine_strategy_mode(condition)
        assert result == StrategyMode.TAKER_HEAVY

    def test_just_under_both_thresholds_returns_maker_heavy(self):
        """spread=49bps + vol=0.49 (just under both thresholds) → MAKER_HEAVY."""
        condition = MarketCondition(spread_bps=49.0, volatility=0.49)
        result = determine_strategy_mode(condition)
        assert result == StrategyMode.MAKER_HEAVY

    def test_exactly_at_thresholds_returns_taker_heavy(self):
        """spread=50bps + vol=0.50 (exactly at thresholds) → TAKER_HEAVY."""
        condition = MarketCondition(spread_bps=50.0, volatility=0.50)
        result = determine_strategy_mode(condition)
        assert result == StrategyMode.TAKER_HEAVY

    def test_zero_vol_with_low_spread_returns_maker_heavy(self):
        """volatility=0.0 (zero vol) with spread=30bps → MAKER_HEAVY."""
        condition = MarketCondition(spread_bps=30.0, volatility=0.0)
        result = determine_strategy_mode(condition)
        assert result == StrategyMode.MAKER_HEAVY


path: test_noaa_calibration.py
content: """
Tests for v1.5 NOAA horizon-based probability calibration.
"""

import sys
from datetime import datetime, timezone, timedelta
from unittest.mock import patch

sys.path.insert(0, ".")
from weather_trader import (
    NOAA_HORIZON_ACCURACY,
    get_forecast_horizon,
    calibrate_noaa_probability,
)


def test_horizon_accuracy_table_has_all_keys():
    """NOAA_HORIZON_ACCURACY covers keys 0-7."""
    for i in range(8):
        assert i in NOAA_HORIZON_ACCURACY, f"Missing horizon {i}"
    # Values should decrease with horizon
    for i in range(1, 8):
        assert NOAA_HORIZON_ACCURACY[i] < NOAA_HORIZON_ACCURACY[i - 1], (
            f"Horizon {i} should be less accurate than {i - 1}"
        )


def test_get_forecast_horizon_today():
    """D+0 (today) returns 0."""
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    info = {"date": today}
    assert get_forecast_horizon(info) == 0


def test_get_forecast_horizon_tomorrow():
    """D+1 (tomorrow) returns 1."""
    tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d")
    info = {"date": tomorrow}
    assert get_forecast_horizon(info) == 1


def test_get_forecast_horizon_d3():
    """D+3 returns 3."""
    d3 = (datetime.now(timezone.utc) + timedelta(days=3)).strftime("%Y-%m-%d")
    info = {"date": d3}
    assert get_forecast_horizon(info) == 3


def test_get_forecast_horizon_d7():
    """D+7 returns 7."""
    d7 = (datetime.now(timezone.utc) + timedelta(days=7)).strftime("%Y-%m-%d")
    info = {"date": d7}
    assert get_forecast_horizon(info) == 7


def test_get_forecast_horizon_unknown():
    """Missing date returns 1 (safe default)."""
    assert get_forecast_horizon(None) == 1
    assert get_forecast_horizon({}) == 1
    assert get_forecast_horizon({"location": "NYC"}) == 1


def test_calibrate_noaa_probability_d1():
    """D+1 calibrated prob should reflect higher accuracy (0.90 base)."""
    tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d")
    info = {"date": tomorrow}
    result = calibrate_noaa_probability(info, base_prob=0.85)
    # D+1 accuracy is 0.90; ratio = 0.90/0.85 ≈ 1.059; 0.85 * 1.059 ≈ 0.90
    assert 0.88 <= result <= 0.95


def test_calibrate_noaa_probability_d5():
    """D+5 calibrated prob should reflect lower accuracy (0.74 base)."""
    d5 = (datetime.now(timezone.utc) + timedelta(days=5)).strftime("%Y-%m-%d")
    info = {"date": d5}
    result = calibrate_noaa_probability(info, base_prob=0.85)
    # D+5 accuracy is 0.74; ratio = 0.74/0.85 ≈ 0.871; 0.85 * 0.871 ≈ 0.74
    assert 0.70 <= result <= 0.78


def test_calibrate_noaa_probability_d7():
    """D+7 calibrated prob should be lowest (0.66 base)."""
    d7 = (datetime.now(timezone.utc) + timedelta(days=7)).strftime("%Y-%m-%d")
    info = {"date": d7}
    result = calibrate_noaa_probability(info, base_prob=0.85)
    assert 0.62 <= result <= 0.70


def test_calibrate_noaa_probability_today():
    """D+0 calibrated prob should be highest (0.95 base)."""
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    info = {"date": today}
    result = calibrate_noaa_probability(info, base_prob=0.85)
    assert 0.92 <= result <= 0.99


def test_calibrate_noaa_probability_bounded():
    """Calibrated probability stays within [0.01, 0.99]."""
    for days in [0, 1, 3, 5, 7, 14]:
        d = (datetime.now(timezone.utc) + timedelta(days=days)).strftime("%Y-%m-%d")
        result = calibrate_noaa_probability({"date": d}, base_prob=0.85)
        assert 0.01 <= result <= 0.99


def test_calibrate_noaa_probability_none_event_info():
    """None event_info returns safe default near base_prob."""
    result = calibrate_noaa_probability(None, base_prob=0.85)
    assert 0.80 <= result <= 0.90


def test_calibrate_noaa_probability_custom_base():
    """Custom base_prob is respected in scaling."""
    d3 = (datetime.now(timezone.utc) + timedelta(days=3)).strftime("%Y-%m-%d")
    info = {"date": d3}
    result = calibrate_noaa_probability(info, base_prob=0.80)
    # D+3 accuracy 0.82 / 0.80 = 1.025; 0.80 * 1.025 ≈ 0.82
    assert 0.78 <= result <= 0.86


path: trade_performance.py
content: #!/usr/bin/env python3
"""
Trade Performance Tracking Module

Provides structured JSONL logging, circuit breaker management, and trade aggregation
for polymarket trading skills. Zero external dependencies - stdlib only.

Usage:
    python trade_performance.py --report skill=polymarket-fast-loop
    python trade_performance.py --validate
    python trade_performance.py --resume /path/to/skill/dir
"""

import json
import sys
import argparse
import importlib
import importlib.util
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional
import math
import statistics

# =============================================================================
# Schema Definition
# =============================================================================

TRADING_LOG_SCHEMA = {
    "timestamp": "UTC ISO 8601 timestamp",
    "skill": "Skill slug (e.g., polymarket-fast-loop)",
    "source": "Trade source identifier",
    "success": "bool - trade execution success",
    "trade_id": "str - unique trade identifier",
    "market_id": "str - polymarket market condition ID",
    "side": "str - 'yes' or 'no'",
    "shares_bought": "float - actual shares purchased",
    "shares_requested": "float - requested shares",
    "cost": "float - trade cost in USD",
    "new_price": "float - price after trade",
    "order_status": "str - order fill status",
    "simulated": "bool - paper trade flag",
    "signal": "dict - signal context (source, strength, etc.)",
    "error": "str - error message if failed",
    "consecutive_losses": "int - circuit breaker state snapshot",
    "skill_paused": "bool - circuit breaker paused state",
}

SKILL_SLUG = "polymarket-weather-trader"
TRADE_SOURCE = "sdk:weather"


# =============================================================================
# Trade Logging
# =============================================================================


def get_trades_path() -> Path:
    """Get path to trades.jsonl in the same directory as this module."""
    return Path(__file__).parent / "trades.jsonl"


def write_trade_log(
    trade_result: dict,
    skill_slug: str,
    source: str,
    signal_data: Optional[dict] = None,
    extra: Optional[dict] = None,
) -> None:
    """
    Append a trade record to trades.jsonl.

    Args:
        trade_result: Dict from execute_trade() with keys: success, trade_id,
                     shares_bought, shares (alias), error, simulated
        skill_slug: Identifier for the skill (e.g., "polymarket-fast-loop")
        source: Trade source (e.g., "sdk:fastloop")
        signal_data: Optional dict with signal context
        extra: Optional additional fields
    """
    trades_path = get_trades_path()

    # Extract core trade data with defaults
    success = trade_result.get("success", False)
    trade_id = trade_result.get("trade_id", "")
    shares_bought = trade_result.get("shares_bought", trade_result.get("shares", 0))
    shares_requested = trade_result.get(
        "shares_requested", trade_result.get("shares_requested", shares_bought)
    )
    cost = trade_result.get("cost", 0.0)
    new_price = trade_result.get("new_price", trade_result.get("price_after", 0.0))
    order_status = trade_result.get("order_status", "")
    simulated = trade_result.get("simulated", False)
    error_msg = trade_result.get("error", "")
    market_id = trade_result.get("market_id", "")
    side = trade_result.get("side", "")

    # Capture circuit breaker state snapshot (inline to avoid import issues)
    skill_dir = str(Path(__file__).parent)
    cb_path = Path(skill_dir) / "circuit_breaker.json"
    is_paused = False
    consecutive_losses = 0
    if cb_path.exists():
        try:
            cb_data = json.loads(cb_path.read_text())
            is_paused = cb_data.get("paused", False)
            consecutive_losses = cb_data.get("consecutive_losses", 0)
        except (json.JSONDecodeError, OSError):
            pass

    # Build entry
    entry = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "skill": skill_slug,
        "source": source,
        "success": success,
        "trade_id": trade_id,
        "market_id": market_id,
        "side": side,
        "shares_bought": shares_bought,
        "shares_requested": shares_requested,
        "cost": cost,
        "new_price": new_price,
        "order_status": order_status,
        "simulated": simulated,
        "signal": signal_data or {},
        "error": error_msg,
        "consecutive_losses": consecutive_losses,
        "skill_paused": is_paused,
    }

    # Add extra fields
    if extra:
        entry.update(extra)

    # Append as JSON line
    with open(trades_path, "a") as f:
        f.write(json.dumps(entry, default=str) + "\n")


# =============================================================================
# Circuit Breaker
# =============================================================================


def get_circuit_breaker_path(skill_dir: str) -> Path:
    """Get path to circuit_breaker.json in skill directory."""
    return Path(skill_dir) / "circuit_breaker.json"


def check_circuit_breaker(
    skill_dir: str,
    threshold: int = 3,
    cooldown_hours: int = 6,
) -> tuple[bool, str]:
    """
    Check circuit breaker status.

    Args:
        skill_dir: Path to skill directory
        threshold: Consecutive losses before triggering pause
        cooldown_hours: Hours to stay paused before auto-resume

    Returns:
        (is_allowed, message) - True if trading allowed, message describes state
    """
    cb_path = get_circuit_breaker_path(skill_dir)

    # No file = not triggered
    if not cb_path.exists():
        return True, ""

    try:
        data = json.loads(cb_path.read_text())
    except (json.JSONDecodeError, OSError):
        # Fail-open on corrupt JSON
        cb_path.unlink()
        return True, ""

    # Check if paused
    if not data.get("paused", False):
        return True, ""

    # Check resume time
    resume_at = data.get("resume_at")
    if resume_at:
        resume_time = datetime.fromisoformat(resume_at)
        now = datetime.now(timezone.utc)
        if now >= resume_time:
            # Auto-resume expired
            cb_path.unlink()
            return True, "auto-resumed"

    # Still paused
    reason = data.get("reason", "consecutive losses exceeded")
    return False, f"PAUSED: {reason}"


# Error patterns that should NOT count as consecutive losses (infra/config issues)
_INFRA_ERROR_PATTERNS = [
    "no polymarket wallet",
    "no wallet",
    "wallet not found",
    "authentication",
    "api key",
    "unauthorized",
    "forbidden",
    "timeout",
    "timed out",
    "connection",
    "network",
    "dns",
    "ssl",
    "certificate",
    "simmer sdk",
    "import failed",
    "module not found",
]


def _is_infra_error(error: str) -> bool:
    """Check if error is a configuration/infra issue (not a market loss)."""
    if not error:
        return False
    error_lower = error.lower()
    return any(p in error_lower for p in _INFRA_ERROR_PATTERNS)


def update_circuit_breaker(
    skill_dir: str,
    trade_success: bool,
    simulated: bool,
    threshold: int = 3,
    cooldown_hours: int = 6,
    error: str = "",
) -> None:
    """
    Update circuit breaker state after a trade.

    Args:
        skill_dir: Path to skill directory
        trade_success: Whether trade succeeded
        simulated: Whether this was a paper trade
        threshold: Consecutive losses before pause
        cooldown_hours: Hours to pause
        error: Error message if trade failed (used to distinguish infra vs market errors)
    """
    # Paper trades don't count
    if simulated:
        return

    # Infra/config errors don't count as losses
    if not trade_success and _is_infra_error(error):
        return

    cb_path = get_circuit_breaker_path(skill_dir)

    # Read existing state
    if cb_path.exists():
        try:
            data = json.loads(cb_path.read_text())
        except (json.JSONDecodeError, OSError):
            data = {"consecutive_losses": 0, "consecutive_wins": 0}
    else:
        data = {"consecutive_losses": 0, "consecutive_wins": 0}

    # Update counters
    if trade_success:
        data["consecutive_losses"] = 0
        data["consecutive_wins"] = data.get("consecutive_wins", 0) + 1
    else:
        data["consecutive_losses"] = data.get("consecutive_losses", 0) + 1
        data["consecutive_wins"] = 0

    # Check threshold
    if data["consecutive_losses"] >= threshold:
        data["paused"] = True
        data["reason"] = (
            f"consecutive losses = {data['consecutive_losses']} >= {threshold}"
        )
        resume_at = datetime.now(timezone.utc) + timedelta(hours=cooldown_hours)
        data["resume_at"] = resume_at.isoformat()
        data["paused_at"] = datetime.now(timezone.utc).isoformat()

    # Write state
    with open(cb_path, "w") as f:
        json.dump(data, f, indent=2)


# =============================================================================
# Trade Aggregation
# =============================================================================


def aggregate_trades(
    trades_path: str,
    skill: Optional[str] = None,
    date: Optional[str] = None,
) -> dict:
    """
    Aggregate trade statistics from trades.jsonl.

    Args:
        trades_path: Path to trades.jsonl file
        skill: Optional skill slug to filter by
        date: Optional date string (YYYY-MM-DD) to filter by

    Returns:
        Dict with: total_trades, successes, failures, live_trades,
        paper_trades, order_success_rate, total_invested_usd, avg_edge
    """
    path = Path(trades_path)
    if not path.exists():
        return {
            "total_trades": 0,
            "successes": 0,
            "failures": 0,
            "live_trades": 0,
            "paper_trades": 0,
            "order_success_rate": 0.0,
            "total_invested_usd": 0.0,
            "avg_edge": 0.0,
        }

    total_trades = 0
    successes = 0
    failures = 0
    live_trades = 0
    paper_trades = 0
    total_invested = 0.0
    edges = []

    with open(path, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue

            try:
                entry = json.loads(line)
            except json.JSONDecodeError:
                continue

            # Filter by skill
            if skill and entry.get("skill") != skill:
                continue

            # Filter by date
            if date:
                entry_date = entry.get("timestamp", "")[:10]
                if entry_date != date:
                    continue

            total_trades += 1

            # Count successes/failures
            if entry.get("success", False):
                successes += 1
            else:
                failures += 1

            # Count live/paper
            if entry.get("simulated", False):
                paper_trades += 1
            else:
                live_trades += 1

            # Sum invested
            cost = entry.get("cost", 0.0)
            total_invested += cost

            # Track edge if available
            edge = entry.get("signal", {}).get("edge")
            if edge is not None:
                edges.append(edge)

    order_success_rate = (successes / total_trades * 100) if total_trades > 0 else 0.0
    avg_edge = sum(edges) / len(edges) if edges else 0.0

    return {
        "total_trades": total_trades,
        "successes": successes,
        "failures": failures,
        "live_trades": live_trades,
        "paper_trades": paper_trades,
        "order_success_rate": round(order_success_rate, 2),
        "total_invested_usd": round(total_invested, 2),
        "avg_edge": round(avg_edge, 4),
    }


# =============================================================================
# Autotuning Functions
# =============================================================================


def compute_health_score(
    trades_path: str,
    skill: str,
    half_life_days: int = 180,
) -> dict:
    """
    Compute a weighted health score (0-100) for a trading strategy.

    Uses exponential half-life decay to weight recent trades more heavily.
    Components: win_rate (40%), pnl_score (30%), consistency (20%), activity (10%).

    Args:
        trades_path: Path to trades.jsonl file
        skill: Skill slug to filter trades
        half_life_days: Half-life for exponential decay weighting (default 180)

    Returns:
        Dict with: health_score, components, halflife_days, trades_analyzed
        Returns health_score=50 with reason="insufficient data" when < 3 trades
    """
    path = Path(trades_path)
    if not path.exists():
        return {
            "health_score": 50,
            "reason": "insufficient data",
            "components": {},
            "halflife_days": half_life_days,
            "trades_analyzed": 0,
        }

    now = datetime.now(timezone.utc)
    trades = []
    with open(path, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
            except json.JSONDecodeError:
                continue
            # Filter by skill and exclude simulated
            if entry.get("skill") != skill or entry.get("simulated", False):
                continue
            trades.append(entry)

    if len(trades) < 3:
        return {
            "health_score": 50,
            "reason": "insufficient data",
            "components": {},
            "halflife_days": half_life_days,
            "trades_analyzed": len(trades),
        }

    # Compute weights and weighted metrics
    weighted_wins = 0.0
    weighted_total = 0.0
    daily_counts = {}
    recent_7d = 0

    for t in trades:
        ts_str = t.get("timestamp", "")
        try:
            ts = datetime.fromisoformat(ts_str)
            if ts.tzinfo is None:
                ts = ts.replace(tzinfo=timezone.utc)
        except (ValueError, TypeError):
            continue

        days_ago = (now - ts).days
        weight = math.exp(-days_ago / half_life_days)

        success = t.get("success", False)
        weighted_total += weight
        if success:
            weighted_wins += weight

        # Group by day for consistency
        day_key = ts.strftime("%Y-%m-%d")
        daily_counts[day_key] = daily_counts.get(day_key, 0) + 1

        # Count recent 7 days
        if days_ago <= 7:
            recent_7d += 1

    if weighted_total == 0:
        return {
            "health_score": 50,
            "reason": "insufficient data",
            "components": {},
            "halflife_days": half_life_days,
            "trades_analyzed": len(trades),
        }

    # Win rate component (40%)
    win_rate_val = (weighted_wins / weighted_total) * 100

    # PnL score component (30%): map win_rate to -1..+1 then to 0..100
    pnl_raw = (win_rate_val / 100) * 2 - 1  # -1 to +1
    pnl_score = (pnl_raw + 1) / 2 * 100  # 0 to 100

    # Consistency component (20%): 1 - CV, clamped 0-100
    counts = list(daily_counts.values())
    if len(counts) > 1 and statistics.mean(counts) > 0:
        cv = statistics.stdev(counts) / statistics.mean(counts)
        consistency = max(0, min(100, (1 - cv) * 100))
    else:
        consistency = 50.0

    # Activity component (10%): min(recent_7d / 5, 1.0) * 100
    activity = min(recent_7d / 5, 1.0) * 100

    # Weighted final score
    health_score = (
        0.40 * win_rate_val + 0.30 * pnl_score + 0.20 * consistency + 0.10 * activity
    )

    return {
        "health_score": round(health_score, 1),
        "components": {
            "win_rate": round(win_rate_val, 1),
            "pnl_score": round(pnl_score, 1),
            "consistency": round(consistency, 1),
            "activity": round(activity, 1),
        },
        "halflife_days": half_life_days,
        "trades_analyzed": len(trades),
    }


def suggest_tuning(
    trades_path: str,
    skill: str,
    config_schema=None,
) -> dict:
    """
    Suggest a single-parameter tuning adjustment based on trade history.

    Implements the autoresearch single-variable principle: only one parameter
    changes per cycle for causal attribution. Cold start guard blocks when < 20 trades.

    Args:
        trades_path: Path to trades.jsonl file
        skill: Skill slug to filter trades
        config_schema: Optional config schema (unused, for future expansion)

    Returns:
        Dict with: parameter, old_value, new_value, reason, confidence
        Returns parameter=None when no clear improvement target
    """
    # Cold start guard
    metrics = aggregate_trades(trades_path, skill=skill)
    total_trades = metrics.get("total_trades", 0)

    if total_trades < 20:
        return {
            "parameter": None,
            "old_value": None,
            "new_value": None,
            "reason": f"insufficient trades ({total_trades} < 20)",
            "confidence": "low",
        }

    win_rate = metrics.get("order_success_rate", 0)
    avg_edge = metrics.get("avg_edge", 0)
    failures = metrics.get("failures", 0)

    # Try to load current config values
    old_threshold = 0.05  # sensible default
    old_position = 5.0  # sensible default
    try:
        from simmer_sdk.skill import load_config, get_config_path

        config = load_config(config_schema, get_config_path(__file__))
        old_threshold = config.get("entry_threshold", old_threshold)
        old_position = config.get(
            "max_position", config.get("max_position_usd", old_position)
        )
    except (ImportError, Exception):
        pass

    # Decision tree (priority order)
    if win_rate < 40:
        new_val = round(old_threshold * 1.10, 4)
        new_val = max(0.01, min(0.50, new_val))
        return {
            "parameter": "entry_threshold",
            "old_value": old_threshold,
            "new_value": new_val,
            "reason": f"win_rate {win_rate:.1f}% below 40%, increase threshold by 10%",
            "confidence": "high",
        }

    if win_rate > 70 and failures > 0:
        new_val = round(old_position * 0.85, 2)
        new_val = max(0.50, min(50.0, new_val))
        return {
            "parameter": "max_position_usd",
            "old_value": old_position,
            "new_value": new_val,
            "reason": f"high win_rate {win_rate:.1f}% but {failures} losses, reduce position by 15%",
            "confidence": "medium",
        }

    if avg_edge < 0.02:
        new_val = round(old_threshold * 1.10, 4)
        new_val = max(0.01, min(0.50, new_val))
        return {
            "parameter": "entry_threshold",
            "old_value": old_threshold,
            "new_value": new_val,
            "reason": f"avg_edge {avg_edge:.4f} too thin (<0.02), increase threshold by 10%",
            "confidence": "medium",
        }

    return {
        "parameter": None,
        "old_value": None,
        "new_value": None,
        "reason": "no clear improvement target",
        "confidence": "low",
    }


def log_param_change(entry: dict) -> None:
    """
    Append a parameter change entry to changelog.jsonl.

    Auto-adds UTC timestamp if not present. Never overwrites existing entries.

    Args:
        entry: Dict with parameter change details (parameter, old_value, new_value,
               reason, status, etc.)
    """
    changelog_path = Path(__file__).parent / "changelog.jsonl"
    if "timestamp" not in entry:
        entry["timestamp"] = datetime.now(timezone.utc).isoformat()
    with open(changelog_path, "a") as f:
        f.write(json.dumps(entry, default=str) + "\n")


def read_changelog(
    skill: str = None,
    status: str = None,
    last_n: int = None,
) -> list:
    """
    Read and filter changelog entries from changelog.jsonl.

    Args:
        skill: Filter by skill slug (optional)
        status: Filter by status value (optional)
        last_n: Return only the last N entries (optional)

    Returns:
        List of matching entry dicts, or [] if file doesn't exist
    """
    changelog_path = Path(__file__).parent / "changelog.jsonl"
    if not changelog_path.exists():
        return []

    entries = []
    with open(changelog_path, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
            except json.JSONDecodeError:
                continue
            # Apply filters
            if skill and entry.get("skill") != skill:
                continue
            if status and entry.get("status") != status:
                continue
            entries.append(entry)

    if last_n is not None:
        entries = entries[-last_n:]

    return entries


# =============================================================================
# CLI
# =============================================================================


def main():
    parser = argparse.ArgumentParser(
        description="Trade Performance CLI - Reporting, validation, circuit breaker control"
    )
    parser.add_argument(
        "--report",
        nargs="*",
        help="Generate trade report. Optional: skill=X date=YYYY-MM-DD",
    )
    parser.add_argument(
        "--validate",
        action="store_true",
        help="Validate trades.jsonl schema compliance",
    )
    parser.add_argument(
        "--resume",
        metavar="SKILL_DIR",
        help="Force resume by deleting circuit_breaker.json",
    )
    parser.add_argument(
        "--auto-tune",
        nargs="?",
        const=SKILL_SLUG,
        metavar="SKILL",
        help="Run autoresearch suggest_tuning() for skill",
    )
    parser.add_argument(
        "--health-score",
        nargs="?",
        const=SKILL_SLUG,
        metavar="SKILL",
        help="Compute health score for skill (or 'all')",
    )
    parser.add_argument(
        "--changelog",
        action="store_true",
        help="Show changelog",
    )
    parser.add_argument(
        "--last",
        type=int,
        default=10,
        help="Show last N entries (with --changelog)",
    )
    parser.add_argument(
        "--status",
        choices=["pending", "keep", "reverted"],
        help="Filter by status (with --changelog)",
    )
    parser.add_argument(
        "--revert-last",
        nargs="?",
        const=SKILL_SLUG,
        metavar="SKILL",
        help="Revert last parameter change for skill",
    )

    args = parser.parse_args()

    if args.report is not None:
        # Parse filter options
        skill = None
        date = None
        for item in args.report:
            if item.startswith("skill="):
                skill = item.split("=", 1)[1]
            elif item.startswith("date="):
                date = item.split("=", 1)[1]

        # Get trades path
        if skill:
            # Look in skill directory
            base = Path(__file__).parent.parent / skill
            trades_path = base / "trades.jsonl"
        else:
            trades_path = get_trades_path()

        if not trades_path.exists():
            print(json.dumps({"error": f"No trades.jsonl at {trades_path}"}))
            sys.exit(1)

        stats = aggregate_trades(str(trades_path), skill=skill, date=date)
        print(json.dumps(stats, indent=2))
        return

    if args.validate:
        trades_path = get_trades_path()
        if not trades_path.exists():
            print("No trades.jsonl found")
            sys.exit(0)

        # Check last N lines
        with open(trades_path, "r") as f:
            lines = f.readlines()

        valid_count = 0
        invalid_count = 0
        errors = []

        for i, line in enumerate(lines[-10:]):  # Last 10 lines
            line = line.strip()
            if not line:
                continue

            try:
                entry = json.loads(line)
                # Check required fields
                required = ["timestamp", "skill", "source", "success", "simulated"]
                missing = [k for k in required if k not in entry]
                if missing:
                    invalid_count += 1
                    errors.append(f"Line {i + 1}: missing {missing}")
                else:
                    valid_count += 1
            except json.JSONDecodeError as e:
                invalid_count += 1
                errors.append(f"Line {i + 1}: {e}")

        print(f"Valid lines: {valid_count}")
        print(f"Invalid lines: {invalid_count}")
        if errors:
            print("\nErrors:")
            for err in errors[:5]:
                print(f"  {err}")
        return

    if args.resume:
        cb_path = Path(args.resume) / "circuit_breaker.json"
        if cb_path.exists():
            cb_path.unlink()
            print(f"Deleted {cb_path} - circuit breaker force-resumed")
        else:
            print(f"No circuit_breaker.json in {args.resume}")
        return

    if args.revert_last is not None:
        skill_slug = args.revert_last
        # Read changelog, find last entry with status "keep" or "pending"
        entries = read_changelog(skill=skill_slug)
        revertable = [e for e in entries if e.get("status") in ("keep", "pending")]
        if not revertable:
            print(
                json.dumps(
                    {"error": "no revertable entries found", "skill": skill_slug}
                )
            )
            sys.exit(1)
        last = revertable[-1]
        # Create revert entry: swap old/new values, mark as reverted
        revert_entry = {
            "parameter": last.get("parameter"),
            "old_value": last.get("new_value"),
            "new_value": last.get("old_value"),
            "reason": f"revert of: {last.get('reason', '')}",
            "status": "reverted",
            "skill": skill_slug,
            "reverts_timestamp": last.get("timestamp"),
        }
        log_param_change(revert_entry)
        print(json.dumps({"reverted": True, "entry": revert_entry}, indent=2))
        return

    if args.changelog:
        entries = read_changelog(skill=SKILL_SLUG, status=args.status, last_n=args.last)
        print(json.dumps(entries, indent=2))
        return

    if args.health_score is not None:
        skill_slug = args.health_score
        # Resolve trades path from skill slug (same pattern as --report)
        if skill_slug and skill_slug != "all":
            base = Path(__file__).parent.parent / skill_slug
            trades_path = base / "trades.jsonl"
        else:
            trades_path = get_trades_path()
        if skill_slug == "all":
            skills = [SKILL_SLUG]
        else:
            skills = [skill_slug]
        results = []
        for s in skills:
            result = compute_health_score(str(trades_path), s)
            results.append(result)
        if len(results) == 1:
            print(json.dumps(results[0], indent=2))
        else:
            print(json.dumps(results, indent=2))
        return

    if args.auto_tune is not None:
        skill_slug = args.auto_tune
        # Resolve trades path from skill slug (same pattern as --report)
        if skill_slug:
            base = Path(__file__).parent.parent / skill_slug
            trades_path = base / "trades.jsonl"
        else:
            trades_path = get_trades_path()
        # Load config from skill's trader module
        config = None
        try:
            skill_dir = Path(__file__).parent.parent / skill_slug
            trader_name = skill_dir.name.replace("-", "_") + "_trader"
            trader_path = skill_dir / f"{skill_dir.name.replace('-', '_')}_trader.py"
            if trader_path.exists():
                spec = importlib.util.spec_from_file_location(
                    trader_name, str(trader_path)
                )
                if spec and spec.loader:
                    module = importlib.util.module_from_spec(spec)
                    spec.loader.exec_module(module)
                    from simmer_sdk.skill import load_config, get_config_path

                    config = load_config(
                        module.CONFIG_SCHEMA,
                        get_config_path(str(trader_path)),
                    )
        except Exception:
            pass  # suggest_tuning will use defaults
        result = suggest_tuning(str(trades_path), skill_slug, config)
        print(json.dumps(result, indent=2))
        # Log as pending if a suggestion was found
        if result.get("parameter"):
            log_param_change({**result, "status": "pending", "skill": skill_slug})
        return

    # No args - show help
    parser.print_help()


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
import statistics
from pathlib import Path
from datetime import datetime, timezone, timedelta
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode

# Load .env from project root (two levels up from this file)
try:
    from dotenv import load_dotenv

    _env_path = Path(__file__).resolve().parent.parent / ".env"
    if _env_path.exists():
        load_dotenv(_env_path)
except ImportError:
    pass  # python-dotenv optional; env vars may be set externally

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

# 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


# Performance tracking hooks (trade logging + circuit breaker)
from trade_performance import (
    write_trade_log,
    check_circuit_breaker,
    update_circuit_breaker,
)

# EV Calculator + Kelly Criterion (v1.2)
from ev_calculator import (
    calculate_ev,
    should_take_trade,
    kelly_fraction,
    quarter_kelly_position,
    is_longtail_contract,
    longtail_ev_adjustment,
)

# Bayesian probability update (v1.3)
from bayesian_update import (
    bayesian_update,
    compute_likelihood_ratio,
    should_update_probability,
    should_close_position,
)

# Maker/Taker strategy arbiter (v1.3)
from maker_taker_arbiter import (
    StrategyMode,
    MarketCondition,
    determine_strategy_mode,
    get_maker_taker_allocation,
)

# Smart Money signals (v1.4)
try:
    sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
    from smart_money_signal import (
        fetch_smart_money_signals,
        get_signal_for_market,
        merge_signal_data,
    )

    SMART_MONEY_AVAILABLE = True
except ImportError:
    SMART_MONEY_AVAILABLE = False

    def fetch_smart_money_signals(*args, **kwargs):
        return []

    def get_signal_for_market(*args, **kwargs):
        return None

    def merge_signal_data(base, sm):
        return base

# =============================================================================
# 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": "SIMMER_WEATHER_MAX_TRADES_PER_RUN",
        "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,
    },
    "circuit_breaker_threshold": {
        "default": 3,
        "env": "SIMMER_WEATHER_CB_THRESHOLD",
        "type": int,
        "help": "Consecutive live trade losses before pausing",
    },
    "circuit_breaker_cooldown": {
        "default": 6,
        "env": "SIMMER_WEATHER_CB_COOLDOWN_HOURS",
        "type": int,
        "help": "Hours before auto-resume after circuit breaker trip",
    },
    # v1.2: EV Calculator + Kelly Criterion
    "ev_min_threshold": {
        "default": 0.0,
        "type": float,
        "help": "Minimum EV required to take trade (0.0 = any positive EV)",
    },
    "longtail_ev_penalty": {
        "default": 0.20,
        "type": float,
        "help": "EV penalty for <20¢ contracts (longtail bias)",
    },
    "longtail_price_cutoff": {
        "default": 0.20,
        "type": float,
        "help": "Price threshold below which contracts are considered longtail",
    },
    "use_kelly_sizing": {
        "default": True,
        "type": bool,
        "help": "Use Kelly Criterion for position sizing (vs fixed %)",
    },
    "kelly_multiplier": {
        "default": 0.25,
        "type": float,
        "help": "Kelly fraction multiplier (0.25 = Quarter Kelly)",
    },
    "noaa_win_probability": {
        "default": 0.85,
        "type": float,
        "help": "Base win probability for NOAA forecasts (in-bucket accuracy)",
    },
}

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

# Circuit breaker settings
CB_THRESHOLD = _config.get("circuit_breaker_threshold", 3)
CB_COOLDOWN_HOURS = _config.get("circuit_breaker_cooldown", 6)

NOAA_API_BASE = "https://api.weather.gov"

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

# v2.1: Trade log for city-level bias learning (Scheme 2)
TRADE_LOG_FILE = str(Path.home() / ".hermes" / "weather-trade-log.jsonl")


def apply_madis_bias_correction(
    forecast_temp: float,
    madis_obs: dict,
    event_info: dict,
    base_prob: float,
    unit: str = "F",
) -> tuple[float, str]:
    """
    Apply MADIS airport-observation bias correction to forecast probability.

    Problem: Forecasts can be systematically hot or cold for a given city.
    Solution: Compare MADIS current observation vs forecast, compute bias,
    and shift probability in the direction the bias suggests.

    Args:
        forecast_temp: The forecasted high temperature
        madis_obs: MADIS observation dict with temp_f / temp_c
        event_info: Parsed event info dict
        base_prob: The base probability from NOAA calibration
        unit: 'F' or 'C'

    Returns:
        (adjusted_probability, log_message)
    """
    if not madis_obs or "temp_f" not in madis_obs:
        return base_prob, ""

    madis_temp_f = madis_obs["temp_f"]

    # Fetch today's forecast for comparison (use cached from get_weather_with_confidence)
    # This is called after we already have the forecast, so we compute bias here
    # MADIS is "right now", forecast is "today's expected high"
    # Bias direction: positive = forecast is warmer than actual, negative = colder
    try:
        from . import get_weather_forecast
    except ImportError:
        try:
            from get_weather_forecast import get_weather_forecast
        except ImportError:
            return base_prob, ""

    try:
        weather = get_weather_forecast(event_info["location"])
        today_key = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        day_forecast = weather.get(today_key, {})
        forecast_high_f = day_forecast.get("high")
        if forecast_high_f is None:
            forecast_high_f = day_forecast.get("high_f")
        if forecast_high_f is None:
            return base_prob, ""
    except Exception:
        return base_prob, ""

    # Temperature error: how wrong is today's forecast right now?
    temp_error_f = forecast_high_f - madis_temp_f  # positive = forecast warmer than actual

    # Normalize by ensemble disagreement (proxy for forecast uncertainty)
    # If models agree but MADIS says otherwise → stronger correction
    # Use 3°F as baseline MAE if no disagreement metric available
    normalized_bias = temp_error_f / 3.0  # clamp to [-1, 1] range roughly
    normalized_bias = max(-1.0, min(1.0, normalized_bias))

    # Shift probability in direction that bias suggests
    # If forecast is too warm (temp_error > 0) and we're buying YES on high-temp:
    #   actual temp tends to be lower → probability should be reduced
    # For a "high temp" event: positive error = forecast too warm → reduce prob
    prob_shift = normalized_bias * 0.06  # max shift ~6%
    adjusted = base_prob - prob_shift
    adjusted = max(0.01, min(0.99, adjusted))

    direction = "warm" if temp_error_f > 0 else "cold"
    msg = (
        f" MADIS bias: forecast {direction} by {abs(temp_error_f):.1f}°F "
        f"({forecast_high_f:.0f}°F vs {madis_temp_f:.0f}°F obs) → prob {base_prob:.3f}→{adjusted:.3f}"
    )
    return adjusted, msg


def log_trade_entry(
    market_id: str,
    market_question: str,
    city: str,
    target_date: str,
    forecast_temp: float,
    bucket_range: str,
    price: float,
    side: str,
    amount: float,
    shares: float,
    cost: float,
    noaa_probability: float,
    bias_adjustment: float,
    madis_bias_msg: str,
    unit: str,
    source: str,
    signal_data: dict,
) -> None:
    """
    Append a trade entry record to the JSONL trade log.
    Used for building city-level forecast bias statistics over time.
    """
    import os
    os.makedirs(os.path.dirname(TRADE_LOG_FILE), exist_ok=True)
    record = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "market_id": market_id,
        "market_question": market_question,
        "city": city,
        "target_date": target_date,
        "forecast_temp": forecast_temp,
        "bucket_range": bucket_range,
        "price": price,
        "side": side,
        "amount_usd": amount,
        "shares": shares,
        "cost_usd": cost,
        "noaa_probability": noaa_probability,
        "bias_adjustment": bias_adjustment,
        "madis_bias_msg": madis_bias_msg,
        "unit": unit,
        "signal_source": source,
        "combined_confidence": signal_data.get("combined_confidence") if signal_data else None,
        "edge": signal_data.get("edge") if signal_data else None,
    }
    try:
        with open(TRADE_LOG_FILE, "a") as f:
            f.write(json.dumps(record) + "\n")
    except Exception:
        pass  # Non-critical, don't disrupt trading
_automaton_reported = False

# Polymarket constraints
MIN_SHARES_PER_ORDER = 3.0  # Minimum 3 shares per order (moderate config)
MIN_TICK_SIZE = 0.01  # Minimum tradeable price
# For small-budget testing ($1/position), the 5-share minimum means:
#   price <= $0.20 → $1.00 buys ≥5 shares (OK)
#   price > $0.20 → $1.00 buys <5 shares (blocked by min shares)
# This is a Polymarket CLOB constraint, not configurable.

# 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"]

# v1.2: EV Calculator + Kelly Criterion
EV_MIN_THRESHOLD = _config["ev_min_threshold"]
LONGTAIL_EV_PENALTY = _config["longtail_ev_penalty"]
LONGTAIL_PRICE_CUTOFF = _config["longtail_price_cutoff"]
USE_KELLY_SIZING = _config["use_kelly_sizing"]
KELLY_MULTIPLIER = _config["kelly_multiplier"]
NOAA_WIN_PROBABILITY = _config["noaa_win_probability"]

# v1.5: NOAA horizon-based probability calibration
# Source: NOAA National Weather Service forecast accuracy statistics
# D+1 ~ 88-92%, D+3 ~ 80-84%, D+5 ~ 70-76%, D+7 ~ 63-70%
# Accuracy degrades ~2-3% per additional forecast day
NOAA_HORIZON_ACCURACY = {
    0: 0.95,  # D+0 (today): nowcast, very high accuracy
    1: 0.90,  # D+1: next-day forecast
    2: 0.86,  # D+2: 2-day forecast
    3: 0.82,  # D+3: 3-day forecast
    4: 0.78,  # D+4: 4-day forecast
    5: 0.74,  # D+5: 5-day forecast
    6: 0.70,  # D+6: 6-day forecast
    7: 0.66,  # D+7: 7-day forecast (weekly)
}


def get_forecast_horizon(event_info: dict) -> int:
    """Compute forecast horizon in days from today to target date."""
    if not event_info or "date" not in event_info:
        return 1
    date_str = event_info["date"]
    try:
        target = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
    except ValueError:
        return 1
    now = datetime.now(timezone.utc)
    horizon = (target - now.replace(hour=0, minute=0, second=0, microsecond=0)).days
    return max(0, horizon)


def calibrate_noaa_probability(event_info: dict, base_prob: float = 0.85) -> float:
    """
    Calibrate NOAA win probability based on forecast horizon.

    NOAA forecast accuracy degrades with horizon (D+1 ~90%, D+5 ~74%, D+7 ~66%).
    We scale the base probability by the ratio of horizon accuracy to base accuracy,
    then blend with the base (horizon-specific values are more extreme; blending
    prevents over-confidence on very-short or very-long horizons).
    """
    horizon = get_forecast_horizon(event_info)
    horizon_accuracy = NOAA_HORIZON_ACCURACY.get(horizon, NOAA_HORIZON_ACCURACY[7])
    ratio = horizon_accuracy / base_prob
    calibrated = base_prob * ratio
    return round(min(0.99, max(0.01, calibrated)), 4)


# v1.4: Smart Money signals
USE_SMART_MONEY = _config.get("use_smart_money", True)
SMART_MONEY_MIN_SCORE = _config.get("smart_money_min_score", 7)

# Cache Smart Money signals per run (fetched once, reused for all markets)
_smart_money_cache = None


def _get_smart_money_signals():
    """Fetch and cache Smart Money signals for this run."""
    global _smart_money_cache
    if _smart_money_cache is None and USE_SMART_MONEY and SMART_MONEY_AVAILABLE:
        _smart_money_cache = fetch_smart_money_signals(min_score=SMART_MONEY_MIN_SCORE)
    return _smart_money_cache or []


# Rate limiting
MAX_TRADES_PER_RUN = _config["max_trades_per_run"]

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

# 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",
    },
    "Houston": {
        "lat": 29.7604,
        "lon": -95.3698,
        "name": "Houston (IAH)",
        "station": "KIAH",
    },
    "Phoenix": {
        "lat": 33.4484,
        "lon": -112.0740,
        "name": "Phoenix (PHX)",
        "station": "KPHX",
    },
    "Philadelphia": {
        "lat": 39.9526,
        "lon": -75.1652,
        "name": "Philadelphia",
        "station": "KPHL",
    },
    "Boston": {
        "lat": 42.3601,
        "lon": -71.0589,
        "name": "Boston (Logan)",
        "station": "KBOS",
    },
    "Denver": {
        "lat": 39.8561,
        "lon": -104.6737,
        "name": "Denver (DEN)",
        "station": "KDEN",
    },
    "Las Vegas": {
        "lat": 36.1699,
        "lon": -115.1398,
        "name": "Las Vegas (LAS)",
        "station": "KLAS",
    },
    "San Francisco": {
        "lat": 37.7749,
        "lon": -122.4194,
        "name": "San Francisco",
        "station": "KSFO",
    },
    "Los Angeles": {
        "lat": 33.9425,
        "lon": -118.4081,
        "name": "Los Angeles (LAX)",
        "station": "KLAX",
    },
    "San Diego": {
        "lat": 32.7338,
        "lon": -117.1897,
        "name": "San Diego (SAN)",
        "station": "KSAN",
    },
    "Washington DC": {
        "lat": 38.9072,
        "lon": -77.0369,
        "name": "Washington DC (Reagan)",
        "station": "KDCA",
    },
    "Nashville": {
        "lat": 36.1263,
        "lon": -86.7714,
        "name": "Nashville (BNA)",
        "station": "KBNA",
    },
    "Portland": {
        "lat": 45.5898,
        "lon": -122.5951,
        "name": "Portland (PDX)",
        "station": "KPDX",
    },
    "Minneapolis": {
        "lat": 44.8848,
        "lon": -93.2223,
        "name": "Minneapolis (MSP)",
        "station": "KMSP",
    },
    "Detroit": {
        "lat": 42.2162,
        "lon": -83.3554,
        "name": "Detroit (DTW)",
        "station": "KDTW",
    },
    "Tampa": {
        "lat": 27.9755,
        "lon": -82.4891,
        "name": "Tampa (TPA)",
        "station": "KTPA",
    },
    "Orlando": {
        "lat": 28.4312,
        "lon": -81.3081,
        "name": "Orlando (MCO)",
        "station": "KMCO",
    },
    "San Antonio": {
        "lat": 29.4241,
        "lon": -98.4936,
        "name": "San Antonio (SAT)",
        "station": "KSAT",
    },
    "Pittsburgh": {
        "lat": 40.4406,
        "lon": -79.9959,
        "name": "Pittsburgh (PIT)",
        "station": "KPIT",
    },
    "Cleveland": {
        "lat": 41.4993,
        "lon": -81.6944,
        "name": "Cleveland (CLE)",
        "station": "KCLE",
    },
    "Austin": {
        "lat": 30.1944,
        "lon": -97.6700,
        "name": "Austin (AUS)",
        "station": "KAUS",
    },
}

# 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"},
    "Taipei": {"lat": 25.0330, "lon": 121.5654, "tz": "Asia/Taipei"},
    "Shanghai": {"lat": 31.2304, "lon": 121.4737, "tz": "Asia/Shanghai"},
    "Beijing": {"lat": 39.9042, "lon": 116.4074, "tz": "Asia/Shanghai"},
    "Hong Kong": {"lat": 22.3193, "lon": 114.1694, "tz": "Asia/Hong_Kong"},
    "Singapore": {"lat": 1.3521, "lon": 103.8198, "tz": "Asia/Singapore"},
    "Sydney": {"lat": -33.8688, "lon": 151.2093, "tz": "Australia/Sydney"},
    "Melbourne": {"lat": -37.8136, "lon": 144.9631, "tz": "Australia/Melbourne"},
    "Paris": {"lat": 48.8566, "lon": 2.3522, "tz": "Europe/Paris"},
    "Berlin": {"lat": 52.5200, "lon": 13.4050, "tz": "Europe/Berlin"},
    "Madrid": {"lat": 40.4168, "lon": -3.7038, "tz": "Europe/Madrid"},
    "Rome": {"lat": 41.9028, "lon": 12.4964, "tz": "Europe/Rome"},
    "Amsterdam": {"lat": 52.3676, "lon": 4.9041, "tz": "Europe/Amsterdam"},
    "Toronto": {"lat": 43.6532, "lon": -79.3832, "tz": "America/Toronto"},
    "Vancouver": {"lat": 49.2827, "lon": -123.1207, "tz": "America/Vancouver"},
    "Shenzhen": {"lat": 22.5431, "lon": 114.0595, "tz": "Asia/Shanghai"},
    "Guangzhou": {"lat": 23.1291, "lon": 113.2644, "tz": "Asia/Shanghai"},
    "Chengdu": {"lat": 30.5728, "lon": 104.0668, "tz": "Asia/Shanghai"},
    "Mumbai": {"lat": 19.0760, "lon": 72.8777, "tz": "Asia/Kolkata"},
    "Delhi": {"lat": 28.7041, "lon": 77.1025, "tz": "Asia/Kolkata"},
    "Wuhan": {"lat": 30.5928, "lon": 114.3055, "tz": "Asia/Shanghai"},
}

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

MAX_RETRIES = 2
RETRY_DELAY = 1

# City -> preferred weather models (in order of preference)
CITY_MODEL_PREFERENCES = {
    # US cities - GFS best, HRRR for short range
    "NYC": ["gfs", "ecmwf_ifs", "icon"],
    "Chicago": ["gfs", "ecmwf_ifs", "icon"],
    "Seattle": ["gfs", "icon", "ecmwf_ifs"],
    "Los Angeles": ["gfs", "ecmwf_ifs", "icon"],
    "Miami": ["gfs", "ecmwf_ifs", "icon"],
    "Houston": ["gfs", "icon", "ecmwf_ifs"],
    # European cities - ECMWF/ICON better
    "London": ["ecmwf_ifs", "icon", "gfs"],
    "Paris": ["ecmwf_ifs", "icon", "gfs"],
    "Berlin": ["icon", "ecmwf_ifs", "gfs"],
    "Munich": ["icon", "ecmwf_ifs", "gfs"],
    "Madrid": ["ecmwf_ifs", "icon", "gfs"],
    "Rome": ["ecmwf_ifs", "icon", "gfs"],
    "Amsterdam": ["ecmwf_ifs", "icon", "gfs"],
    # Asian cities
    "Tokyo": ["gfs", "ecmwf_ifs", "icon"],
    "Seoul": ["gfs", "kma", "ecmwf_ifs"],
    "Beijing": ["gfs", "cma_grapes", "ecmwf_ifs"],
    "Shanghai": ["gfs", "cma_grapes", "ecmwf_ifs"],
    "Hong Kong": ["gfs", "ecmwf_ifs", "icon"],
    "Singapore": ["gfs", "ecmwf_ifs", "icon"],
    "Mumbai": ["gfs", "ecmwf_ifs", "icon"],
    "Delhi": ["gfs", "ecmwf_ifs", "icon"],
    # Australian cities
    "Sydney": ["gfs", "bom_access_global", "ecmwf_ifs"],
    "Melbourne": ["gfs", "bom_access_global", "ecmwf_ifs"],
    # Default
    "default": ["gfs", "ecmwf_ifs", "icon"],
}


def _retry_with_backoff(func, *args, **kwargs):
    """Execute func with exponential backoff retry logic."""
    import time

    last_error = None
    for attempt in range(MAX_RETRIES):
        try:
            result = func(*args, **kwargs)
            if result:
                return result
        except Exception as e:
            last_error = e
        if attempt < MAX_RETRIES - 1:
            time.sleep(RETRY_DELAY * (attempt + 1))
    return None


def get_openmeteo_forecast(
    city: str, model: str = None, use_ensemble: bool = False
) -> dict:
    """Get Open-Meteo forecast for a city with specific model or ensemble.

    Args:
        city: City name to look up in INTERNATIONAL_LOCATIONS
        model: Specific model to use (gfs, ecmwf_ifs, icon, etc.). If None, uses city preference.
        use_ensemble: If True, fetch ensemble data with multiple models for confidence scoring.

    Returns:
        dict with date -> {"high": temp, "low": temp} in Celsius, or
        dict with ensemble data if use_ensemble=True
    """
    loc = INTERNATIONAL_LOCATIONS.get(city)
    if not loc:
        return {}

    if model is None:
        prefs = CITY_MODEL_PREFERENCES.get(city, CITY_MODEL_PREFERENCES["default"])
        model = prefs[0]

    base_url = OPEN_METEO_ENSEMBLE_BASE if use_ensemble else OPEN_METEO_BASE

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

    if use_ensemble:
        prefs = CITY_MODEL_PREFERENCES.get(city, CITY_MODEL_PREFERENCES["default"])
        models_param = ",".join(prefs[:4])
        params += f"&models={models_param}"

    url = base_url + params
    try:
        with urlopen(url, timeout=20) as resp:
            data = json.loads(resp.read().decode())
    except Exception as e:
        print(f"  Open-Meteo ({model or 'auto'}) error for {city}: {e}")
        return {}

    if use_ensemble and "model_run" in data:
        return _parse_ensemble_forecast(data)
    else:
        return _parse_openmeteo_daily(data)


def _parse_openmeteo_daily(data: dict) -> dict:
    """Parse Open-Meteo daily forecast data."""
    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": round(h) if h is not None else None,
            "low": round(l) if l is not None else None,
        }
    return forecasts


def _parse_ensemble_forecast(data: dict) -> dict:
    """Parse Open-Meteo ensemble forecast data with model runs.

    Returns dict with date -> {
        "high": median_high,
        "low": median_low,
        "high_std": std_dev,
        "low_std": std_dev,
        "model_count": number of models,
        "models": {model_name: {"high": temp, "low": temp}}
    }
    """
    daily = data.get("daily", {})
    model_runs = data.get("model_run", [])

    if not model_runs or not daily:
        return {}

    dates = daily.get("time", [])
    forecasts = {}

    for date_idx, d in enumerate(dates):
        highs = []
        lows = []
        model_temps = {}

        for run in model_runs:
            run_data = run.get("daily", {})
            h = run_data.get("temperature_2m_max", [None] * len(dates))
            l = run_data.get("temperature_2m_min", [None] * len(dates))

            model_name = run.get("model", "unknown")
            high_val = h[date_idx] if date_idx < len(h) else None
            low_val = l[date_idx] if date_idx < len(l) else None

            if high_val is not None:
                highs.append(high_val)
                lows.append(low_val)
                model_temps[model_name] = {
                    "high": round(high_val),
                    "low": round(low_val),
                }

        if highs:
            import statistics

            forecasts[d] = {
                "high": round(statistics.median(highs)),
                "low": round(statistics.median(lows)),
                "high_std": round(statistics.stdev(highs), 1) if len(highs) > 1 else 0,
                "low_std": round(statistics.stdev(lows), 1) if len(lows) > 1 else 0,
                "model_count": len(highs),
                "models": model_temps,
            }
        else:
            forecasts[d] = {"high": None, "low": None}

    return forecasts


def get_openmeteo_multi_model(city: str) -> dict:
    """Fetch forecasts from multiple models and compare.

    Returns dict with date -> {
        "high": best_estimate,
        "low": best_estimate,
        "all_highs": [temps from all models],
        "all_lows": [temps from all models],
        "disagreement": max - min (uncertainty measure)
    }
    """
    loc = INTERNATIONAL_LOCATIONS.get(city)
    if not loc:
        return {}

    prefs = CITY_MODEL_PREFERENCES.get(city, CITY_MODEL_PREFERENCES["default"])

    all_forecasts = {}
    for model in prefs[:3]:
        forecast = get_openmeteo_forecast(city, model=model)
        if forecast:
            all_forecasts[model] = forecast

    if not all_forecasts:
        return {}

    import statistics

    all_dates = set()
    for f in all_forecasts.values():
        all_dates.update(f.keys())

    combined = {}
    for date in sorted(all_dates):
        highs = []
        lows = []
        for model, forecast in all_forecasts.items():
            if date in forecast and forecast[date]["high"] is not None:
                highs.append(forecast[date]["high"])
                lows.append(forecast[date]["low"])

        if highs:
            combined[date] = {
                "high": round(statistics.median(highs)),
                "low": round(statistics.median(lows)),
                "all_highs": highs,
                "all_lows": lows,
                "disagreement": max(highs) - min(highs) if len(highs) > 1 else 0,
                "model_count": len(highs),
            }
        else:
            combined[date] = {"high": None, "low": None}

    return combined


def get_madis_observation(location: str) -> dict:
    """Get real-time surface observation from MADIS (NOAA/MesoWest).

    MADIS provides current conditions from ASOS/AWOS stations.
    Useful for verifying today's actual temperature.
    """
    if location not in LOCATIONS:
        return {}

    loc = LOCATIONS[location]
    station_id = loc.get("station")
    if not station_id:
        return {}

    url = f"https://api.weather.gov/stations/{station_id}/observations/latest"
    headers = {
        "User-Agent": "SimmerWeatherSkill/1.0 (https://simmer.markets)",
        "Accept": "application/geo+json",
    }

    try:
        req = Request(url, headers=headers)
        with urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read().decode())
    except Exception as e:
        return {}

    if not data or "properties" not in data:
        return {}

    props = data["properties"]
    temp_c = props.get("temperature", {}).get("value")
    if temp_c is None:
        return {}

    temp_f = round(temp_c * 9 / 5 + 32)
    obs_time = props.get("timestamp", "")

    return {
        "temp_f": temp_f,
        "temp_c": round(temp_c, 1),
        "station": station_id,
        "time": obs_time,
        "source": "MADIS",
    }


def get_openmeteo_current(city: str) -> dict:
    """Get current conditions from Open-Meteo for any city."""
    loc = INTERNATIONAL_LOCATIONS.get(city)
    if not loc:
        return {}

    params = (
        f"?latitude={loc['lat']}&longitude={loc['lon']}"
        f"&current=temperature_2m"
        f"&temperature_unit=celsius"
    )
    url = OPEN_METEO_BASE + params

    try:
        with urlopen(url, timeout=15) as resp:
            data = json.loads(resp.read().decode())
    except Exception:
        return {}

    current = data.get("current", {})
    temp_c = current.get("temperature_2m")
    if temp_c is None:
        return {}

    return {
        "temp_c": round(temp_c, 1),
        "temp_f": round(temp_c * 9 / 5 + 32),
        "source": "Open-Meteo",
    }


def _fetch_with_retry(url: str, headers: dict = None, timeout: int = 30) -> dict:
    """Fetch JSON with retry logic for network errors."""
    import time

    for attempt in range(MAX_RETRIES):
        try:
            req = Request(url, headers=headers or {})
            with urlopen(req, timeout=timeout) as resp:
                return json.loads(resp.read().decode())
        except HTTPError as e:
            if e.code == 429:
                wait_time = int(e.headers.get("Retry-After", 60))
                print(f"  Rate limited, waiting {wait_time}s...")
                time.sleep(wait_time)
            else:
                print(f"  HTTP Error {e.code}: {url}")
                if attempt < MAX_RETRIES - 1:
                    time.sleep(RETRY_DELAY * (attempt + 1))
        except URLError as e:
            print(f"  URL Error: {e.reason}")
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_DELAY * (attempt + 1))
        except Exception as e:
            print(f"  Error fetching {url}: {e}")
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_DELAY * (attempt + 1))

    return None


def get_noaa_forecast_with_fallback(location: str) -> dict:
    """Get NOAA forecast with automatic fallback to Open-Meteo.

    Tries NOAA up to 3 times, then falls back to Open-Meteo GFS.
    """
    if location not in LOCATIONS:
        return {}

    noaa_result = _retry_with_backoff(get_noaa_forecast, location)
    if noaa_result:
        return noaa_result

    print(f"  NOAA unavailable for {location}, falling back to Open-Meteo GFS...")

    city = location
    if city not in INTERNATIONAL_LOCATIONS:
        INTERNATIONAL_LOCATIONS[city] = LOCATIONS[city].copy()
        INTERNATIONAL_LOCATIONS[city]["tz"] = "America/New_York"

    openmeteo_result = get_openmeteo_forecast(city, model="gfs")
    if openmeteo_result:
        return openmeteo_result

    print(f"  Open-Meteo GFS also failed for {location}")
    return {}


def get_weather_forecast(city: str) -> dict:
    """
    Unified weather forecast for any city with multi-source fallback.

    Priority chain:
    1. NOAA (US cities) with retry
    2. Open-Meteo with city-preferred model
    3. Open-Meteo multi-model median

    Returns {} if city is not supported or all sources unavailable.
    """
    if city in LOCATIONS:
        return get_noaa_forecast_with_fallback(city)
    elif city in INTERNATIONAL_LOCATIONS:
        result = get_openmeteo_forecast(city)
        if result:
            return result

        print(f"  Primary model failed for {city}, trying multi-model...")
        multi = get_openmeteo_multi_model(city)
        if multi:
            return multi

        return {}
    else:
        noaa_result = get_noaa_forecast_with_fallback(city)
        if noaa_result:
            return noaa_result
        return get_openmeteo_forecast(city)


def _extract_today_forecast(forecasts: dict) -> dict:
    """Extract today's forecast from date-keyed forecast dict."""
    if not forecasts:
        return {}

    today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")

    if today_str in forecasts:
        return forecasts[today_str]

    dates = sorted(forecasts.keys())
    if dates:
        return forecasts[dates[0]]

    return {}


def get_weather_with_confidence(city: str) -> dict:
    """
    Get weather forecast with confidence scoring from ensemble models.

    Returns dict with forecast + confidence metrics:
    {
        "high": median_high,
        "low": median_low,
        "confidence": 0.0-1.0 based on model agreement,
        "disagreement": degrees of uncertainty,
        "model_count": number of models used,
        "source": "ensemble" or "single"
    }
    """
    if city in LOCATIONS:
        basic = get_noaa_forecast_with_fallback(city)
        if basic:
            madis = get_madis_observation(city)
            today = _extract_today_forecast(basic)
            result = {
                "high": today.get("high"),
                "low": today.get("low"),
                "confidence": 0.85,
                "source": "NOAA",
                "all_forecasts": basic,
            }
            if madis:
                result["current"] = madis
            return result

    basic = get_openmeteo_forecast(city)
    if not basic:
        return {"high": None, "low": None, "confidence": 0, "source": "none"}

    multi = get_openmeteo_multi_model(city)
    if multi:
        today_key = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        if today_key not in multi:
            today_key = sorted(multi.keys())[0] if multi else None

        if today_key and multi[today_key].get("model_count", 0) > 1:
            disagreement = multi[today_key].get("disagreement", 0)
            if disagreement <= 2:
                confidence = 0.90
            elif disagreement <= 4:
                confidence = 0.80
            elif disagreement <= 6:
                confidence = 0.70
            else:
                confidence = 0.60

            return {
                "high": multi[today_key].get("high"),
                "low": multi[today_key].get("low"),
                "confidence": confidence,
                "source": "multi_model",
                "model_count": multi[today_key].get("model_count", 1),
                "disagreement": disagreement,
                "all_forecasts": multi,
            }

    today = _extract_today_forecast(basic)
    return {
        "high": today.get("high"),
        "low": today.get("low"),
        "confidence": 0.70,
        "source": "single_model",
        "model_count": 1,
        "all_forecasts": basic,
    }


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

MODEL_HISTORICAL_ACCURACY = {
    "gfs": {"bias": 0.5, "mae": 2.1, "样本量": "high"},
    "ecmwf_ifs": {"bias": -0.3, "mae": 1.8, "样本量": "high"},
    "icon": {"bias": 0.2, "mae": 1.9, "样本量": "medium"},
    "jma": {"bias": 0.4, "mae": 2.0, "样本量": "medium"},
    "kma": {"bias": 0.3, "mae": 2.2, "样本量": "low"},
    "gem": {"bias": 0.6, "mae": 2.4, "样本量": "medium"},
    "bom_access_global": {"bias": 0.5, "mae": 2.3, "样本量": "low"},
    "cma_grapes": {"bias": 0.8, "mae": 2.5, "样本量": "low"},
    "meteofrance": {"bias": 0.1, "mae": 1.9, "样本量": "medium"},
    "ukmo": {"bias": 0.2, "mae": 2.0, "样本量": "medium"},
}


def get_historical_calibration(
    city: str, target_date: str, forecast_temp: float
) -> dict:
    """Get historical accuracy data for calibrating forecast confidence.

    Uses Open-Meteo's historical forecast API to compare past forecasts
    against actual observations for this city/model combination.

    Returns dict with:
    {
        "bias_corrected_temp": temperature after removing average bias,
        "confidence_adjustment": multiplier for probability (0.5-1.0),
        "historical_mae": mean absolute error in degrees,
        "sample_count": number of historical comparisons
    }
    """
    loc = INTERNATIONAL_LOCATIONS.get(city)
    if not loc:
        return {
            "bias_corrected_temp": forecast_temp,
            "confidence_adjustment": 0.80,
            "historical_mae": 2.0,
        }

    import time
    from datetime import datetime as dt

    try:
        target = dt.strptime(target_date, "%Y-%m-%d")
    except ValueError:
        return {
            "bias_corrected_temp": forecast_temp,
            "confidence_adjustment": 0.80,
            "historical_mae": 2.0,
        }

    past_date = target - timedelta(days=7)
    if past_date < dt.now():
        past_date = dt.now() - timedelta(days=1)

    params = (
        f"?latitude={loc['lat']}&longitude={loc['lon']}"
        f"&start_date={past_date.strftime('%Y-%m-%d')}"
        f"&end_date={past_date.strftime('%Y-%m-%d')}"
        f"&daily=temperature_2m_max,temperature_2m_min"
        f"&temperature_unit=celsius"
        f"&timezone={loc['tz'].replace('/', '%2F')}"
        f"&past_days=1"
    )

    url = OPEN_METEO_HISTORICAL_BASE + params
    try:
        with urlopen(url, timeout=20) as resp:
            data = json.loads(resp.read().decode())
    except Exception:
        return {
            "bias_corrected_temp": forecast_temp,
            "confidence_adjustment": 0.80,
            "historical_mae": 2.0,
        }

    daily = data.get("daily", {})
    actual_highs = daily.get("temperature_2m_max", [])
    actual_lows = daily.get("temperature_2m_min", [])

    if actual_highs and actual_highs[0] is not None:
        actual_high = actual_highs[0]
        error = abs(forecast_temp - actual_high)

        if error < 3:
            confidence = 0.90
        elif error < 5:
            confidence = 0.75
        else:
            confidence = 0.60

        return {
            "bias_corrected_temp": forecast_temp,
            "confidence_adjustment": confidence,
            "historical_mae": round(error, 1),
            "actual_vs_forecast": {
                "actual": actual_high,
                "forecast": forecast_temp,
                "error": round(error, 1),
            },
        }

    return {
        "bias_corrected_temp": forecast_temp,
        "confidence_adjustment": 0.80,
        "historical_mae": 2.0,
    }


def get_ensemble_agreement_score(city: str, date: str) -> dict:
    """Calculate agreement score from multiple forecast models.

    Returns:
    {
        "agreement_score": 0.0-1.0 (1.0 = all models agree),
        "spread_degrees": max_temp - min_temp across models,
        "model_count": number of models,
        "dominant_outlook": "warm" or "cold" or "uncertain"
    }
    """
    multi = get_openmeteo_multi_model(city)
    if not multi or date not in multi:
        return {"agreement_score": 0.5, "spread_degrees": 0, "model_count": 0}

    day_data = multi[date]
    highs = day_data.get("all_highs", [])
    if not highs or len(highs) < 2:
        return {
            "agreement_score": 0.7,
            "spread_degrees": 0,
            "model_count": len(highs) if highs else 0,
        }

    spread = max(highs) - min(highs)
    median_high = statistics.median(highs)

    if spread <= 2:
        agreement = 0.95
    elif spread <= 4:
        agreement = 0.80
    elif spread <= 6:
        agreement = 0.65
    else:
        agreement = 0.50

    warm_count = sum(1 for h in highs if h >= median_high)
    dominant = "uncertain"
    if warm_count == len(highs):
        dominant = "warm"
    elif warm_count == 0:
        dominant = "cold"

    return {
        "agreement_score": agreement,
        "spread_degrees": spread,
        "model_count": len(highs),
        "dominant_outlook": dominant,
    }


def fetch_json(url, headers=None, timeout=15):
    """Fetch JSON from URL with error handling."""
    try:
        req = Request(url, headers=headers or {})
        with urlopen(req, timeout=timeout) 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, timeout=10)

    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, timeout=10)
    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 = {
        # US cities (NOAA)
        "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",
        "ft. lauderdale": "Miami",
        "fort lauderdale": "Miami",
        "houston": "Houston",
        "phoenix": "Phoenix",
        "philadelphia": "Philadelphia",
        "boston": "Boston",
        "denver": "Denver",
        "las vegas": "Las Vegas",
        "san francisco": "San Francisco",
        "los angeles": "Los Angeles",
        "san diego": "San Diego",
        "washington dc": "Washington DC",
        "washington, dc": "Washington DC",
        "nashville": "Nashville",
        "portland": "Portland",
        "minneapolis": "Minneapolis",
        "detroit": "Detroit",
        "tampa": "Tampa",
        "orlando": "Orlando",
        # International cities (Open-Meteo)
        "tel aviv": "Tel Aviv",
        "taipei": "Taipei",
        "shanghai": "Shanghai",
        "beijing": "Beijing",
        "hong kong": "Hong Kong",
        "singapore": "Singapore",
        "munich": "Munich",
        "london": "London",
        "tokyo": "Tokyo",
        "seoul": "Seoul",
        "ankara": "Ankara",
        "lucknow": "Lucknow",
        "wellington": "Wellington",
        "sydney": "Sydney",
        "melbourne": "Melbourne",
        "paris": "Paris",
        "berlin": "Berlin",
        "madrid": "Madrid",
        "rome": "Rome",
        "amsterdam": "Amsterdam",
        "toronto": "Toronto",
        "vancouver": "Vancouver",
        # v1.5: Added cities (in INTERNATIONAL_LOCATIONS but missing from aliases)
        "shenzhen": "Shenzhen",
        "guangzhou": "Guangzhou",
        "chengdu": "Chengdu",
        "mumbai": "Mumbai",
        "bombay": "Mumbai",
        "delhi": "Delhi",
        "new delhi": "Delhi",
        "wuhan": "Wuhan",
        # US cities
        "austin": "Austin",
        "austin bergstrom": "Austin",
    }

    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}


# =============================================================================
# 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 broadly for any temperature-related market on Polymarket,
    then imports any that aren't already in Simmer. Cities are auto-detected
    from the imported markets and matched against NOAA/Open-Meteo forecast data.

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

    # Broad search terms to capture all temperature markets
    search_terms = [
        "temperature",
        "highest temperature",
        "lowest temperature",
        "high temp",
        "will it be hot",
        "weather",
    ]

    for term in search_terms:
        try:
            results = client.list_importable_markets(
                q=term, venue="polymarket", min_volume=500, limit=30
            )
        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 and "temp" 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,
        )
        return {
            "success": result.success,
            "trade_id": result.trade_id,
            "shares_bought": result.shares_bought,
            "shares": result.shares_bought,
            "cost": result.cost,
            "new_price": result.new_price,
            "market_id": result.market_id,
            "side": result.side,
            "error": result.error,
            "simulated": result.simulated,
        }
    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,
        )
        return {
            "success": result.success,
            "trade_id": result.trade_id,
            "error": result.error,
            "simulated": result.simulated,
        }
    except Exception as e:
        return {"error": str(e)}


def get_positions() -> list:
    """Get current positions as list of dicts."""
    try:
        positions = get_client().get_positions()
        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,
    price: float = None,
    win_prob: float = None,
) -> float:
    """Calculate position size based on Kelly Criterion or fallback to default.

    Args:
        default_size: Maximum position size from config
        smart_sizing: Whether to use smart sizing (vs fixed)
        price: Market price (for Kelly calculation)
        win_prob: Estimated win probability (for Kelly calculation)
    """
    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

    # v1.2: Use Kelly Criterion if enabled and price/win_prob provided
    if USE_KELLY_SIZING and price is not None and win_prob is not None:
        kelly_pos = quarter_kelly_position(
            price=price,
            win_prob=win_prob,
            bankroll=balance,
            max_pos=default_size,
        )
        kelly_pos = max(kelly_pos, 1.0)  # Minimum $1
        print(
            f"  💡 Kelly sizing: ${kelly_pos:.2f} (Kelly={KELLY_MULTIPLIER:.0%} of ${balance:.2f} balance)"
        )
        return kelly_pos

    # Fallback: fixed percentage sizing
    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)}")

            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


# =============================================================================
# Bayesian Position Monitoring (v1.3)
# =============================================================================


def monitor_positions(positions: list, config: dict) -> list:
    """
    Check each position for Bayesian update trigger.
    Returns list of positions with updated probabilities.

    Integration: When PnL drops below -10%, Bayesian update recalculates
    probability. If updated_prob < initial_prob - 15%, position is closed.
    Otherwise, position is reduced to new Kelly size with updated probability.
    """
    updated_positions = []
    for pos in positions:
        current_price = pos.get("current_price", pos.get("last_price", 0))
        entry_price = pos.get("entry_price", 0)

        if entry_price <= 0:
            updated_positions.append(pos)
            continue

        # Compute PnL percentage
        pnl_pct = (current_price - entry_price) / entry_price

        # Check if we should trigger Bayesian update
        if not pos.get("bayesian_updated", False) and should_update_probability(
            pnl_pct
        ):
            # Compute likelihood ratio from price movement
            lr = compute_likelihood_ratio(current_price, entry_price)

            # Update probability using Bayes
            initial_prob = pos.get("initial_prob", pos.get("current_prob", 0.5))
            new_prob = bayesian_update(initial_prob, lr)

            # Check if should close
            if should_close_position(new_prob, initial_prob):
                pos["action"] = "close"
                pos["close_reason"] = "bayesian_update"
                print(
                    f"  🔄 Bayesian close: {pos.get('question', 'Unknown')[:40]}... PnL={pnl_pct:.1%}, prob dropped {initial_prob:.0%}→{new_prob:.0%}"
                )
            else:
                # Reduce to new Kelly size with updated probability
                kelly = kelly_fraction(current_price, new_prob)
                quarter_kelly = kelly / 4
                bankroll = pos.get("bankroll", 1000)
                max_pos = pos.get("max_position", 100)
                new_size = bankroll * quarter_kelly
                pos["current_prob"] = new_prob
                pos["adjusted_size"] = min(new_size, max_pos)
                pos["action"] = "reduce"
                pos["bayesian_updated"] = True
                print(
                    f"  🔄 Bayesian reduce: {pos.get('question', 'Unknown')[:40]}... PnL={pnl_pct:.1%}, prob {initial_prob:.0%}→{new_prob:.0%}, size→${pos['adjusted_size']:.2f}"
                )

            updated_positions.append(pos)
        else:
            updated_positions.append(pos)

    return updated_positions


# =============================================================================
# Maker/Taker Market Detection (v1.3)
# =============================================================================


def detect_market_condition(market: dict = None) -> MarketCondition:
    """
    Extract MarketCondition from market data for strategy determination.

    For weather markets, we use a simplified approach:
    - spread_bps estimated from recent price stability
    - volatility estimated from price history if available

    Args:
        market: Optional market dict with additional context

    Returns:
        MarketCondition with spread_bps and volatility
    """
    # Default values for weather markets (typically tighter spreads)
    # Weather markets on Polymarket tend to have reasonable liquidity
    default_spread_bps = 30.0  # Typical 30 bps spread for weather
    default_volatility = 0.40  # Moderate volatility assumption

    # If market data available, try to extract better estimates
    if market:
        # Use fee_rate as proxy for spread (higher fees = wider spreads)
        fee_rate = market.get("fee_rate_bps", 0)
        if fee_rate > 0:
            # Convert fee rate to spread estimate
            default_spread_bps = min(fee_rate * 2, 100.0)

        # Could use historical price data for volatility estimation
        # For now, use the default

    return MarketCondition(spread_bps=default_spread_bps, volatility=default_volatility)


def decide_trade(
    market: dict,
    condition: MarketCondition,
    config: dict,
    price: float,
    win_prob: float,
) -> dict:
    """
    Decide trade parameters based on market condition and Kelly sizing.

    Args:
        market: Market data dict
        condition: Current MarketCondition
        config: Configuration dict
        price: Market price
        win_prob: Estimated win probability

    Returns:
        Dict with trade parameters including maker_pct, taker_pct, strategy_mode
    """
    mode = determine_strategy_mode(condition)
    maker_pct, taker_pct = get_maker_taker_allocation(mode)

    return {
        "mode": mode,
        "maker_pct": maker_pct,
        "taker_pct": taker_pct,
        "strategy_mode": mode.value,
        "price": price,
        "win_prob": win_prob,
    }


# =============================================================================
# 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,
):
    """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:       Auto-detected from markets (NOAA + Open-Meteo)")
    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'}")

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

        # v1.5: Skip city filter — instead check if we can get forecast data for this city
        if location not in forecast_cache:
            log(f"\n📍 {location} {date_str} ({metric} temp)")
            log(f"  🌐 Detected city: {location} — fetching weather data...")

            weather_data = get_weather_with_confidence(location)
            forecast_cache[location] = {
                "forecasts": weather_data.get("all_forecasts", {}),
                "confidence": weather_data.get("confidence", 0.80),
                "source": weather_data.get("source", "unknown"),
                "model_count": weather_data.get("model_count", 1),
                "disagreement": weather_data.get("disagreement", 0),
                "models": weather_data.get("models", {}),
            }

        cached = forecast_cache[location]
        forecasts = cached["forecasts"]
        if not forecasts:
            log(f"  ⏭️  No forecast data available for {location} — skipping")
            continue

        day_forecast = forecasts.get(date_str, {})
        forecast_temp = day_forecast.get(metric) or day_forecast.get("high")

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

        is_international = location in INTERNATIONAL_LOCATIONS
        unit_label = "°C" if is_international else "°F"
        source_label = cached.get(
            "source", "Open-Meteo" if is_international else "NOAA"
        ).upper()
        log(
            f"  📊 [{source_label}] forecast: {forecast_temp}{unit_label} "
            f"(confidence: {cached.get('confidence', 0):.0%})"
        )
        if cached.get("model_count", 1) > 1:
            log(
                f"     Ensemble: {cached['model_count']} models, "
                f"disagreement: {cached.get('disagreement', 0):.1f}°"
            )

        if cached.get("confidence", 0) < 0.6:
            log(
                f"  ⚠️  Low confidence ({cached.get('confidence', 0):.0%}) — consider reducing position"
            )

        if BINARY_ONLY and len(event_markets) > 2:
            log(
                f"  ⏭️  Range event ({len(event_markets)} buckets) — binary_only=true, skipping"
            )
            continue

        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
        # v1.5: NOAA forecasts degrade with horizon (D+1 ~90%, D+5 ~74%)
        # v2.0: Multi-source: blend with ensemble confidence when available
        base_prob = calibrate_noaa_probability(event_info, NOAA_WIN_PROBABILITY)

        ensemble_confidence = forecasts.get("confidence", 0.80)
        if ensemble_confidence < 0.6:
            noaa_probability = base_prob * 0.9
            log(
                f"  📉 Low ensemble confidence - reduced probability to {noaa_probability:.2f}"
            )
        elif ensemble_confidence > 0.8:
            noaa_probability = min(0.99, base_prob * 1.05)
            log(
                f"  📈 High ensemble confidence - boosted probability to {noaa_probability:.2f}"
            )
        else:
            noaa_probability = base_prob

        # v2.1: MADIS airport-observation bias correction (Scheme 1)
        # Airport实测 vs 网格预报 → 实时方向偏差修正
        raw_probability = noaa_probability  # 保存修正前的值用于 trade_log
        madis_bias_msg = ""
        madis_obs = get_madis_observation(location)
        if madis_obs:
            noaa_probability, madis_msg = apply_madis_bias_correction(
                forecast_temp, madis_obs, event_info, noaa_probability, unit_label[-1]
            )
            madis_bias_msg = madis_msg
            if madis_msg:
                log(f"  🌡️  {madis_msg}")

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

        # Check price trend
        trend_bonus = ""
        if use_trends:
            history = get_price_history(market_id)
            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)"

        # v1.2: EV check + Longtail filter
        try:
            raw_ev = calculate_ev(price, noaa_probability)
            is_longtail = price < LONGTAIL_PRICE_CUTOFF

            if is_longtail:
                adjusted_ev = longtail_ev_adjustment(price, raw_ev)
                effective_ev = adjusted_ev
                ev_label = f"(longtail adjusted: raw EV={raw_ev:.3f}, adjusted={adjusted_ev:.3f})"
            else:
                effective_ev = raw_ev
                ev_label = f"(EV={raw_ev:.3f})"

            if effective_ev <= EV_MIN_THRESHOLD:
                log(
                    f"  ⏸️  EV check failed {ev_label} - min EV={EV_MIN_THRESHOLD:.3f}, skipping"
                )
                skip_reasons.append("ev too low")
                continue
            log(f"  💡 EV check passed {ev_label}")
        except ZeroDivisionError:
            log(f"  ⏸️  EV calculation error (price={price}) - skipping")
            skip_reasons.append("ev calc error")
            continue

        # v1.3: Detect Maker/Taker strategy mode from market conditions
        market_condition = detect_market_condition(matching_market)
        strategy = decide_trade(
            matching_market, market_condition, _config, price, noaa_probability
        )
        log(
            f"  📊 Strategy: {strategy['mode'].value} ({strategy['maker_pct']:.0%} Maker / {strategy['taker_pct']:.0%} Taker)"
        )

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

            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

            # v1.4: Merge Smart Money signals if available
            sm_signals = _get_smart_money_signals()
            market_slug = (
                matching_market.get("slug") or matching_market.get("question", "")[:50]
            )
            sm_signal = get_signal_for_market(sm_signals, market_slug)
            base_signal_data = {
                "edge": round(edge, 4),
                "confidence": noaa_probability,
                "signal_source": cached.get("source", "noaa_forecast"),
                "forecast_temp": forecast_temp,
                "bucket_range": outcome_name,
                "market_price": round(price, 4),
                "threshold": ENTRY_THRESHOLD,
                "ensemble_confidence": cached.get("confidence", 0.80),
                "model_count": cached.get("model_count", 1),
                "disagreement": cached.get("disagreement", 0),
            }
            signal_data = merge_signal_data(base_signal_data, sm_signal)
            if sm_signal:
                log(
                    f"  🐋 Smart Money: {sm_signal['smart_money_signal']} "
                    f"(score={sm_signal['smart_money_score']}/10, "
                    f"combined_confidence={signal_data.get('combined_confidence', 0):.2f})"
                )

            # Pre-trade circuit breaker check
            should_trade, cb_reason = check_circuit_breaker(
                os.path.dirname(__file__),
                threshold=CB_THRESHOLD,
                cooldown_hours=CB_COOLDOWN_HOURS,
            )
            if not should_trade:
                log(f"  {cb_reason}", force=True)
                skip_reasons.append("circuit breaker")
                continue
            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_data,
            )

            # Post-trade logging (success or failure)
            write_trade_log(
                result,
                SKILL_SLUG,
                TRADE_SOURCE,
                signal_data=signal_data,
            )

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

                # v2.1: Append to trade log for city-level bias learning (Scheme 2)
                log_trade_entry(
                    market_id=market_id,
                    market_question=matching_market.get("question", ""),
                    city=location,
                    target_date=date_str,
                    forecast_temp=forecast_temp,
                    bucket_range=outcome_name,
                    price=price,
                    side="YES",
                    amount=position_size,
                    shares=shares,
                    cost=result.get("cost", 0),
                    noaa_probability=raw_probability,
                    bias_adjustment=round(raw_probability - noaa_probability, 4),
                    madis_bias_msg=madis_bias_msg,
                    unit=unit_label[-1],
                    source=cached.get("source", "unknown"),
                    signal_data=signal_data,
                )

                # 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])

            # Update circuit breaker state
            update_circuit_breaker(
                os.path.dirname(__file__),
                result.get("success", False),
                result.get("simulated", False),
                threshold=CB_THRESHOLD,
                cooldown_hours=CB_COOLDOWN_HOURS,
                error=result.get("error", ""),
            )
        else:
            log(
                f"  ⏸️  Price ${price:.2f} above threshold ${ENTRY_THRESHOLD:.2f} - skip"
            )

    # v1.3: Bayesian position monitoring - check for PnL-triggered probability updates
    current_positions = get_positions()
    if current_positions:
        monitored = monitor_positions(current_positions, _config)
        # Apply Bayesian-driven actions (close/reduce) before exit check
        for pos in monitored:
            if (
                pos.get("action") == "close"
                and pos.get("close_reason") == "bayesian_update"
            ):
                # Bayesian update triggered close - would sell here if live
                pass  # Exit handling done via check_exit_opportunities
            elif pos.get("action") == "reduce":
                # Bayesian update triggered reduce - log for now
                pass  # Position size adjustment would happen on next trade

    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",
        dest="live",
        action="store_true",
        help="Execute real USDC trades on Polymarket (requires TRADING_VENUE=polymarket). "
        "Not needed for $SIM trading (TRADING_VENUE=sim is always active).",
    )
    parser.add_argument(
        "--dry-run",
        dest="dry_run",
        action="store_true",
        help="Simulated trades only (overrides default when TRADING_VENUE=polymarket)",
    )
    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(
        "--quiet",
        "-q",
        action="store_true",
        help="Only output when trades execute or errors occur (ideal for high-frequency runs)",
    )
    parser.add_argument(
        "--resume",
        action="store_true",
        help="Force resume trading (clear circuit breaker)",
    )
    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 ALL module-level vars (must match names defined at module top)
            globals().update(
                {
                    "ENTRY_THRESHOLD": _config["entry_threshold"],
                    "EXIT_THRESHOLD": _config["exit_threshold"],
                    "MAX_POSITION_USD": _config["max_position_usd"],
                    "SMART_SIZING_PCT": _config["sizing_pct"],
                    "MAX_TRADES_PER_RUN": _config["max_trades_per_run"],
                    "BINARY_ONLY": _config["binary_only"],
                    "EV_MIN_THRESHOLD": _config.get("ev_min_threshold", 0.0),
                    "LONGTAIL_EV_PENALTY": _config.get("longtail_ev_penalty", 0.20),
                    "LONGTAIL_PRICE_CUTOFF": _config.get("longtail_price_cutoff", 0.20),
                    "CB_THRESHOLD": _config.get("circuit_breaker_threshold", 3),
                    "CB_COOLDOWN_HOURS": _config.get("circuit_breaker_cooldown", 6),
                }
            )
            _locations_str = _config["locations"]
            globals()["ACTIVE_LOCATIONS"] = [
                loc.strip().upper() for loc in _locations_str.split(",") if loc.strip()
            ]

    # Handle --resume flag
    if args.resume:
        from trade_performance import check_circuit_breaker
        from pathlib import Path

        breaker_path = Path(__file__).parent / "circuit_breaker.json"
        if breaker_path.exists():
            breaker_path.unlink()
            print("[OK] Circuit breaker cleared - trading resumed")
        else:
            print("[INFO] No active circuit breaker found")
        sys.exit(0)

    # Default: TRADING_VENUE=sim → real Simmer API; TRADING_VENUE=polymarket → dry-run (need --live for real trades)
    # --dry-run flag overrides to True in any mode.
    dry_run = args.dry_run or (
        args.live is False and os.environ.get("TRADING_VENUE") != "sim"
    )

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

    # 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 (CRITICAL)