Skill: richducat/dolph-candle-momentum (version 1.0.0)

--- name: polymarket-candle-momentum description: Trade Polymarket 5-minute crypto fast markets using 1-minute candle body analysis and volume surge detection from Binance. Scans BTC, ETH, SOL, XRP, BNB simultaneously and picks the strongest signal each cycle. Enters when the last candle has a strong body (>60% of range) confirmed by a volume spike (>1.5x average). Backtested at 86%+ win rate over 3 months. metadata: author: "Mibayy" version: "1.6.4" displayName: "Polymarket Candle Momentum" difficulty: "intermediate" --- # Polymarket Candle Momentum Trade Polymarket 5-minute crypto fast markets using **candle body analysis** and **volume surge detection** from Binance. A stronger, data-driven alternative to simple momentum signals. > **This is a template.** The default signal uses Binance 1-minute candle structure (body/range ratio + volume surge). Remix it with your own thresholds, additional indicators, or alternative data sources. The skill handles all the plumbing (market discovery, import, trade execution). Your agent provides the alpha. > Fast markets carry Polymarket's 10% fee (`is_paid: true`). The default thresholds are calibrated to maintain edge after fees. ## The Signal Most fast-market skills use simple price momentum (did price go up or down?). This skill looks at **how** price moved: 1. **Candle Body Ratio** - Measures the last 1-minute candle's body size relative to its full range. A body ratio > 60% means a decisive move with minimal wicks (strong conviction, not noise). 2. **Volume Surge** - Confirms the move with volume. The last candle's volume must be > 1.5x the average of the previous 3 candles. High volume + strong body = real move, not a fake-out. 3. **Direction Alignment** - Body direction (bullish/bearish) must align with the 5-minute momentum direction. ### Why This Works Better Simple momentum catches every wiggle. Candle body analysis filters for **quality** moves: - A 0.5% move on a doji candle (long wicks, tiny body) = noise. Skip. - A 0.3% move on a marubozu candle (90% body, no wicks) = conviction. Trade. Backtested on BTC/ETH/SOL/XRP/BNB (3 months, 6131 five-minute slots): - `body > 65% + volume > 1.5x`: **86.3% win rate** (N=292) - `body > 60% + volume > 1.5x`: **84.7% win rate** (N=333) ## Setup **pip dependencies:** `simmer-sdk`, `requests` **Environment variables:** - `SIMMER_API_KEY` (required) - get from simmer.markets/dashboard No other credentials needed. The script only reads `SIMMER_API_KEY` from the environment. All trades go through `SimmerClient.trade()` - no wallet private key, no direct CLOB access. ```bash export SIMMER_API_KEY="your_key_here" # Dry run (default, no trades placed) python candle_momentum.py # Live trading via SimmerClient python candle_momentum.py --live # Quiet mode for cron python candle_momentum.py --live --quiet ``` ## How to Run on a Loop The script runs one cycle. Set up a cron (every 2 min recommended for 5min markets): ```bash */2 * * * * cd /path/to/skill && SIMMER_API_KEY=your_key python candle_momentum.py --live --quiet ``` ## Security Notes - Only `SIMMER_API_KEY` is read from environment. Nothing else. - No host files are read. No logs written outside the script's own stdout. - `automaton.managed` is `false` - the skill does not auto-execute. - Dry-run by default. `--live` must be passed explicitly. ## Trade Execution Path 1. Fetches Binance public klines (no auth) for BTC, ETH, SOL, XRP, BNB 2. Scans all 5 assets, picks strongest signal (body_ratio x vol_surge score) 3. Finds matching Polymarket fast market via `SimmerClient` 4. If `--live`: calls `SimmerClient.trade()` with side, amount, reasoning 5. If dry-run: logs the signal, no trade placed ## Configuration Via environment variables only (no local file persistence): ```bash python candle_momentum.py --set body_threshold=0.65 python candle_momentum.py --set asset=ETH python candle_momentum.py --set vol_threshold=2.0 ``` ### Settings | Setting | Default | Env Var | Description | |---|---|---|---| | `body_threshold` | 0.60 | `CM_BODY_THRESHOLD` | Min candle body/range ratio (0-1) | | `vol_threshold` | 1.5 | `CM_VOL_THRESHOLD` | Min volume surge vs 3-candle average | | `max_position` | 5.0 | `CM_MAX_POSITION` | Max USD per trade | | `assets` | BTC,ETH,SOL,XRP,BNB | - | Assets to scan (picks best signal) | | `asset` | BTC | `CM_ASSET` | Single asset override (ignores `assets`) | | `window` | 5m | `CM_WINDOW` | Market window (5m or 15m) | | `min_time_remaining` | 60 | `CM_MIN_TIME` | Skip markets with less time (seconds) | | `lookback_candles` | 3 | `CM_LOOKBACK` | Candles for volume average | | `entry_threshold` | 0.05 | `CM_ENTRY_THRESHOLD` | Min price divergence from 50c | ## CLI Options ```bash python candle_momentum.py # Dry run python candle_momentum.py --live # Real trades python candle_momentum.py --live --quiet # Silent mode python candle_momentum.py --positions # Show open positions python candle_momentum.py --config # Show config python candle_momentum.py --set KEY=VALUE # Update config ``` ## Remix Ideas The candle body + volume signal is just the starting point. Ideas: - **Multi-timeframe**: Confirm 1m signal with 5m candle structure - **Wick analysis**: Filter out candles with long upper wicks (rejection) - **RSI filter**: Skip overbought/oversold conditions - **Multi-exchange**: Compare candle patterns across Binance + Coinbase - **Order flow**: Add Polymarket CLOB depth as confirmation To customize, edit `get_candle_signal()` in `candle_momentum.py`. ## Example Output ``` Polymarket Candle Momentum Trader ================================================== [DRY RUN] Use --live to enable trading. Configuration: Asset: BTC Body threshold: 0.60 (min candle body ratio) Vol threshold: 1.5x (min volume surge) Max position: $5.00 Discovering BTC fast markets... Found 3 active fast markets Selected: Bitcoin Up or Down - Mar 19, 10:30-10:35AM ET Expires in: 210s Current YES price: $0.490 Fetching BTC candle data (binance)... Last candle: O=84250 H=84312 L=84245 C=84305 Body ratio: 0.90 (strong!) Direction: BULLISH Volume surge: 2.3x avg Signal: BUY YES (body=0.90, vol=2.3x, direction=UP) Divergence: 5c (YES at 0.49, expected ~0.54) [DRY RUN] Would buy $5.00 YES ``` ## Source All trades tagged with `source: "sdk:candle-momentum"` for portfolio tracking.

