Skill: icloud-git/polymarket-edge (version 1.0.0)

--- name: polymarket-edge description: "Trade and analyse Polymarket prediction markets with a 5-minute BTC EMA crossover strategy. Browse markets, read order books, run signals, manage a live auto-trader, and view portfolio positions. Billed per-call via SkillPay.me (0.001 USDT / call, BNB Chain USDT)." metadata: openclaw: requires: env: - SKILL_BILLING_API_KEY - SKILL_ID bins: - python3 - pip primaryEnv: SKILL_BILLING_API_KEY --- # Polymarket Edge A locally-running FastAPI skill that wraps the Polymarket Gamma + CLOB APIs with a built-in EMA(5/20) crossover strategy and SkillPay.me billing. ## Setup ```bash # Install dependencies pip install -r requirements.txt # Set required environment variables export SKILL_BILLING_API_KEY=[REDACTED_SECRET] export SKILL_ID=polymarket-edge # Start the skill server (port 8080) python main.py ``` The interactive docs are available at `http://localhost:8080/docs`. ## Environment variables | Variable | Required | Description | |---|---|---| | `SKILL_BILLING_API_KEY` | ✅ | From your skillpay.me dashboard | | `SKILL_ID` | ✅ | Your skill slug, e.g. `polymarket-edge` | | `POLYMARKET_PRIVATE_KEY` | optional | EVM private key for live order placement | ## Billing Every billed endpoint requires `?user_id=<id>` in the query string. If the user has no tokens the API returns **HTTP 402** with a `top_up_url`. ``` GET /balance?user_id=alice → current token balance GET /topup?user_id=alice&amount=10 → BNB Chain USDT payment link ``` 1 USDT = 1 000 tokens · 1 call = 1 token · min top-up 8 USDT · SkillPay takes 5 % ## Key endpoints (all require `?user_id=`) ### Market data ``` GET /markets/search?q=bitcoin Search all Polymarket markets GET /markets/btc List active BTC/Bitcoin markets GET /market/{id} Single market details GET /market/{token_id}/book Full order book (bids + asks) GET /market/{token_id}/price Mid-price, spread, implied probability GET /market/{token_id}/history 5-min OHLCV candles ``` ### Strategy signals ``` POST /signal Run EMA crossover on top BTC markets Returns BUY_YES / BUY_NO / HOLD / SKIP per market ``` ### Auto-trader ``` GET /autotrader/status Is the auto-trader running? POST /autotrader/start Start 5-min BTC cycle (background task) POST /autotrader/stop Stop auto-trader GET /autotrader/log Last 50 trade log entries ``` ### Portfolio ``` GET /portfolio?wallet=0x... Open positions + USD value for a wallet ``` ## Strategy logic - **Candles**: 5-minute YES-token price history from Polymarket CLOB - **Signal**: EMA(5) crosses above EMA(20) → `BUY_YES`; crosses below → `BUY_NO` - **Filters**: skip if spread > 0.05 or YES price outside [0.15, 0.85] - **Live trading**: set `?auto_trade=true` on `/autotrader/start` (requires `POLYMARKET_PRIVATE_KEY` + `py-clob-client`) ## Live trading (optional) ```bash pip install py-clob-client export POLYMARKET_PRIVATE_KEY=0x<your_burner_wallet> # Then uncomment the py-clob-client block in polymarket_client.py curl -X POST "http://localhost:8080/autotrader/start?user_id=alice&auto_trade=true" ``` ⚠️ Use a burner wallet. Never commit your private key.

path: billing.py
content: """
SkillPay Billing SDK — Python
1 USDT = 1000 tokens | 1 call = 1 token | Min deposit 8 USDT

Endpoints:
  POST /billing/charge       — deduct 1 token from user
  GET  /billing/balance      — query user token balance
  POST /billing/payment-link — generate top-up link
"""
import os
import httpx
from dataclasses import dataclass
from typing import Optional

BILLING_URL = "https://skillpay.me/api/v1/billing"

API_KEY: str = os.environ.get(
    "SKILL_BILLING_API_KEY",
    "[REDACTED_SECRET]",
)
SKILL_ID: str = os.environ.get("SKILL_ID", "e9f8947d-02b2-4287-ac49-3fb7df57526b")

_HEADERS = {}

def _get_headers() -> dict:
    return {
        "X-API-Key": os.environ.get("SKILL_BILLING_API_KEY", API_KEY),
        "Content-Type": "application/json",
    }


@dataclass
class ChargeResult:
    ok: bool
    balance: float
    payment_url: Optional[str] = None


async def charge_user(user_id: str) -> ChargeResult:
    """Deduct 1 token from user. Returns ok=False + payment_url if balance is zero."""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.post(
            f"{BILLING_URL}/charge",
            headers=_get_headers(),
            json={"user_id": user_id, "skill_id": SKILL_ID, "amount": 0.001},
        )
        resp.raise_for_status()
        data = resp.json()
        if data["success"]:
            return ChargeResult(ok=True, balance=data["balance"])
        return ChargeResult(
            ok=False,
            balance=data["balance"],
            payment_url=data.get("payment_url"),
        )


async def get_balance(user_id: str) -> float:
    """Return current token balance for a user."""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(
            f"{BILLING_URL}/balance",
            headers=_get_headers(),
            params={"user_id": user_id},
        )
        resp.raise_for_status()
        return resp.json()["balance"]


