Skill: simmer/polymarket-ai-divergence (version 2.3.0)

--- name: polymarket-ai-divergence description: Find markets where Simmer's AI consensus diverges from the real market price, then trade on the mispriced side using Kelly sizing. Scans for divergence, checks fees and safeguards, and executes trades on zero-fee markets with sufficient edge. metadata: author: Simmer (@simmer_markets) version: "2.3.0" displayName: Polymarket AI Divergence difficulty: intermediate --- # Polymarket AI Divergence Trader Find markets where Simmer's AI consensus diverges from the real market price, then trade the edge. > **This is a template.** The default logic trades when AI divergence exceeds 2% on zero-fee markets, using Kelly sizing capped at 25%. Remix it with different edge thresholds, sizing strategies, or additional filters (e.g., only trade markets resolving within 7 days). The skill handles plumbing (divergence scanning, fee checks, safeguards, execution). Your agent provides the alpha. ## What It Does 1. **Scans** all active markets for AI vs market price divergence 2. **Filters** to markets with edge above threshold (default 2%) and zero fees 3. **Checks** safeguards (flip-flop detection, existing positions) 4. **Sizes** using Kelly criterion, capped conservatively 5. **Executes** trades on the mispriced side (YES when AI is bullish, NO when bearish) ## Setup Flow When user asks to install or configure this skill: 1. **Install the Simmer SDK** ```bash pip install simmer-sdk ``` 2. **Ask for Simmer API key** - They can get it from simmer.markets/dashboard → SDK tab - Store in environment as `SIMMER_API_KEY` 3. **Ask for wallet private key** (required for live trading) - This is the private key for their Polymarket wallet (the wallet that holds USDC) - Store in environment as `WALLET_PRIVATE_KEY` - The SDK uses this to sign orders client-side automatically — no manual signing needed - Not needed for $SIM paper trading on the Simmer venue ## Quick Commands ```bash # Scan only (dry run, no trades) python ai_divergence.py # Scan + execute trades python ai_divergence.py --live # Only show bullish divergences python ai_divergence.py --bullish # Only >15% divergence python ai_divergence.py --min 15 # JSON output python ai_divergence.py --json # Cron mode (quiet, trades only) python ai_divergence.py --live --quiet # Show config python ai_divergence.py --config # Update config python ai_divergence.py --set max_bet_usd=10 ``` ## Configuration | Key | Env Var | Default | Description | |-----|---------|---------|-------------| | `min_divergence` | `SIMMER_DIVERGENCE_MIN` | 5.0 | Min divergence % for scanner display | | `min_edge` | `SIMMER_DIVERGENCE_MIN_EDGE` | 0.02 | Min divergence to trade (2%) | | `max_bet_usd` | `SIMMER_DIVERGENCE_MAX_BET` | 5.0 | Max bet per trade | | `max_trades_per_run` | `SIMMER_DIVERGENCE_MAX_TRADES` | 3 | Max trades per cycle | | `kelly_cap` | `SIMMER_DIVERGENCE_KELLY_CAP` | 0.25 | Kelly fraction cap | | `daily_budget` | `SIMMER_DIVERGENCE_DAILY_BUDGET` | 25.0 | Daily spend limit | | `default_direction` | `SIMMER_DIVERGENCE_DIRECTION` | (both) | Filter: "bullish" or "bearish" | Update via CLI: `python ai_divergence.py --set max_bet_usd=10` ## How It Works ### Divergence Signal Each imported market has two prices: - **AI consensus** (`current_probability`) — Simmer's AI consensus price, derived from multi-model ensemble forecasting - **External price** (`external_price_yes`) — Real market price on Polymarket/Kalshi `divergence = AI consensus - external price` When divergence > 0: AI thinks the market is underpriced → buy YES When divergence < 0: AI thinks the market is overpriced → buy NO ### Kelly Sizing Position size uses the Kelly criterion: ``` kelly_fraction = edge / (1 - price) position_size = kelly_fraction * max_bet_usd ``` Capped at `kelly_cap` (default 25%) to limit risk. ### Fee Filtering 75% of Polymarket markets have 0% fees. The remaining 25% charge 10% (short-duration crypto/sports). This skill **only trades zero-fee markets** to avoid fee drag eroding the edge. ### Safeguards - **Fee check**: Skips markets with any taker fee - **Flip-flop detection**: Uses SDK's context API to detect contradictory trades - **Position check**: Skips markets where you already hold a position - **Daily budget**: Stops trading when daily spend limit is reached - **Kelly sizing**: Conservative sizing prevents over-betting ## API Endpoints Used - `GET /api/sdk/markets/opportunities` — Divergence-ranked market list - `GET /api/sdk/context/{market_id}` — Fee rate and safeguards per market - `POST /api/sdk/trade` — Trade execution (via SDK client) - `GET /api/sdk/positions` — Current portfolio positions ## Troubleshooting **"No markets above min edge threshold"** → All divergences are below the `min_edge` setting. Lower it with `--set min_edge=0.01` or wait for larger divergences. **"Daily budget exhausted"** → The skill has hit its daily spend limit. Adjust with `--set daily_budget=50`. **All markets skipped for fees** → Only zero-fee markets are traded. If all available divergence opportunities have fees, no trades execute. This is by design. **"context fetch failed"** → The SDK context endpoint is rate-limited (18 req/min). If running frequently, reduce `max_trades_per_run`.