path: _meta.json
content: ownerId: kn7504pxc91fksqt41f5w6az4d8378f2
slug: polymarket-candle-momentum
version: 2.0.0
publishedAt: 1774002040913

path: candle_momentum.py
content: #!/usr/bin/env python3
"""
Polymarket Candle Momentum Trader

Trade 5-minute crypto fast markets using 1-minute candle body analysis
and volume surge detection from Binance.

Signal: body_ratio > threshold + volume_surge > threshold + direction alignment
Backtested: 86%+ win rate on BTC/ETH/SOL/XRP/BNB (3 months)

Usage:
    python candle_momentum.py              # Dry run
    python candle_momentum.py --live       # Real trades
    python candle_momentum.py --live --quiet
"""

import os
import sys
import json
import time
import argparse
import requests
try:
    from simmer_sdk import SimmerClient
except ImportError:
    print("ERROR: simmer-sdk not installed. Run: pip install simmer-sdk")
    sys.exit(1)

# ---- Constants ----
TRADE_SOURCE = "sdk:polymarket-candle-momentum"
SKILL_SLUG = "polymarket-candle-momentum"

BINANCE_KLINES_URL = "https://api.binance.com/api/v3/klines"

ASSET_SYMBOLS = {
    "BTC": "BTCUSDT",
    "ETH": "ETHUSDT",
    "SOL": "SOLUSDT",
    "XRP": "XRPUSDT",
    "BNB": "BNBUSDT",
    "DOGE": "DOGEUSDT",
    "ADA": "ADAUSDT",
    "AVAX": "AVAXUSDT",
}

# ---- Config ----
DEFAULTS = {
    "body_threshold": 0.60,
    "vol_threshold": 1.5,
    "max_position": 5.0,
    "asset": "BTC",
    "assets": ["BTC", "ETH", "SOL", "XRP", "BNB"],
    "window": "5m",
    "min_time_remaining": 60,
    "lookback_candles": 3,
    "entry_threshold": 0.05,
}


def load_config():
    """Load config from env vars and defaults."""
    cfg = dict(DEFAULTS)

    # Env var overrides
    env_map = {
        "CM_BODY_THRESHOLD": ("body_threshold", float),
        "CM_VOL_THRESHOLD": ("vol_threshold", float),
        "CM_MAX_POSITION": ("max_position", float),
        "CM_ASSET": ("asset", str),
        "CM_WINDOW": ("window", str),
        "CM_MIN_TIME": ("min_time_remaining", int),
        "CM_LOOKBACK": ("lookback_candles", int),
        "CM_ENTRY_THRESHOLD": ("entry_threshold", float),
    }
    for env_key, (cfg_key, cast) in env_map.items():
        val = os.environ.get(env_key)
        if val is not None:
            cfg[cfg_key] = cast(val)

    return cfg