async def get_payment_link(user_id: str, amount: float = 8.0) -> str:
    """Generate a USDT top-up link for the user. Default minimum is 8 USDT."""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.post(
            f"{BILLING_URL}/payment-link",
            headers=_get_headers(),
            json={"user_id": user_id, "amount": amount},
        )
        resp.raise_for_status()
        return resp.json()["payment_url"]


# ── Decorator ─────────────────────────────────────────────────────────────────

def require_tokens(func):
    """
    FastAPI route decorator.
    Expects the request to have a query param or header `user_id`.
    Charges 1 token before executing; returns 402 if balance is zero.

    Usage:
        @app.get("/some-endpoint")
        @require_tokens
        async def handler(user_id: str, ...):
            ...
    """
    import functools
    from fastapi import Request
    from fastapi.responses import JSONResponse

    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        # FastAPI injects `request` if the function requests it, but we grab
        # user_id from kwargs directly — callers must declare `user_id: str`.
        user_id = kwargs.get("user_id") or "anonymous"
        result = await charge_user(user_id)
        if not result.ok:
            return JSONResponse(
                status_code=402,
                content={
                    "error": "Insufficient tokens",
                    "balance": result.balance,
                    "top_up_url": result.payment_url,
                    "message": "Please top up your SkillPay balance to continue. 8 USDT = 8000 calls.",
                },
            )
        return await func(*args, **kwargs)

    return wrapper


path: requirements.txt
content: fastapi>=0.111.0
uvicorn[standard]>=0.29.0
httpx>=0.27.0

# Optional: for live order placement on Polymarket CLOB
# py-clob-client>=0.17.0


path: strategy.py
content: """
BTC 5-Minute Auto-Trading Strategy for Polymarket
──────────────────────────────────────────────────
Logic overview:
  1. Fetch active BTC price markets (e.g. "Will BTC be above $X by date?").
  2. Pull 5-minute price history for YES token.
  3. Compute momentum (EMA crossover) and mean-reversion signals.
  4. Generate a trade signal: BUY_YES / BUY_NO / HOLD.
  5. Optionally place the order if live trading is enabled.

Signal rules (tunable via config):
  - EMA(5) crosses above EMA(20)  → BUY_YES  (momentum bullish)
  - EMA(5) crosses below EMA(20)  → BUY_NO   (momentum bearish)
  - YES price < 0.15              → too cheap / skip (low confidence)
  - YES price > 0.85              → too expensive / skip
  - Spread > 0.05                 → illiquid / skip
"""
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional

from polymarket_client import (
    Market,
    get_active_btc_markets,
    get_order_book,
    get_price_history,
    get_midpoint,
    get_spread,
    place_limit_order,
)

log = logging.getLogger("strategy")

# ── Config ────────────────────────────────────────────────────────────────────

@dataclass
class StrategyConfig:
    ema_fast: int   = 5       # Fast EMA period
    ema_slow: int   = 20      # Slow EMA period
    interval: str   = "5m"    # Candle interval
    fidelity: int   = 60      # Number of candles to fetch
    min_price: float = 0.15   # Skip YES tokens priced below this
    max_price: float = 0.85   # Skip YES tokens priced above this
    max_spread: float = 0.05  # Skip illiquid markets
    order_size: float = 5.0   # USDC per trade
    auto_trade: bool = False   # Set True to place real orders


DEFAULT_CONFIG = StrategyConfig()


# ── EMA ───────────────────────────────────────────────────────────────────────

def ema(prices: list[float], period: int) -> list[float]:
    """Compute exponential moving average."""
    if len(prices) < period:
        return []
    k = 2 / (period + 1)
    ema_values = [sum(prices[:period]) / period]
    for p in prices[period:]:
        ema_values.append(p * k + ema_values[-1] * (1 - k))
    return ema_values


def crossover_signal(fast: list[float], slow: list[float]) -> str:
    """
    Returns:
      'BUY_YES'  — fast crossed above slow on the last two candles
      'BUY_NO'   — fast crossed below slow on the last two candles
      'HOLD'     — no crossover
    """
    if len(fast) < 2 or len(slow) < 2:
        return "HOLD"
    # Align lengths (slow EMA is shorter due to warm-up)
    min_len = min(len(fast), len(slow))
    f, s = fast[-min_len:], slow[-min_len:]
    prev_above = f[-2] > s[-2]
    curr_above = f[-1] > s[-1]
    if not prev_above and curr_above:
        return "BUY_YES"
    if prev_above and not curr_above:
        return "BUY_NO"
    return "HOLD"


# ── Signal generation ─────────────────────────────────────────────────────────

@dataclass
class TradeSignal:
    market_id: str
    question: str
    yes_token_id: str
    no_token_id: str
    action: str           # BUY_YES | BUY_NO | HOLD | SKIP
    yes_price: float
    no_price: float
    spread: float
    ema_fast_last: float
    ema_slow_last: float
    reason: str
    timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())