path: ai_divergence.py
content: #!/usr/bin/env python3
"""
Simmer AI Divergence Trader

Finds markets where Simmer's AI consensus diverges from the real market price,
then trades on the mispriced side using Kelly sizing.

Usage:
    python ai_divergence.py              # Scan only (dry run)
    python ai_divergence.py --live       # Scan + execute trades
    python ai_divergence.py --min 10     # Only >10% divergence
    python ai_divergence.py --bullish    # AI more bullish than market
    python ai_divergence.py --bearish    # AI more bearish than market
    python ai_divergence.py --json       # Machine-readable output
"""

import os
import sys
import json
import argparse
from datetime import datetime, timezone
from pathlib import Path

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


# =============================================================================
# Configuration
# =============================================================================

from simmer_sdk.skill import load_config, update_config, get_config_path

CONFIG_SCHEMA = {
    "min_divergence": {"env": "SIMMER_DIVERGENCE_MIN", "default": 5.0, "type": float},
    "default_direction": {"env": "SIMMER_DIVERGENCE_DIRECTION_FILTER", "default": "", "type": str},
    "max_bet_usd": {"env": "SIMMER_DIVERGENCE_MAX_BET_USD", "default": 5.0, "type": float},
    "max_trades_per_run": {"env": [REDACTED_SECRET], "default": 3, "type": int},
    "min_edge": {"env": "SIMMER_DIVERGENCE_MIN_EDGE", "default": 0.02, "type": float},
    "kelly_cap": {"env": "SIMMER_DIVERGENCE_KELLY_CAP", "default": 0.25, "type": float},
    "daily_budget": {"env": [REDACTED_SECRET], "default": 25.0, "type": float},
}

_config = load_config(CONFIG_SCHEMA, __file__, slug="polymarket-ai-divergence")

DEFAULT_MIN_DIVERGENCE = _config["min_divergence"]
DEFAULT_DIRECTION = _config["default_direction"]
MAX_BET_USD = _config["max_bet_usd"]
_automaton_max = os.environ.get("AUTOMATON_MAX_BET")
if _automaton_max:
    MAX_BET_USD = min(MAX_BET_USD, float(_automaton_max))
MAX_TRADES_PER_RUN = _config["max_trades_per_run"]
MIN_EDGE = _config["min_edge"]
KELLY_CAP = _config["kelly_cap"]
DAILY_BUDGET = _config["daily_budget"]

TRADE_SOURCE = "sdk:divergence"
SKILL_SLUG = "polymarket-ai-divergence"
_automaton_reported = False
MIN_SHARES_PER_ORDER = 5.0


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


# =============================================================================
# Daily spend tracking
# =============================================================================

def _get_spend_path():
    return Path(__file__).parent / "daily_spend.json"


def _load_daily_spend():
    """Load today's spend. Resets if date != today (UTC)."""
    spend_path = _get_spend_path()
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    if spend_path.exists():
        try:
            with open(spend_path) as f:
                data = json.load(f)
            if data.get("date") == today:
                return data
        except (json.JSONDecodeError, IOError):
            pass
    return {"date": today, "spent": 0.0, "trades": 0}


def _save_daily_spend(spend_data):
    with open(_get_spend_path(), "w") as f:
        json.dump(spend_data, f, indent=2)


# =============================================================================
# Trading helpers
# =============================================================================