def save_config(cfg):
    """Print config (no file persistence - use env vars to override)."""
    print(json.dumps(cfg, indent=2))


_client = None
def get_client():
    """Create SimmerClient from environment."""
    global _client
    if _client is None:
        [REDACTED_SECRET]"SIMMER_API_KEY")
        if not [REDACTED_SECRET]"ERROR: SIMMER_API_KEY not set")
            sys.exit(1)
        _client = SimmerClient([REDACTED_SECRET], venue=os.environ.get("TRADING_VENUE", "polymarket"))
    return _client



def check_context(client, market_id, my_probability=None):
    """Check market context before trading (flip-flop, slippage, edge)."""
    try:
        params = {}
        if my_probability is not None:
            params["my_probability"] = my_probability
        ctx = client.get_market_context(market_id, **params)
        trading = ctx.get("trading", {})
        flip_flop = trading.get("flip_flop_warning")
        if flip_flop and "SEVERE" in flip_flop:
            return False, f"flip-flop: {flip_flop}"
        slippage = ctx.get("slippage", {})
        if slippage.get("slippage_pct", 0) > 0.15:
            return False, "slippage too high"
        edge = ctx.get("edge_analysis", {})
        if edge.get("recommendation") == "HOLD":
            return False, "edge below threshold"
        return True, "ok"
    except Exception:
        return True, "context unavailable"


# ---- Signal Logic ----

def fetch_binance_candles(symbol, interval="1m", limit=10):
    """Fetch recent klines from Binance."""
    try:
        resp = requests.get(
            BINANCE_KLINES_URL,
            params={"symbol": symbol, "interval": interval, "limit": limit},
            timeout=10,
        )
        resp.raise_for_status()
        raw = resp.json()
        candles = []
        for k in raw:
            candles.append({
                "open": float(k[1]),
                "high": float(k[2]),
                "low": float(k[3]),
                "close": float(k[4]),
                "volume": float(k[5]),
                "close_time": int(k[6]),
            })
        return candles
    except Exception as e:
        print(f"  ERROR fetching Binance data: {e}")
        return None


def get_candle_signal(cfg):
    """
    Analyze the last 1-minute candle for body strength and volume surge.

    Returns: (direction, body_ratio, vol_surge, reasoning) or (None, ...)
    """
    asset = cfg["asset"].upper()
    symbol = ASSET_SYMBOLS.get(asset)
    if not symbol:
        print(f"  ERROR: Unknown asset {asset}")
        return None, 0, 0, "unknown asset"

    lookback = cfg["lookback_candles"]
    candles = fetch_binance_candles(symbol, "1m", limit=lookback + 2)
    if not candles or len(candles) < lookback + 1:
        return None, 0, 0, "insufficient candle data"

    # Last closed candle (second to last, since last may be incomplete)
    last = candles[-2]
    prev_candles = candles[-(lookback + 2):-2]

    # Body ratio
    candle_range = last["high"] - last["low"]
    if candle_range <= 0:
        return None, 0, 0, "zero range candle"

    body = abs(last["close"] - last["open"])
    body_ratio = body / candle_range

    # Direction
    is_bullish = last["close"] > last["open"]

    # Volume surge
    if prev_candles:
        avg_vol = sum(c["volume"] for c in prev_candles) / len(prev_candles)
        vol_surge = last["volume"] / avg_vol if avg_vol > 0 else 0
    else:
        vol_surge = 1.0

    # 5-minute momentum for alignment
    first_price = candles[0]["open"]
    last_price = last["close"]
    momentum_pct = (last_price - first_price) / first_price if first_price > 0 else 0
    momentum_up = momentum_pct > 0

    # Check thresholds
    body_ok = body_ratio >= cfg["body_threshold"]
    vol_ok = vol_surge >= cfg["vol_threshold"]
    direction_aligned = (is_bullish and momentum_up) or (not is_bullish and not momentum_up)

    if not body_ok:
        reason = f"body_ratio {body_ratio:.2f} < {cfg['body_threshold']}"
        return None, body_ratio, vol_surge, reason

    if not vol_ok:
        reason = f"vol_surge {vol_surge:.1f}x < {cfg['vol_threshold']}x"
        return None, body_ratio, vol_surge, reason

    if not direction_aligned:
        reason = f"direction mismatch: candle={'BULL' if is_bullish else 'BEAR'}, momentum={'UP' if momentum_up else 'DOWN'}"
        return None, body_ratio, vol_surge, reason

    direction = "UP" if momentum_up else "DOWN"
    reasoning = (
        f"Candle body={body_ratio:.0%} (>{cfg['body_threshold']:.0%}), "
        f"vol={vol_surge:.1f}x (>{cfg['vol_threshold']}x), "
        f"{asset} {direction}, momentum={momentum_pct:+.3%}"
    )

    return direction, body_ratio, vol_surge, reasoning