async def analyze_market(market: Market, cfg: StrategyConfig = DEFAULT_CONFIG) -> TradeSignal:
    """Run the strategy on a single market. Returns a TradeSignal."""
    if len(market.clob_token_ids) < 2:
        return TradeSignal(
            market_id=market.id, question=market.question,
            yes_token_id="", no_token_id="",
            action="SKIP", yes_price=0, no_price=0, spread=0,
            ema_fast_last=0, ema_slow_last=0,
            reason="Market has no tradeable tokens",
        )

    yes_token = market.clob_token_ids[0]
    no_token  = market.clob_token_ids[1]

    # Fetch price + spread in parallel
    mid, spread_val = await asyncio.gather(
        get_midpoint(yes_token),
        get_spread(yes_token),
    )
    if mid is None:
        return TradeSignal(
            market_id=market.id, question=market.question,
            yes_token_id=yes_token, no_token_id=no_token,
            action="SKIP", yes_price=0, no_price=0, spread=0,
            ema_fast_last=0, ema_slow_last=0,
            reason="No midpoint price available",
        )

    yes_price = float(mid)
    no_price  = round(1.0 - yes_price, 4)
    spread    = float(spread_val or 0)

    # Liquidity check
    if spread > cfg.max_spread:
        return TradeSignal(
            market_id=market.id, question=market.question,
            yes_token_id=yes_token, no_token_id=no_token,
            action="SKIP", yes_price=yes_price, no_price=no_price, spread=spread,
            ema_fast_last=0, ema_slow_last=0,
            reason=f"Spread {spread:.3f} exceeds limit {cfg.max_spread}",
        )

    # Price sanity
    if yes_price < cfg.min_price or yes_price > cfg.max_price:
        return TradeSignal(
            market_id=market.id, question=market.question,
            yes_token_id=yes_token, no_token_id=no_token,
            action="SKIP", yes_price=yes_price, no_price=no_price, spread=spread,
            ema_fast_last=0, ema_slow_last=0,
            reason=f"YES price {yes_price:.3f} outside tradeable range [{cfg.min_price}, {cfg.max_price}]",
        )

    # 5-min candle data
    history = await get_price_history(yes_token, interval=cfg.interval, fidelity=cfg.fidelity)
    prices = [float(c.get("p") or c.get("price") or c.get("c") or 0) for c in history if c]
    prices = [p for p in prices if p > 0]

    if len(prices) < cfg.ema_slow + 2:
        return TradeSignal(
            market_id=market.id, question=market.question,
            yes_token_id=yes_token, no_token_id=no_token,
            action="HOLD", yes_price=yes_price, no_price=no_price, spread=spread,
            ema_fast_last=0, ema_slow_last=0,
            reason=f"Not enough history: {len(prices)} candles (need {cfg.ema_slow + 2})",
        )

    fast_ema = ema(prices, cfg.ema_fast)
    slow_ema = ema(prices, cfg.ema_slow)
    signal   = crossover_signal(fast_ema, slow_ema)

    ema_fast_last = fast_ema[-1] if fast_ema else 0
    ema_slow_last = slow_ema[-1] if slow_ema else 0

    reason_map = {
        "BUY_YES": f"EMA({cfg.ema_fast}) {ema_fast_last:.3f} crossed above EMA({cfg.ema_slow}) {ema_slow_last:.3f}",
        "BUY_NO":  f"EMA({cfg.ema_fast}) {ema_fast_last:.3f} crossed below EMA({cfg.ema_slow}) {ema_slow_last:.3f}",
        "HOLD":    f"EMA({cfg.ema_fast})={ema_fast_last:.3f}, EMA({cfg.ema_slow})={ema_slow_last:.3f} — no crossover",
    }

    return TradeSignal(
        market_id=market.id, question=market.question,
        yes_token_id=yes_token, no_token_id=no_token,
        action=signal,
        yes_price=yes_price, no_price=no_price, spread=spread,
        ema_fast_last=ema_fast_last, ema_slow_last=ema_slow_last,
        reason=reason_map.get(signal, "HOLD"),
    )


# ── Auto-trader ───────────────────────────────────────────────────────────────

_running = False
_trade_log: list[dict] = []


async def run_once(cfg: StrategyConfig = DEFAULT_CONFIG) -> list[TradeSignal]:
    """Fetch BTC markets, analyse each, optionally trade. Returns all signals."""
    markets = await get_active_btc_markets(limit=5)
    signals = await asyncio.gather(*[analyze_market(m, cfg) for m in markets])

    for sig in signals:
        entry = {
            "ts": sig.timestamp,
            "market": sig.question[:60],
            "action": sig.action,
            "yes_price": sig.yes_price,
            "reason": sig.reason,
        }
        _trade_log.append(entry)
        if len(_trade_log) > 500:
            _trade_log.pop(0)

        if cfg.auto_trade and sig.action in ("BUY_YES", "BUY_NO"):
            token_id = sig.yes_token_id if sig.action == "BUY_YES" else sig.no_token_id
            buy_price = sig.yes_price if sig.action == "BUY_YES" else sig.no_price
            try:
                order = await place_limit_order(
                    token_id=token_id,
                    side="BUY",
                    price=round(buy_price + 0.01, 2),  # slight premium to fill
                    size=round(cfg.order_size / buy_price, 2),
                )
                entry["order_id"] = order.order_id
                entry["order_status"] = order.status
                log.info("Placed order %s for %s", order.order_id, sig.question[:40])
            except Exception as exc:
                log.warning("Order failed for %s: %s", sig.question[:40], exc)
                entry["order_error"] = str(exc)

    return list(signals)