def execute_trade(market_id, side, amount, signal_data=None):
    """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,
            signal_data=signal_data,
        )
        return {
            "success": result.success,
            "trade_id": result.trade_id,
            "shares_bought": result.shares_bought,
            "shares": result.shares_bought,
            "error": result.error,
            "simulated": result.simulated,
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


def get_positions():
    """Get current positions as list of dicts, filtered by venue."""
    try:
        client = get_client()
        positions = client.get_positions(venue=client.venue)
        from dataclasses import asdict
        return [asdict(p) for p in positions]
    except Exception:
        return []


def get_market_context(market_id):
    """Fetch market context (includes fee_rate_bps and safeguards)."""
    try:
        return get_client()._request("GET", f"/api/sdk/context/{market_id}")
    except Exception:
        return None


def calculate_kelly_size(edge, price, max_bet, kelly_cap):
    """Kelly criterion position sizing.

    kelly_fraction = edge / (1 - price) for YES side
    Capped at kelly_cap fraction of max_bet.
    """
    if price <= 0 or price >= 1:
        return 0
    kelly = edge / (1 - price)
    kelly = max(0, min(kelly, kelly_cap))
    return round(kelly * max_bet, 2)


# =============================================================================
# Market data
# =============================================================================

def get_markets() -> list:
    """Fetch markets with divergence between LMSR pool price and external venue price.

    Divergence sources:
    - 'oracle': AI multi-model forecast (activated markets)
    - 'crowd': Sim agent trading activity against LMSR pool (tracking markets)
    """
    data = get_client()._request("GET", "/api/sdk/markets/opportunities", params={"limit": 50, "min_divergence": 0.01})
    return [
        {
            "id": m.get("id"),
            "question": m.get("question"),
            "current_probability": m.get("current_probability"),
            "external_price_yes": m.get("external_price_yes"),
            "divergence": m.get("divergence"),
            "import_source": m.get("import_source"),
            "resolves_at": m.get("resolves_at"),
            "opportunity_score": m.get("opportunity_score"),
            "recommended_side": m.get("recommended_side"),
            "signal_source": m.get("signal_source", "crowd"),
        }
        for m in data.get("opportunities", [])
    ]


def enrich_with_gamma(markets: list) -> list:
    """Enrich market list with Polymarket metadata from Gamma API.

    Adds description, category, volume_24h, and liquidity fields from
    Polymarket's Gamma API. Only enriches polymarket-sourced markets.
    Failures are non-blocking — markets are returned unchanged on error.
    """
    poly_markets = [m for m in markets if m.get("import_source") == "polymarket"]
    if not poly_markets:
        return markets

    try:
        # Local helper, lives next to this skill (was in simmer_sdk pre-0.9.21)
        from gamma_api import GammaClient
    except ImportError:
        return markets

    gamma = GammaClient()

    # Build a lookup from question text to Gamma metadata
    gamma_lookup = {}
    seen_queries = set()
    for m in poly_markets:
        q = m.get("question", "")
        # Use first few significant words as search query
        words = q.split()[:5]
        query = " ".join(words)
        if query in seen_queries or not query:
            continue
        seen_queries.add(query)

        try:
            results = gamma.search(query, pages=1)
            for event in results:
                for gm in event.get("markets", []):
                    gamma_lookup[gm.get("question", "")] = gm
        except Exception:
            continue

    # Merge Gamma metadata into our markets
    for m in markets:
        question = m.get("question", "")
        gm = gamma_lookup.get(question)
        if gm:
            m["gamma_description"] = gm.get("description", "")
            m["gamma_category"] = gm.get("category", "")
            m["gamma_volume_24h"] = gm.get("volume_24h", 0)
            m["gamma_liquidity"] = gm.get("liquidity", 0)
            m["gamma_tags"] = gm.get("tags", [])

    return markets


# =============================================================================
# Scanner display
# =============================================================================

def format_divergence(markets: list, min_div: float = 0, direction: str = None) -> None:
    """Display divergence table."""
    filtered = []
    for m in markets:
        div = m.get("divergence") or 0
        if abs(div) < min_div / 100:
            continue
        if direction == "bullish" and div <= 0:
            continue
        if direction == "bearish" and div >= 0:
            continue
        filtered.append(m)

    filtered.sort(key=lambda m: abs(m.get("divergence") or 0), reverse=True)

    if not filtered:
        print("No markets match your filters.")
        return

    print()
    print("🔮 AI Divergence Scanner")
    print("=" * 75)
    print(f"{'Market':<36} {'LMSR':>7} {'Venue':>7} {'Div':>7} {'Source':>6} {'Signal':>8}")
    print("-" * 75)

    for m in filtered[:20]:
        q = m.get("question", "")[:34]
        simmer = m.get("current_probability") or 0
        poly = m.get("external_price_yes") or 0
        div = m.get("divergence") or 0
        src = m.get("signal_source", "crowd")[:5]

        is_polymarket = m.get("import_source") in ("polymarket", "kalshi")
        if div > 0.05:
            signal = "🟡 AI>MKT" if is_polymarket else "🟢 BUY"
        elif div < -0.05:
            signal = "🟡 AI<MKT" if is_polymarket else "🔴 SELL"
        else:
            signal = "⚪ HOLD"

        print(f"{q:<36} {simmer:>6.1%} {poly:>6.1%} {div:>+6.1%} {src:>6} {signal:>8}")

    print("-" * 75)
    print(f"Showing {len(filtered[:20])} of {len(filtered)} markets with divergence")
    print()

    bullish = len([m for m in filtered if (m.get("divergence") or 0) > 0])
    bearish = len([m for m in filtered if (m.get("divergence") or 0) < 0])
    avg_div = sum(abs(m.get("divergence") or 0) for m in filtered) / len(filtered) if filtered else 0

    print(f"📊 Summary: {bullish} bullish, {bearish} bearish, avg divergence {avg_div:.1%}")


def show_opportunities(markets: list) -> None:
    """Show actionable high-conviction opportunities."""
    print()
    print("💡 Top Opportunities (>10% divergence)")
    print("=" * 75)

    opps = [m for m in markets if abs(m.get("divergence") or 0) > 0.10]
    opps.sort(key=lambda m: abs(m.get("divergence") or 0), reverse=True)

    if not opps:
        print("No high-divergence opportunities right now.")
        return

    for m in opps[:5]:
        q = m.get("question", "")
        simmer = m.get("current_probability") or 0
        poly = m.get("external_price_yes") or 0
        div = m.get("divergence") or 0
        resolves = m.get("resolves_at", "Unknown")

        is_external = m.get("import_source") in ("polymarket", "kalshi")
        venue_name = "Kalshi" if m.get("import_source") == "kalshi" else "Polymarket"
        if is_external:
            action = f"Simmer AI: {simmer:.0%} vs {venue_name}: {poly:.0%} — do your own research before trading"
        elif div > 0:
            action = f"AI says BUY YES (AI: {simmer:.0%} vs Market: {poly:.0%})"
        else:
            action = f"AI says BUY NO (AI: {simmer:.0%} vs Market: {poly:.0%})"

        print(f"\n📌 {q[:70]}")
        print(f"   {action}")
        print(f"   Divergence: {div:+.1%} | Resolves: {resolves[:10] if resolves else 'TBD'}")


# =============================================================================
# Trade execution
# =============================================================================

def run_divergence_trades(markets, dry_run=True, quiet=False):
    """Scan for divergence opportunities and execute trades.

    Returns (signals_found, trades_attempted, trades_executed).
    """
    def log(msg, force=False):
        if not quiet or force:
            print(msg)

    # Filter to tradeable candidates
    candidates = [
        m for m in markets
        if m.get("id") and abs(m.get("divergence") or 0) >= MIN_EDGE
    ]
    candidates.sort(key=lambda m: abs(m.get("divergence") or 0), reverse=True)

    signals_found = len(candidates)
    skip_reasons = []
    execution_errors = []
    if not candidates:
        log("  No markets above min edge threshold")
        return signals_found, 0, 0, skip_reasons, 0.0, []

    # Load daily spend
    daily_spend = _load_daily_spend()
    remaining_budget = DAILY_BUDGET - daily_spend["spent"]
    if remaining_budget <= 0:
        log(f"  Daily budget exhausted (${daily_spend['spent']:.2f}/${DAILY_BUDGET:.2f})", force=True)
        skip_reasons.append("daily budget exhausted")
        return signals_found, 0, 0, skip_reasons, 0.0, []

    # Get existing positions to avoid doubling up
    positions = get_positions()
    held_market_ids = {p.get("market_id") for p in positions if (p.get("shares_yes") or 0) > 0 or (p.get("shares_no") or 0) > 0}

    trades_attempted = 0
    trades_executed = 0
    total_usd_spent = 0.0

    log(f"\n{'=' * 50}")
    log(f"  🎯 Divergence Trading")
    log(f"{'=' * 50}")

    for m in candidates[:MAX_TRADES_PER_RUN * 2]:  # Check extra in case some get filtered
        if trades_executed >= MAX_TRADES_PER_RUN:
            break
        if remaining_budget < 0.50:
            log(f"  Budget remaining ${remaining_budget:.2f} < $0.50 — stopping")
            break

        market_id = m["id"]
        div = m.get("divergence") or 0
        question = m.get("question", "Unknown")[:50]

        # Skip if already holding
        if market_id in held_market_ids:
            log(f"  ⏭️  {question}... — already holding position")
            skip_reasons.append("already holding")
            continue

        # Fetch context for fee rate + safeguards
        context = get_market_context(market_id)
        if not context:
            log(f"  ⏭️  {question}... — context fetch failed")
            continue

        ctx_market = context.get("market", {})
        fee_rate_bps = ctx_market.get("fee_rate_bps", 0)
        fee_pct = fee_rate_bps / 10000  # e.g. 1000bps = 10%

        # Check flip-flop safeguard
        discipline = context.get("discipline", {})
        warning_level = discipline.get("warning_level", "none")
        if warning_level == "severe":
            log(f"  ⏭️  {question}... — flip-flop warning (severe)")
            skip_reasons.append("safeguard: flip-flop severe")
            continue

        # Determine side and price
        side = "yes" if div > 0 else "no"
        edge = abs(div)

        # Subtract fee from edge — only trade if edge exceeds fee
        net_edge = edge - fee_pct
        if net_edge < MIN_EDGE:
            log(f"  ⏭️  {question}... — edge {edge:.1%} - fee {fee_pct:.1%} = net {net_edge:.1%} < min {MIN_EDGE:.1%}")
            skip_reasons.append(f"net edge too low after {fee_rate_bps}bps fee")
            continue
        edge = net_edge  # Use fee-adjusted edge for Kelly sizing
        # Price we're buying at (external price for the side we're trading)
        if side == "yes":
            price = m.get("external_price_yes") or 0.5
        else:
            price = 1 - (m.get("external_price_yes") or 0.5)

        # Kelly sizing
        position_size = calculate_kelly_size(edge, price, MAX_BET_USD, KELLY_CAP)
        if position_size < 0.50:
            log(f"  ⏭️  {question}... — Kelly size ${position_size:.2f} too small")
            skip_reasons.append("position too small")
            continue

        # Cap to remaining budget
        position_size = min(position_size, remaining_budget)

        trades_attempted += 1

        if dry_run:
            log(f"  🔒 [PAPER] {side.upper()} ${position_size:.2f} on {question}...")
            log(f"     Edge: {edge:.1%} | Price: ${price:.3f} | Kelly: ${position_size:.2f}")
            continue

        # Execute trade
        log(f"  🎯 Trading {side.upper()} ${position_size:.2f} on {question}...")
        log(f"     Edge: {edge:.1%} | Price: ${price:.3f}")
        _signal_data = {
            "edge": round(edge, 4),
            "confidence": round(min(0.95, edge * 2 + 0.5), 2),
            "signal_source": m.get("signal_source", "crowd"),
            "ai_forecast": round(m.get("current_probability") or 0, 4),
            "market_price": round(m.get("external_price_yes") or 0, 4),
            "divergence_pct": round(abs(div) * 100, 2),
        }
        result = execute_trade(market_id, side, position_size, signal_data=_signal_data)

        if result and result.get("success"):
            trades_executed += 1
            total_usd_spent += position_size
            shares = result.get("shares_bought") or result.get("shares") or 0
            simulated = result.get("simulated", False)
            prefix = "[PAPER] " if simulated else ""
            log(f"  ✅ {prefix}Bought {shares:.1f} {side.upper()} shares", force=True)

            if not simulated:
                daily_spend["spent"] += position_size
                daily_spend["trades"] += 1
                _save_daily_spend(daily_spend)
                remaining_budget -= position_size
        else:
            error = result.get("error", "Unknown error") if result else "No response"
            log(f"  ❌ Trade failed: {error}", force=True)
            execution_errors.append(error[:120])

    log(f"\n  Signals: {signals_found} | Attempted: {trades_attempted} | Executed: {trades_executed}")
    if dry_run:
        log("  [PAPER MODE — use --live for real trades]")

    return signals_found, trades_attempted, trades_executed, skip_reasons, total_usd_spent, execution_errors


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

def main():
    parser = argparse.ArgumentParser(description="Simmer AI Divergence Trader")
    parser.add_argument("--live", action="store_true", help="Execute real trades (default is dry-run)")
    parser.add_argument("--dry-run", action="store_true", help="(Default) Show opportunities without trading")
    parser.add_argument("--min", type=float, default=DEFAULT_MIN_DIVERGENCE,
                        help=f"Minimum divergence %% for scanner (default: {DEFAULT_MIN_DIVERGENCE})")
    parser.add_argument("--bullish", action="store_true", help="Only bullish divergence (Simmer > Poly)")
    parser.add_argument("--bearish", action="store_true", help="Only bearish divergence (Simmer < Poly)")
    parser.add_argument("--opportunities", "-o", action="store_true", help="Show top opportunities only")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument("--config", action="store_true", help="Show configuration")
    parser.add_argument("--quiet", "-q", action="store_true", help="Only output on trades/errors")
    parser.add_argument("--enrich", action="store_true",
                        help="Enrich results with Polymarket metadata via Gamma API")
    parser.add_argument("--set", action="append", metavar="KEY=VALUE",
                        help="Set config value (e.g., --set min_edge=0.03)")
    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)
                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:
            update_config(updates, __file__)
            print(f"✅ Config updated: {updates}")
            print(f"   Saved to: {get_config_path(__file__)}")

    # Show config
    if args.config:
        config_path = get_config_path(__file__)
        print("🔮 AI Divergence Trader Configuration")
        print("=" * 40)
        for key, spec in CONFIG_SCHEMA.items():
            val = _config.get(key, spec.get("default"))
            print(f"  {key:<22} = {val}")
        print(f"\nConfig file: {config_path}")
        print(f"Config exists: {'Yes' if config_path.exists() else 'No'}")
        return

    # Validate API key by initializing client
    dry_run = not args.live
    client = get_client(live=not dry_run)

    # Redeem any winning positions before starting the cycle
    try:
        redeemed = client.auto_redeem()
        for r in redeemed:
            if r.get("success"):
                print(f"  💰 Redeemed {r['market_id'][:8]}... ({r.get('side', '?')})")
    except Exception:
        pass  # Non-critical — don't block trading

    # Balance pre-flight: skip cleanly when wallet is underfunded instead of
    # looping on rejected trades. Helper is collateral-agnostic — checks pUSD
    # on V2, USDC.e on V1 per server's exchange_version.
    global MAX_BET_USD, _automaton_reported
    is_paper_venue_pre = os.environ.get("TRADING_VENUE", "polymarket") == "sim"
    if not dry_run and not is_paper_venue_pre:
        _preflight = client.ensure_can_trade(min_usd=1.0)
        if not _preflight["ok"]:
            print(f"  ⏸️  insufficient_balance: ${_preflight['balance']:.2f} {_preflight['collateral']} "
                  f"(need ≥ $1.00) — skip")
            if os.environ.get("AUTOMATON_MANAGED"):
                print(json.dumps({"automaton": {
                    "signals": 0, "trades_attempted": 0, "trades_executed": 0,
                    "skip_reason": _preflight["reason"],
                    "balance_usd": round(_preflight["balance"], 2),
                }}))
                _automaton_reported = True
            return
        if _preflight["max_safe_size"] < MAX_BET_USD:
            print(f"  💰 Capping max bet ${MAX_BET_USD:.2f} → ${_preflight['max_safe_size']:.2f} "
                  f"(balance ${_preflight['balance']:.2f} {_preflight['collateral']})")
            MAX_BET_USD = _preflight["max_safe_size"]

    direction = DEFAULT_DIRECTION or None
    if args.bullish:
        direction = "bullish"
    elif args.bearish:
        direction = "bearish"

    markets = get_markets()
    markets = [m for m in markets if m.get('is_live_now', True) is not False]  # skip not-yet-open markets (no-op if field absent)

    if args.enrich:
        markets = enrich_with_gamma(markets)

    if args.json:
        filtered = [m for m in markets if abs(m.get("divergence") or 0) >= args.min / 100]
        filtered.sort(key=lambda m: abs(m.get("divergence") or 0), reverse=True)
        print(json.dumps(filtered, indent=2))
        return

    # Scanner display
    if not args.quiet:
        if args.opportunities:
            show_opportunities(markets)
        else:
            format_divergence(markets, args.min, direction)
            show_opportunities(markets)

    # Trade execution
    skip_reasons = []
    is_paper_venue = os.environ.get("TRADING_VENUE", "polymarket") == "sim"
    if args.live or is_paper_venue:
        effective_dry_run = dry_run and not is_paper_venue
        signals, attempted, executed, skip_reasons, total_usd_spent, execution_errors = run_divergence_trades(markets, dry_run=effective_dry_run, quiet=args.quiet)
    else:
        signals = len([m for m in markets if abs(m.get("divergence") or 0) >= MIN_EDGE])
        attempted = 0
        executed = 0
        total_usd_spent = 0.0
        execution_errors = []

    # Structured report for automaton
    if os.environ.get("AUTOMATON_MANAGED"):
        report = {"signals": signals, "trades_attempted": attempted, "trades_executed": executed, "amount_usd": round(total_usd_spent, 2)}
        if signals > 0 and executed == 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 __name__ == "__main__":
    main()

    # Fallback report for automaton if main() 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"}}))


path: clawhub.json
content: {
  "emoji": "\ud83d\udd2e",
  "primaryEnv": "SIMMER_API_KEY",
  "requires": {
    "env": [
      "SIMMER_API_KEY"
    ],
    "pip": [
      "simmer-sdk>=0.11.1"
    ]
  },
  "envVars": [
    {
      "name": "SIMMER_API_KEY",
      "required": true,
      "description": "Your Simmer SDK API key \u2014 get from simmer.markets/dashboard"
    },
    {
      "name": "WALLET_PRIVATE_KEY",
      "required": false,
      "description": "Only needed for external-wallet self-custody trading. Not required when using managed wallets."
    }
  ],
  "cron": null,
  "autostart": false,
  "automaton": {
    "managed": true,
    "entrypoint": "ai_divergence.py"
  },
  "tunables": [
    {
      "env": "SIMMER_DIVERGENCE_MIN_EDGE",
      "type": "number",
      "default": 0.05,
      "range": [
        0.01,
        0.3
      ],
      "step": 0.01,
      "label": "Minimum edge threshold"
    },
    {
      "env": "SIMMER_DIVERGENCE_MAX_BET_USD",
      "type": "number",
      "default": 50,
      "range": [
        1,
        200
      ],
      "step": 5,
      "label": "Max bet per trade"
    },
    {
      "env": "SIMMER_DIVERGENCE_KELLY_CAP",
      "type": "number",
      "default": 0.25,
      "range": [
        0.01,
        1.0
      ],
      "step": 0.01,
      "label": "Kelly fraction cap"
    },
    {
      "env": "SIMMER_DIVERGENCE_DAILY_BUDGET_USD",
      "type": "number",
      "default": 100,
      "range": [
        10,
        500
      ],
      "step": 10,
      "label": "Daily budget"
    },
    {
      "env": "SIMMER_DIVERGENCE_MAX_TRADES_PER_RUN",
      "type": "number",
      "default": 5,
      "range": [
        1,
        20
      ],
      "step": 1,
      "label": "Max trades per run"
    },
    {
      "env": "SIMMER_DIVERGENCE_DIRECTION_FILTER",
      "type": "enum",
      "default": "any",
      "options": [
        "any",
        "yes_only",
        "no_only"
      ],
      "label": "Direction filter"
    }
  ]
}


path: gamma_api.py
content: """
Gamma API client for Polymarket market research.