# ---- Market Discovery ----

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

def find_fast_markets(client, cfg):
    """Find active fast markets for the configured asset via Simmer REST API."""
    try:
        [REDACTED_SECRET]"SIMMER_API_KEY", "")
        resp = requests.get(
            f"{SIMMER_API_BASE}/api/sdk/fast-markets",
            headers={"Authorization": f"Bearer {api_key}"},
            params={
                "asset": cfg["asset"],
                "window": cfg["window"],
                "limit": 10,
            },
            timeout=10,
        )
        resp.raise_for_status()
        return resp.json().get("markets", [])
    except Exception as e:
        print(f"  ERROR discovering markets: {e}")
        return []


def select_best_market(markets, cfg):
    """Pick the best market to trade (most time remaining, live)."""
    min_time = cfg["min_time_remaining"]
    viable = []

    for m in markets:
        if not m.get("is_live_now", False):
            continue

        resolves_at = m.get("resolves_at", "")
        if resolves_at:
            from datetime import datetime, timezone
            try:
                res_dt = datetime.fromisoformat(resolves_at.replace("Z", "+00:00"))
                now = datetime.now(timezone.utc)
                remaining = (res_dt - now).total_seconds()
                if remaining < min_time:
                    continue
                m["_remaining"] = remaining
                viable.append(m)
            except Exception:
                continue

    if not viable:
        return None

    # Pick market with most time remaining for best entry
    viable.sort(key=lambda x: x.get("_remaining", 0), reverse=True)
    return viable[0]


# ---- Main ----

def run_cycle(client, cfg, live=False, quiet=False):
    """Run one trading cycle across all configured assets."""

    if not quiet:
        mode = "LIVE" if live else "DRY RUN"
        print(f"\n  Polymarket Candle Momentum Trader")
        print("=" * 50)
        print(f"  [{mode}]{'  Use --live to enable.' if not live else ''}")
        print(f"\n  Config:")
        print(f"  Assets:         {cfg.get('assets', [cfg['asset']])}")
        print(f"  Body threshold: {cfg['body_threshold']} (min candle body ratio)")
        print(f"  Vol threshold:  {cfg['vol_threshold']}x (min volume surge)")
        print(f"  Max position:   ${cfg['max_position']:.2f}")
        print()

    assets = cfg.get("assets", [cfg["asset"]])

    # Scan all assets, collect signals
    best_signal = None  # (body_ratio * vol_surge score, asset, direction, body, vol, reasoning, market)

    for asset in assets:
        asset_cfg = dict(cfg)
        asset_cfg["asset"] = asset

        # Discover markets for this asset
        markets = find_fast_markets(client, asset_cfg)
        if not markets:
            if not quiet:
                print(f"  {asset}: no active fast markets")
            continue

        market = select_best_market(markets, asset_cfg)
        if not market:
            if not quiet:
                print(f"  {asset}: no markets with >{cfg['min_time_remaining']}s remaining")
            continue

        direction, body_ratio, vol_surge, reasoning = get_candle_signal(asset_cfg)

        if direction is None:
            if not quiet:
                print(f"  {asset}: skip ({reasoning})")
            continue

        # Check price divergence
        yes_price = market.get("current_probability", 0.5)
        if direction == "UP":
            divergence = (0.50 + cfg["entry_threshold"]) - yes_price
        else:
            divergence = yes_price - (0.50 - cfg["entry_threshold"])

        if divergence < 0:
            if not quiet:
                print(f"  {asset}: price already moved (YES=${yes_price:.3f})")
            continue

        score = body_ratio * vol_surge
        if best_signal is None or score > best_signal[0]:
            best_signal = (score, asset, direction, body_ratio, vol_surge, reasoning, market)
            if not quiet:
                print(f"  {asset}: SIGNAL body={body_ratio:.0%} vol={vol_surge:.1f}x dir={direction} score={score:.2f}")

    if best_signal is None:
        if not quiet:
            print("  No tradeable signal across all assets.")
        return {"action": "skip", "reason": "no signal"}

    score, asset, direction, body_ratio, vol_surge, reasoning, market = best_signal
    market_id = market["id"]
    yes_price = market.get("current_probability", 0.5)
    remaining = market.get("_remaining", 0)
    side = "yes" if direction == "UP" else "no"

    if not quiet:
        print(f"\n  Best signal: {asset} {direction} (score={score:.2f})")
        print(f"  Market: {market.get('question', market_id)[:60]}")
        print(f"  Expires in: {int(remaining)}s | YES=${yes_price:.3f}")

    if not quiet:
        print(f"\n  SIGNAL: BUY {side.upper()} (body={body_ratio:.0%}, vol={vol_surge:.1f}x, {asset} {direction})")

    # 5. Execute or dry-run
    amount = cfg["max_position"]
    full_reasoning = f"[candle-momentum] {asset} {reasoning}"

    if not live:
        if not quiet:
            print(f"\n  [DRY RUN] Would buy ${amount:.2f} {side.upper()}")
        return {
            "action": "dry_run",
            "side": side,
            "amount": amount,
            "body_ratio": body_ratio,
            "vol_surge": vol_surge,
            "direction": direction,
        }

    # Live trade
    ok, reason = check_context(client, market_id)
    if not ok:
        if not quiet:
            print(f"  Skipping trade: {reason}")
        return {"action": "skip", "reason": reason}

    try:
        result = client.trade(
            market_id=market_id,
            side=side,
            amount=amount,
            venue=os.environ.get("TRADING_VENUE", "polymarket"),
            source=TRADE_SOURCE,
            skill_slug=SKILL_SLUG,
            reasoning=full_reasoning,
        )

        if not quiet:
            if result.success:
                print(f"\n  TRADED: {result.shares_bought:.1f} shares {side.upper()} for ${result.cost:.2f}")
            else:
                print(f"\n  Trade failed: {result.error}")
                if result.hint:
                    print(f"  Hint: {result.hint}")

        return {
            "action": "traded" if result.success else "failed",
            "side": side,
            "shares": result.shares_bought if result.success else 0,
            "cost": result.cost if result.success else 0,
            "body_ratio": body_ratio,
            "vol_surge": vol_surge,
        }

    except Exception as e:
        if not quiet:
            print(f"\n  Trade error: {e}")
        return {"action": "error", "error": str(e)}