async def auto_trade_loop(interval_seconds: int = 300, cfg: StrategyConfig = DEFAULT_CONFIG):
    """Run the strategy every `interval_seconds` (default 5 min)."""
    global _running
    _running = True
    log.info("Auto-trader started. Interval: %ds.", interval_seconds)
    while _running:
        try:
            signals = await run_once(cfg)
            actionable = [s for s in signals if s.action not in ("HOLD", "SKIP")]
            log.info("Cycle done. %d signal(s) actionable out of %d markets.", len(actionable), len(signals))
        except Exception as exc:
            log.error("Strategy cycle error: %s", exc)
        await asyncio.sleep(interval_seconds)
    log.info("Auto-trader stopped.")


def stop_auto_trader():
    global _running
    _running = False


def get_trade_log() -> list[dict]:
    return list(reversed(_trade_log))


def is_running() -> bool:
    return _running


path: README.md
content: # Polymarket Edge

An OpenClaw skill that lets any AI agent browse Polymarket prediction markets, run a 5-minute BTC EMA crossover strategy, and optionally place live trades — with every API call billed via **SkillPay.me** (0.001 USDT / call).

> **Polymarket Edge** gives your agent a systematic, always-on trading edge on prediction markets — no manual research, no babysitting.

---

## Quick Start

```bash
cd polymarket-edge
cp .env.example .env          # edit SKILL_BILLING_API_KEY if needed
pip install -r requirements.txt
python main.py                # starts on http://localhost:8080
```

Open `http://localhost:8080/docs` for the interactive Swagger UI.

---

## Billing model

| Item | Value |
|------|-------|
| Platform | SkillPay.me (BNB Chain USDT) |
| Rate | 1 token per call |
| Token price | 1 USDT = 1000 tokens |
| Minimum top-up | 8 USDT |
| Withdrawal fee | 5% |

Every billed endpoint requires `?user_id=<your_id>`. If tokens run out the API returns `HTTP 402` with a `top_up_url`.

```
GET /balance?user_id=alice          → { balance: 3200, usdt_equivalent: 3.2 }
GET /topup?user_id=alice&amount=10  → { payment_url: "https://..." }
```

---

## Endpoints

### Free
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Health check |
| GET | `/balance` | Token balance |
| GET | `/topup` | Get USDT top-up link |

### Billed (1 token each)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/markets/search?q=bitcoin` | Search all markets |
| GET | `/markets/btc` | List active BTC markets |
| GET | `/market/{id}` | Single market details |
| GET | `/market/{token_id}/book` | Full order book |
| GET | `/market/{token_id}/price` | Mid-price + spread |
| GET | `/market/{token_id}/history` | 5-min OHLCV candles |
| POST | `/signal` | Run EMA crossover on BTC markets |
| GET | `/autotrader/status` | Is auto-trader running? |
| POST | `/autotrader/start` | Start 5-min BTC cycle |
| POST | `/autotrader/stop` | Stop auto-trader |
| GET | `/autotrader/log` | Last 50 trade log entries |
| GET | `/portfolio` | Open positions for a wallet |

---

## Strategy

Candle interval: **5 minutes**  
Signal: **EMA(5) / EMA(20) crossover** on the YES-token price

| Condition | Action |
|-----------|--------|
| EMA(5) crosses above EMA(20) | `BUY_YES` |
| EMA(5) crosses below EMA(20) | `BUY_NO` |
| No crossover | `HOLD` |
| Spread > 0.05 | `SKIP` (illiquid) |
| YES price < 0.15 or > 0.85 | `SKIP` (extreme) |

### Signal-only mode (default)
```bash
curl "http://localhost:8080/signal?user_id=alice"
```
Returns signals without placing any orders.

### Live trading mode
```bash
# 1. Set your wallet key
export POLYMARKET_PRIVATE_KEY=0x...

# 2. Install py-clob-client
pip install py-clob-client

# 3. Uncomment place_limit_order impl in polymarket_client.py

# 4. Start with auto_trade=true
curl -X POST "http://localhost:8080/autotrader/start?user_id=alice&auto_trade=true"
```

> ⚠️ Only use a burner wallet. Never commit your private key.

---

## Live trading integration (py-clob-client)

Replace the stub in `polymarket_client.py → place_limit_order()`:

```python
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import OrderArgs, OrderType

async def place_limit_order(token_id, side, price, size, order_type="GTC"):
    client = ClobClient(
        host="https://clob.polymarket.com",
        key=_PRIVATE_KEY,
        chain_id=137,           # Polygon mainnet
    )
    client.create_api_key()     # derive L2 credentials
    resp = client.create_and_post_order(
        OrderArgs(
            token_id=token_id,
            price=price,
            size=size,
            side=side,          # "BUY" / "SELL"
        )
    )
    return PlacedOrder(
        order_id=resp.orderID,
        status=resp.status,
        success=resp.success,
        error_msg=None,
    )
```

---

## Deploying to ClawHub