Provides direct access to Polymarket's Gamma API for market metadata,
search, and analytics not available through the Simmer API.

The Gamma API is free, requires no authentication, and has a generous
rate limit of 15,000 requests per 10 seconds.

Usage:
    from simmer_sdk.gamma_api import GammaClient

    gamma = GammaClient()

    # Search for markets
    results = gamma.search("US elections")
    for event in results:
        print(event["title"], len(event["markets"]), "markets")

    # List active markets with filters (cursor-paginated)
    markets, next_cursor = gamma.get_markets(active=True, limit=10)

    # Get a single event by slug
    event = gamma.get_event("will-trump-win-2024")
"""

import json
import logging
from typing import Any, Dict, List, Optional, Tuple
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode

logger = logging.getLogger(__name__)

GAMMA_API_BASE = "https://gamma-api.polymarket.com"


class GammaClient:
    """Lightweight client for Polymarket's Gamma API.

    All methods are synchronous and use only stdlib (no extra deps).
    """

    def __init__(self, base_url: str = GAMMA_API_BASE, timeout: int = 10):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout

    def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
        """Make a GET request to the Gamma API."""
        url = f"{self.base_url}{path}"
        if params:
            # Filter out None values
            filtered = {k: v for k, v in params.items() if v is not None}
            if filtered:
                url = f"{url}?{urlencode(filtered)}"
        try:
            req = Request(url, headers={"User-Agent": "simmer-sdk"})
            with urlopen(req, timeout=self.timeout) as resp:
                return json.loads(resp.read())
        except (HTTPError, URLError, json.JSONDecodeError) as e:
            logger.warning("Gamma API request failed: %s %s", url, e)
            return None

    # ------------------------------------------------------------------
    # Search
    # ------------------------------------------------------------------

    def search(
        self,
        query: str,
        *,
        active: bool = True,
        closed: bool = False,
        pages: int = 1,
    ) -> List[Dict[str, Any]]:
        """Search Polymarket events and markets via /public-search.

        Args:
            query: Free-text search string.
            active: Only return active events (default True).
            closed: Include closed/resolved markets (default False).
            pages: Number of result pages to fetch (5 events per page).

        Returns:
            List of event dicts, each with a nested ``markets`` list.
        """
        events: Dict[str, Dict] = {}  # dedup by event ID

        for page in range(1, pages + 1):
            params = {
                "q": query,
                "page": page,
                "events_status": "active" if active else None,
                "keep_closed_markets": "1" if closed else "0",
            }
            data = self._get("/public-search", params)
            if not data:
                break

            # Response is {"events": [...], "pagination": {...}}
            event_list = data.get("events", []) if isinstance(data, dict) else data
            if not event_list or not isinstance(event_list, list):
                break

            for event in event_list:
                eid = event.get("id")
                if eid and eid not in events:
                    events[eid] = self._parse_event(event)

        return list(events.values())

    # ------------------------------------------------------------------
    # Markets
    # ------------------------------------------------------------------

    def get_markets(
        self,
        *,
        active: bool = True,
        closed: bool = False,
        limit: int = 50,
        after_cursor: Optional[str] = None,
        order: str = "volume24hr",
        ascending: bool = False,
    ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
        """List markets with optional filters, sorted by a field.

        Args:
            active: Only active markets.
            closed: Only closed markets.
            limit: Max results per page (1-1000).
            after_cursor: Cursor from a prior call's ``next_cursor``. None for first page.
            order: Sort field (volume24hr, liquidity, volume, startDate, endDate).
            ascending: Sort direction.

        Returns:
            Tuple of (parsed market dicts, next_cursor). next_cursor is None on the last page.
        """
        params: Dict[str, Any] = {
            "limit": limit,
            "order": order,
            "ascending": str(ascending).lower(),
        }
        if after_cursor:
            params["after_cursor"] = after_cursor
        if active:
            params["active"] = "true"
            params["closed"] = "false"
        elif closed:
            params["closed"] = "true"

        data = self._get("/markets/keyset", params)
        if not data or not isinstance(data, dict):
            return [], None
        markets = [self._parse_market(m) for m in data.get("markets", [])]
        return markets, data.get("next_cursor")

    def get_market(self, condition_id: str) -> Optional[Dict[str, Any]]:
        """Get a single market by its condition ID."""
        data = self._get("/markets/keyset", {"condition_ids": condition_id})
        if not data or not isinstance(data, dict):
            return None
        markets = data.get("markets", [])
        if not markets:
            return None
        return self._parse_market(markets[0])

    # ------------------------------------------------------------------
    # Events
    # ------------------------------------------------------------------

    def get_events(
        self,
        *,
        active: bool = True,
        closed: bool = False,
        limit: int = 50,
        after_cursor: Optional[str] = None,
        order: str = "volume24hr",
        ascending: bool = False,
    ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
        """List events (groups of related markets).

        Args:
            active: Only active events.
            closed: Only closed events.
            limit: Max results per page (1-500).
            after_cursor: Cursor from a prior call's ``next_cursor``. None for first page.
            order: Sort field.
            ascending: Sort direction.

        Returns:
            Tuple of (parsed event dicts with nested markets, next_cursor).
        """
        params: Dict[str, Any] = {
            "limit": limit,
            "order": order,
            "ascending": str(ascending).lower(),
        }
        if after_cursor:
            params["after_cursor"] = after_cursor
        if active:
            params["active"] = "true"
            params["closed"] = "false"
        elif closed:
            params["closed"] = "true"

        data = self._get("/events/keyset", params)
        if not data or not isinstance(data, dict):
            return [], None
        events = [self._parse_event(e) for e in data.get("events", [])]
        return events, data.get("next_cursor")

    def get_event(self, slug: str) -> Optional[Dict[str, Any]]:
        """Get a single event by slug.

        Args:
            slug: Event URL slug (e.g. 'will-trump-win-2024').

        Returns:
            Parsed event dict or None.
        """
        data = self._get("/events/keyset", {"slug": slug})
        if not data or not isinstance(data, dict):
            return None
        events = data.get("events", [])
        if not events:
            return None
        return self._parse_event(events[0])

    # ------------------------------------------------------------------
    # Parsing helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _safe_float(val: Any, default: float = 0.0) -> float:
        if val is None:
            return default
        try:
            return float(val)
        except (ValueError, TypeError):
            return default

    @staticmethod
    def _parse_json_field(val: Any) -> Any:
        if isinstance(val, str):
            try:
                return json.loads(val)
            except (json.JSONDecodeError, TypeError):
                return val
        return val

    def _parse_market(self, data: dict) -> Dict[str, Any]:
        """Parse a raw Gamma API market into a clean dict."""
        outcomes = self._parse_json_field(data.get("outcomes", []))
        outcome_prices_raw = self._parse_json_field(data.get("outcomePrices", []))
        clob_token_ids = self._parse_json_field(data.get("clobTokenIds", []))

        outcome_prices = []
        for p in (outcome_prices_raw if isinstance(outcome_prices_raw, list) else []):
            outcome_prices.append(self._safe_float(p, 0.5))

        yes_price = outcome_prices[0] if outcome_prices else 0.5
        no_price = outcome_prices[1] if len(outcome_prices) > 1 else 1.0 - yes_price

        return {
            "id": data.get("id"),
            "condition_id": data.get("conditionId", ""),
            "slug": data.get("slug", ""),
            "question": data.get("question", ""),
            "description": data.get("description", ""),
            "category": data.get("category", ""),
            "end_date": data.get("endDate", ""),
            "outcomes": outcomes if isinstance(outcomes, list) else [],
            "outcome_prices": outcome_prices,
            "yes_price": yes_price,
            "no_price": no_price,
            "clob_token_ids": clob_token_ids if isinstance(clob_token_ids, list) else [],
            "active": bool(data.get("active", False)),
            "closed": bool(data.get("closed", False)),
            "volume": self._safe_float(data.get("volume")),
            "volume_24h": self._safe_float(data.get("volume24hr")),
            "liquidity": self._safe_float(data.get("liquidity")),
            "one_day_price_change": self._safe_float(data.get("oneDayPriceChange")),
            "one_week_price_change": self._safe_float(data.get("oneWeekPriceChange")),
            "one_month_price_change": self._safe_float(data.get("oneMonthPriceChange")),
            "image_url": data.get("image"),
            "neg_risk": bool(
                data.get("neg_risk", False)
                or data.get("negRisk", False)
                or data.get("enableNegRisk", False)
            ),
            "featured": bool(data.get("featured", False)),
            "competitive": bool(data.get("competitive", False)),
            "is_new": bool(data.get("new", False)),
            "group_item_title": data.get("groupItemTitle"),
            "tags": data.get("tags", []),
        }

    def _parse_event(self, data: dict) -> Dict[str, Any]:
        """Parse a raw Gamma API event (with nested markets) into a clean dict."""
        raw_markets = data.get("markets", [])
        markets = [self._parse_market(m) for m in raw_markets] if raw_markets else []

        return {
            "id": data.get("id"),
            "slug": data.get("slug", ""),
            "title": data.get("title", ""),
            "description": data.get("description", ""),
            "category": data.get("category", ""),
            "end_date": data.get("endDate", ""),
            "active": bool(data.get("active", False)),
            "closed": bool(data.get("closed", False)),
            "volume": self._safe_float(data.get("volume")),
            "volume_24h": self._safe_float(data.get("volume24hr")),
            "liquidity": self._safe_float(data.get("liquidity")),
            "image_url": data.get("image"),
            "neg_risk": bool(
                data.get("neg_risk", False)
                or data.get("negRisk", False)
                or data.get("enableNegRisk", False)
            ),
            "tags": data.get("tags", []),
            "markets": markets,
            "market_count": len(markets),
        }


path: scripts/status.py
content: #!/usr/bin/env python3
"""
Quick status check for AI Divergence Scanner.