def show_positions(client):
    """Show current fast market positions."""
    try:
        data = client.get_positions(source="candle-momentum")
        positions = data.get("positions", [])
        if not positions:
            print("  No candle-momentum positions.")
            return
        print(f"\n  Candle Momentum Positions ({len(positions)}):")
        for p in positions:
            q = p.get("question", "")[:50]
            pnl = p.get("pnl", 0)
            print(f"  {q}  PnL: {pnl:+.2f} {p.get('currency', '')}")
    except Exception as e:
        print(f"  Error: {e}")


def main():
    parser = argparse.ArgumentParser(description="Polymarket Candle Momentum Trader")
    parser.add_argument("--live", action="store_true", help="Enable real trading")
    parser.add_argument("--quiet", action="store_true", help="Minimal output")
    parser.add_argument("--positions", action="store_true", help="Show positions")
    parser.add_argument("--config", action="store_true", help="Show config")
    parser.add_argument("--set", action="append", help="Set config KEY=VALUE")
    args = parser.parse_args()

    cfg = load_config()

    # Handle --set
    if args.set:
        for kv in args.set:
            if "=" not in kv:
                print(f"  Invalid: {kv} (use KEY=VALUE)")
                continue
            k, v = kv.split("=", 1)
            if k in DEFAULTS:
                cast = type(DEFAULTS[k])
                cfg[k] = cast(v)
            else:
                print(f"  Unknown key: {k}")
        save_config(cfg)
        print(f"  Config updated: {cfg}")
        return

    if args.config:
        print(json.dumps(cfg, indent=2))
        return

    client = get_client()

    if args.positions:
        show_positions(client)
        return

    result = run_cycle(client, cfg, live=args.live, quiet=args.quiet)

    if not args.quiet:
        print(f"\n  Result: {result.get('action', 'unknown')}")


if __name__ == "__main__":
    main()


path: clawhub.json
content: {
  "emoji": "🕯️",
  "version": "2.0.0",
  "requires": {
    "pip": [
      "simmer-sdk",
      "requests"
    ],
    "env": [
      "SIMMER_API_KEY"
    ]
  },
  "cron": "*/2 * * * *",
  "automaton": {
    "managed": false,
    "entrypoint": "candle_momentum.py"
  }
}


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