1. Push this folder to a public GitHub repo.
2. Register the skill at `clawhub.io` — point it at `main.py`.
3. Set env vars `SKILL_BILLING_API_KEY` and `SKILL_ID` in the ClawHub dashboard.
4. Users install the skill; SkillPay handles all payment collection.
5. Watch your balance grow at `skillpay.me/dashboard`. Withdraw anytime (5% fee, BNB Chain USDT).

---

## File structure

```
polymarket-edge/
├── main.py               FastAPI app — all routes
├── billing.py            SkillPay SDK (charge / balance / payment-link)
├── polymarket_client.py  Polymarket Gamma + CLOB REST wrappers
├── strategy.py           EMA crossover strategy + auto-trader loop
├── requirements.txt
├── .env.example
└── README.md
```


path: main.py
content: """
Polymarket Edge — Skill entrypoint
════════════════════════════════════════
Monetised via SkillPay.me (1 token = 0.001 USDT per call)

Routes
──────
GET  /                         health check (free)
GET  /balance?user_id=         check SkillPay token balance (free)
GET  /topup?user_id=           get USDC top-up link (free)

GET  /markets/search?q=        search any Polymarket market   [1 token]
GET  /markets/btc              list active BTC markets         [1 token]
GET  /market/{id}              get a single market by ID       [1 token]
GET  /market/{id}/book         full order book                 [1 token]
GET  /market/{id}/price        mid-price + spread              [1 token]
GET  /market/{id}/history      5-min price history             [1 token]

POST /signal                   run strategy signal on BTC mkts [1 token]
GET  /autotrader/status        is auto-trader running?         [1 token]
POST /autotrader/start         start 5-min BTC auto-trader     [1 token]
POST /autotrader/stop          stop auto-trader                [1 token]
GET  /autotrader/log           last 50 trade log entries       [1 token]

POST /portfolio?wallet=        view open positions             [1 token]
"""

import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Optional

from fastapi import FastAPI, Query, Path, BackgroundTasks
from fastapi.responses import JSONResponse

import billing
import polymarket_client as pm
import strategy as strat
from strategy import StrategyConfig

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
log = logging.getLogger("main")


# ── App lifecycle ─────────────────────────────────────────────────────────────

@asynccontextmanager
async def lifespan(app: FastAPI):
    log.info("Polymarket Edge skill started.")
    yield
    strat.stop_auto_trader()
    log.info("Skill shut down.")


app = FastAPI(
    title="Polymarket Edge",
    description=(
        "AI skill for Polymarket prediction market trading. "
        "Powered by SkillPay.me — each API call costs 1 token (0.001 USDT)."
    ),
    version="1.0.0",
    lifespan=lifespan,
)


# ── Helpers ───────────────────────────────────────────────────────────────────

async def _charge_or_reject(user_id: str) -> Optional[JSONResponse]:
    """Charge 1 token. Return 402 response if balance is zero."""
    result = await billing.charge_user(user_id)
    if not result.ok:
        return JSONResponse(
            status_code=402,
            content={
                "error": "Insufficient SkillPay tokens",
                "balance": result.balance,
                "top_up_url": result.payment_url,
                "message": "Top up with USDT on BNB Chain. 8 USDT = 8000 calls.",
            },
        )
    return None


# ── Free routes ───────────────────────────────────────────────────────────────

@app.get("/", tags=["Health"])
async def health():
    return {
        "skill": "Polymarket Edge",
        "status": "ok",
        "billing": "SkillPay.me — 1 token per call",
        "auto_trader_running": strat.is_running(),
    }


@app.get("/balance", tags=["Billing"])
async def get_balance(user_id: str = Query(..., description="Your SkillPay user ID")):
    balance = await billing.get_balance(user_id)
    return {"user_id": user_id, "balance": balance, "unit": "tokens", "usdt_equivalent": balance / 1000}


@app.get("/topup", tags=["Billing"])
async def get_topup_link(
    user_id: str = Query(...),
    amount: float = Query(8.0, description="USDT amount to top up (min 8)"),
):
    url = await billing.get_payment_link(user_id, amount)
    return {"payment_url": url, "amount_usdt": amount, "tokens_received": int(amount * 1000)}


# ── Market data (billed) ──────────────────────────────────────────────────────

@app.get("/markets/search", tags=["Markets"])
async def search_markets(
    q: str = Query(..., description="Search keyword"),
    limit: int = Query(10, ge=1, le=50),
    user_id: str = Query(...),
):
    if err := await _charge_or_reject(user_id):
        return err
    markets = await pm.search_markets(q, limit)
    return {
        "query": q,
        "count": len(markets),
        "markets": [
            {
                "id": m.id,
                "question": m.question,
                "slug": m.slug,
                "yes_price": m.outcome_prices[0] if m.outcome_prices else None,
                "no_price": m.outcome_prices[1] if len(m.outcome_prices) > 1 else None,
                "volume": m.volume,
                "liquidity": m.liquidity,
                "active": m.active,
                "end_date": m.end_date,
            }
            for m in markets
        ],
    }