Usage:
    python scripts/status.py
"""

import os
import sys
import json
from urllib.request import Request, urlopen
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_URL = os.environ.get("SIMMER_API_URL", "https://api.simmer.markets")


def main():
    [REDACTED_SECRET]"SIMMER_API_KEY")
    if not [REDACTED_SECRET]"❌ SIMMER_API_KEY not set")
        sys.exit(1)
    
    print("📊 Checking AI Divergence status...\n")
    
    try:
        req = Request(
            f"{SIMMER_API_URL}/api/sdk/markets",
            headers={"Authorization": f"Bearer {api_key}"}
        )
        data = json.loads(urlopen(req, timeout=30).read())
        markets = data.get("markets", [])
        
        high_div = [m for m in markets if abs(m.get("divergence") or 0) > 0.10]
        med_div = [m for m in markets if 0.05 < abs(m.get("divergence") or 0) <= 0.10]
        
        bullish = len([m for m in markets if (m.get("divergence") or 0) > 0.05])
        bearish = len([m for m in markets if (m.get("divergence") or 0) < -0.05])
        
        print("=" * 40)
        print("🔮 AI DIVERGENCE STATUS")
        print("=" * 40)
        print(f"  Total markets:     {len(markets)}")
        print(f"  High divergence:   {len(high_div)} (>10%)")
        print(f"  Medium divergence: {len(med_div)} (5-10%)")
        print(f"  Bullish signals:   {bullish}")
        print(f"  Bearish signals:   {bearish}")
        print("=" * 40)
        
        if high_div:
            print("\n💡 Top opportunity:")
            top = max(high_div, key=lambda m: abs(m.get("divergence") or 0))
            q = top.get("question", "")[:50]
            div = top.get("divergence") or 0
            print(f"   {q}...")
            print(f"   Divergence: {div:+.1%}")
        
        print()
        
    except HTTPError as e:
        print(f"❌ API Error: {e.code}")
        sys.exit(1)
    except URLError as e:
        print(f"❌ Connection error: {e.reason}")
        sys.exit(1)


if __name__ == "__main__":
    main()


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