@app.get("/markets/btc", tags=["Markets"])
async def btc_markets(
    limit: int = Query(10, ge=1, le=20),
    user_id: str = Query(...),
):
    if err := await _charge_or_reject(user_id):
        return err
    markets = await pm.get_active_btc_markets(limit)
    return {
        "count": len(markets),
        "markets": [
            {
                "id": m.id,
                "question": m.question,
                "yes_token_id": m.clob_token_ids[0] if m.clob_token_ids else None,
                "no_token_id": m.clob_token_ids[1] if len(m.clob_token_ids) > 1 else None,
                "yes_price": m.outcome_prices[0] if m.outcome_prices else None,
                "no_price": m.outcome_prices[1] if len(m.outcome_prices) > 1 else None,
                "volume": m.volume,
                "liquidity": m.liquidity,
                "end_date": m.end_date,
            }
            for m in markets
        ],
    }


@app.get("/market/{market_id}", tags=["Markets"])
async def get_market(
    market_id: str = Path(...),
    user_id: str = Query(...),
):
    if err := await _charge_or_reject(user_id):
        return err
    m = await pm.get_market_by_id(market_id)
    return {
        "id": m.id,
        "question": m.question,
        "slug": m.slug,
        "condition_id": m.condition_id,
        "outcomes": m.outcomes,
        "prices": m.outcome_prices,
        "yes_token_id": m.clob_token_ids[0] if m.clob_token_ids else None,
        "no_token_id": m.clob_token_ids[1] if len(m.clob_token_ids) > 1 else None,
        "volume": m.volume,
        "liquidity": m.liquidity,
        "active": m.active,
        "closed": m.closed,
        "end_date": m.end_date,
    }


@app.get("/market/{token_id}/book", tags=["Order Book"])
async def order_book(
    token_id: str = Path(..., description="YES or NO token ID"),
    user_id: str = Query(...),
):
    if err := await _charge_or_reject(user_id):
        return err
    book = await pm.get_order_book(token_id)
    return {
        "token_id": book.token_id,
        "last_trade_price": book.last_trade_price,
        "best_bid": book.bids[0].price if book.bids else None,
        "best_ask": book.asks[0].price if book.asks else None,
        "bid_depth": len(book.bids),
        "ask_depth": len(book.asks),
        "bids": [{"price": b.price, "size": b.size} for b in book.bids[:10]],
        "asks": [{"price": a.price, "size": a.size} for a in book.asks[:10]],
        "timestamp": book.timestamp,
    }


@app.get("/market/{token_id}/price", tags=["Order Book"])
async def market_price(
    token_id: str = Path(..., description="YES or NO token ID"),
    user_id: str = Query(...),
):
    if err := await _charge_or_reject(user_id):
        return err
    mid, spread = await asyncio.gather(
        pm.get_midpoint(token_id),
        pm.get_spread(token_id),
    )
    return {
        "token_id": token_id,
        "mid_price": mid,
        "spread": spread,
        "implied_probability_pct": round(float(mid) * 100, 2) if mid else None,
    }


@app.get("/market/{token_id}/history", tags=["Order Book"])
async def price_history(
    token_id: str = Path(..., description="YES or NO token ID"),
    interval: str = Query("5m", description="1m | 5m | 1h | 6h | 1d"),
    fidelity: int = Query(60, ge=5, le=200),
    user_id: str = Query(...),
):
    if err := await _charge_or_reject(user_id):
        return err
    history = await pm.get_price_history(token_id, interval, fidelity)
    return {"token_id": token_id, "interval": interval, "candles": len(history), "history": history}


# ── Strategy (billed) ─────────────────────────────────────────────────────────

@app.post("/signal", tags=["Strategy"])
async def run_signal(
    user_id: str = Query(...),
    ema_fast: int = Query(5),
    ema_slow: int = Query(20),
    auto_trade: bool = Query(False, description="Place real orders (requires POLYMARKET_PRIVATE_KEY)"),
):
    """
    Run EMA crossover strategy across top BTC markets.
    Returns trade signals: BUY_YES / BUY_NO / HOLD / SKIP per market.
    """
    if err := await _charge_or_reject(user_id):
        return err
    cfg = StrategyConfig(ema_fast=ema_fast, ema_slow=ema_slow, auto_trade=auto_trade)
    signals = await strat.run_once(cfg)
    return {
        "signal_count": len(signals),
        "actionable": len([s for s in signals if s.action in ("BUY_YES", "BUY_NO")]),
        "signals": [
            {
                "market": sig.question[:80],
                "action": sig.action,
                "yes_price": sig.yes_price,
                "no_price": sig.no_price,
                "spread": sig.spread,
                "ema_fast": sig.ema_fast_last,
                "ema_slow": sig.ema_slow_last,
                "reason": sig.reason,
                "timestamp": sig.timestamp,
            }
            for sig in signals
        ],
    }


# ── Auto-trader (billed) ──────────────────────────────────────────────────────

@app.get("/autotrader/status", tags=["Auto Trader"])
async def autotrader_status(user_id: str = Query(...)):
    if err := await _charge_or_reject(user_id):
        return err
    return {"running": strat.is_running(), "log_entries": len(strat.get_trade_log())}


@app.post("/autotrader/start", tags=["Auto Trader"])
async def autotrader_start(
    background_tasks: BackgroundTasks,
    user_id: str = Query(...),
    interval_seconds: int = Query(300, ge=60, description="Check interval in seconds (min 60)"),
    auto_trade: bool = Query(False, description="Enable live order placement"),
):
    """Start the 5-minute BTC auto-trader in the background."""
    if err := await _charge_or_reject(user_id):
        return err
    if strat.is_running():
        return JSONResponse(status_code=409, content={"error": "Auto-trader is already running."})

    cfg = StrategyConfig(auto_trade=auto_trade)
    background_tasks.add_task(strat.auto_trade_loop, interval_seconds, cfg)
    return {
        "status": "started",
        "interval_seconds": interval_seconds,
        "live_trading": auto_trade,
        "message": (
            "Auto-trader is scanning BTC markets every "
            f"{interval_seconds}s using EMA({cfg.ema_fast}/{cfg.ema_slow}) crossover strategy."
        ),
    }


@app.post("/autotrader/stop", tags=["Auto Trader"])
async def autotrader_stop(user_id: str = Query(...)):
    if err := await _charge_or_reject(user_id):
        return err
    strat.stop_auto_trader()
    return {"status": "stopped"}


@app.get("/autotrader/log", tags=["Auto Trader"])
async def autotrader_log(
    user_id: str = Query(...),
    limit: int = Query(50, ge=1, le=200),
):
    if err := await _charge_or_reject(user_id):
        return err
    entries = strat.get_trade_log()[:limit]
    return {"count": len(entries), "entries": entries}


# ── Portfolio (billed) ────────────────────────────────────────────────────────

@app.get("/portfolio", tags=["Portfolio"])
async def portfolio(
    wallet: str = Query(..., description="Your Polymarket proxy wallet address (0x...)"),
    user_id: str = Query(...),
):
    if err := await _charge_or_reject(user_id):
        return err
    positions, value = await asyncio.gather(
        pm.get_positions(wallet),
        pm.get_portfolio_value(wallet),
    )
    return {"wallet": wallet, "positions": positions, "value": value}


# ── Entrypoint ────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)


path: polymarket_client.py
content: """
Polymarket REST client — wraps Gamma (market data) and CLOB (trading) APIs.

Public endpoints (no auth):
  - Search / list markets
  - Order book, mid-price, spread, last trade
  - Price history

Authenticated endpoints (requires EVM private key):
  - Place limit / market orders
  - Cancel orders
  - Query own trades & balances

Auth flow (Polymarket L2):
  The CLOB API uses EIP-712 typed-data signatures.
  Set env var POLYMARKET_PRIVATE_KEY=0x<hex> to enable trading.
"""
import os
import hashlib
import time
from typing import Optional
import httpx
from dataclasses import dataclass, field

GAMMA_BASE = "https://gamma-api.polymarket.com"
CLOB_BASE  = "https://clob.polymarket.com"

_PRIVATE_KEY: Optional[str] = os.environ.get("POLYMARKET_PRIVATE_KEY")


# ── Data models ───────────────────────────────────────────────────────────────

@dataclass
class Market:
    id: str
    question: str
    slug: str
    condition_id: str
    clob_token_ids: list[str]
    outcome_prices: list[str]  # e.g. ["0.65", "0.35"]
    outcomes: list[str]        # e.g. ["Yes", "No"]
    volume: float
    liquidity: float
    active: bool
    closed: bool
    end_date: Optional[str]


@dataclass
class OrderBookLevel:
    price: float
    size: float


@dataclass
class OrderBook:
    token_id: str
    bids: list[OrderBookLevel]
    asks: list[OrderBookLevel]
    last_trade_price: Optional[float]
    timestamp: str


@dataclass
class PlacedOrder:
    order_id: str
    status: str
    success: bool
    error_msg: Optional[str]


# ── Helpers ───────────────────────────────────────────────────────────────────

def _parse_market(raw: dict) -> Market:
    prices = raw.get("outcomePrices") or raw.get("outcome_prices") or []
    outcomes = raw.get("outcomes") or []
    clob_ids = raw.get("clobTokenIds") or raw.get("clob_token_ids") or []
    return Market(
        id=str(raw.get("id", "")),
        question=raw.get("question", ""),
        slug=raw.get("slug", ""),
        condition_id=str(raw.get("conditionId") or raw.get("condition_id") or ""),
        clob_token_ids=[str(t) for t in clob_ids],
        outcome_prices=[str(p) for p in prices],
        outcomes=[str(o) for o in outcomes],
        volume=float(raw.get("volumeNum") or raw.get("volume_num") or 0),
        liquidity=float(raw.get("liquidityNum") or raw.get("liquidity_num") or 0),
        active=bool(raw.get("active", False)),
        closed=bool(raw.get("closed", False)),
        end_date=raw.get("endDate") or raw.get("end_date"),
    )


# ── Public market data ─────────────────────────────────────────────────────────

async def search_markets(keyword: str, limit: int = 10) -> list[Market]:
    """Full-text search across all Polymarket markets."""
    async with httpx.AsyncClient(timeout=15) as client:
        resp = await client.get(
            f"{GAMMA_BASE}/markets",
            params={"keyword": keyword, "limit": limit, "active": "true"},
        )
        resp.raise_for_status()
        data = resp.json()
        items = data if isinstance(data, list) else data.get("data", data.get("markets", []))
        return [_parse_market(m) for m in items]


async def get_active_btc_markets(limit: int = 10) -> list[Market]:
    """Fetch active prediction markets related to BTC / Bitcoin price."""
    async with httpx.AsyncClient(timeout=15) as client:
        resp = await client.get(
            f"{GAMMA_BASE}/markets",
            params={
                "keyword": "Bitcoin BTC price",
                "active": "true",
                "closed": "false",
                "limit": limit,
                "order": "volume_num",
            },
        )
        resp.raise_for_status()
        data = resp.json()
        items = data if isinstance(data, list) else data.get("data", data.get("markets", []))
        return [_parse_market(m) for m in items]


async def get_market_by_id(market_id: str) -> Market:
    """Fetch a single market by numeric ID or slug."""
    async with httpx.AsyncClient(timeout=15) as client:
        resp = await client.get(f"{GAMMA_BASE}/markets/{market_id}")
        resp.raise_for_status()
        return _parse_market(resp.json())


async def get_order_book(token_id: str) -> OrderBook:
    """Return the full order book for a token (YES or NO token_id)."""
    async with httpx.AsyncClient(timeout=15) as client:
        resp = await client.get(f"{CLOB_BASE}/book", params={"token_id": token_id})
        resp.raise_for_status()
        raw = resp.json()

        bids = [OrderBookLevel(price=float(b["price"]), size=float(b["size"])) for b in raw.get("bids", [])]
        asks = [OrderBookLevel(price=float(a["price"]), size=float(a["size"])) for a in raw.get("asks", [])]
        ltp = raw.get("last_trade_price")

        return OrderBook(
            token_id=token_id,
            bids=bids,
            asks=asks,
            last_trade_price=float(ltp) if ltp else None,
            timestamp=raw.get("timestamp", ""),
        )


async def get_midpoint(token_id: str) -> Optional[float]:
    """Return mid-price for a token."""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(f"{CLOB_BASE}/midpoint", params={"token_id": token_id})
        resp.raise_for_status()
        val = resp.json().get("mid")
        return float(val) if val is not None else None


async def get_price_history(token_id: str, interval: str = "1h", fidelity: int = 50) -> list[dict]:
    """
    Return OHLCV-style price history.
    interval: 1m, 5m, 1h, 6h, 1d
    """
    async with httpx.AsyncClient(timeout=20) as client:
        resp = await client.get(
            f"{CLOB_BASE}/prices-history",
            params={"market": token_id, "interval": interval, "fidelity": fidelity},
        )
        resp.raise_for_status()
        return resp.json().get("history", [])


async def get_spread(token_id: str) -> Optional[float]:
    """Return bid-ask spread for a token."""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(f"{CLOB_BASE}/spread", params={"token_id": token_id})
        resp.raise_for_status()
        val = resp.json().get("spread")
        return float(val) if val is not None else None


# ── Authenticated trading ──────────────────────────────────────────────────────

def _check_private_key() -> str:
    if not _PRIVATE_KEY:
        raise RuntimeError(
            "POLYMARKET_PRIVATE_KEY not set. "
            "Export your wallet private key to enable trading."
        )
    return _PRIVATE_KEY


async def get_clob_auth_headers() -> dict:
    """
    Build L2 auth headers for the CLOB API.
    Polymarket uses EIP-712 signatures. For simplicity this calls the
    /auth/derive-api-key endpoint which returns short-lived API credentials.
    In production use py-clob-client for the full signing flow.
    """
    # Real signing requires py-clob-client or manual eth_sign/EIP-712.
    # This stub documents the expected header format.
    raise NotImplementedError(
        "Install py-clob-client and call ClobClient.create_api_key() "
        "to get (api_key, secret, passphrase) for authenticated requests."
    )


async def place_limit_order(
    token_id: str,
    side: str,          # "BUY" or "SELL"
    price: float,       # 0.01 – 0.99
    size: float,        # number of shares
    order_type: str = "GTC",
) -> PlacedOrder:
    """
    Place a limit order. Requires POLYMARKET_PRIVATE_KEY.

    For a fully working implementation, integrate py-clob-client:
        pip install py-clob-client
        from py_clob_client.client import ClobClient
        client = ClobClient(host, key=pk, chain_id=137)
        resp = client.create_and_post_order(
            OrderArgs(token_id=..., price=..., size=..., side=...)
        )
    """
    _check_private_key()
    # ↓ Placeholder — replace with py-clob-client call in production
    raise NotImplementedError(
        "Integrate py-clob-client for live order placement. "
        "See README for instructions."
    )


async def place_market_order(
    token_id: str,
    side: str,
    amount_usdc: float,
) -> PlacedOrder:
    """
    Place a market buy (amount in USDC) or market sell (amount in shares).
    Requires POLYMARKET_PRIVATE_KEY.
    """
    _check_private_key()
    raise NotImplementedError(
        "Integrate py-clob-client for live market orders. "
        "See README for instructions."
    )


async def get_positions(wallet_address: str) -> list[dict]:
    """Return open positions for a wallet address (Gamma Data API)."""
    async with httpx.AsyncClient(timeout=15) as client:
        resp = await client.get(f"{GAMMA_BASE}/positions", params={"user": wallet_address})
        resp.raise_for_status()
        return resp.json()


async def get_portfolio_value(wallet_address: str) -> list[dict]:
    """Return current USD value of all positions."""
    async with httpx.AsyncClient(timeout=15) as client:
        resp = await client.get(f"{GAMMA_BASE}/value", params={"user": wallet_address})
        resp.raise_for_status()
        return resp.json()


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