Skill: diagnostikon/polymarket-candle-doji-breakout-trader (version 0.0.3)

--- name: polymarket-candle-doji-breakout-trader description: Detects doji patterns (48-52% probability) in crypto 5-minute interval markets on Polymarket and trades the post-doji breakout in the direction of the pre-doji trend. In candlestick analysis a doji after a directional trend signals indecision before a breakout -- the next interval tends to continue the prior trend direction. Targets BTC, ETH, SOL, and XRP Up or Down bundles with conviction-based position sizing. metadata: author: Diagnostikon owner: Diagnostikon version: "1.0.0" displayName: Candle Doji Breakout Trader difficulty: advanced --- # Candle -- Doji Breakout Trader > **This is a template.** > The default signal detects doji patterns in crypto 5-minute interval markets and trades the post-doji breakout using conviction-based sizing. The skill handles all the plumbing (interval parsing, doji detection, trend analysis, trade execution, safeguards). Your agent provides the alpha. ## Strategy Overview Polymarket lists 5-minute interval markets for BTC, ETH, SOL, and XRP: "Will Bitcoin be Up or Down in the 10:50AM-10:55AM ET interval?" These resolve to YES (up) or NO (down) based on the actual price movement. A doji is a 5-minute interval where probability sits at 48-52% -- zero directional conviction. In candlestick analysis, a doji appearing after a clear directional trend (2-3 consecutive UP or DOWN intervals) signals indecision before a breakout. The next interval after the doji tends to break out strongly, continuing the pre-doji trend direction, because the underlying momentum has only paused -- not reversed. ## Edge Unlike a generic mean-reversion or momentum strategy, the doji breakout specifically targets the transition from indecision back to trend continuation. The edge arises because: 1. **Doji = temporary pause, not reversal** -- when a coin trends UP for 3 intervals then prints a doji (48-52%), retail participants read this as "the move is over" and price the next interval near 50%. But the underlying trend momentum typically resumes. 2. **Market mispricing of continuation** -- the post-doji interval is systematically underpriced for continuation because participants anchor on the doji's neutrality rather than the preceding trend. 3. **Candlestick pattern validity** -- doji-after-trend is one of the most robust patterns in technical analysis, documented across equities, forex, and crypto on multiple timeframes. 4. **Bundle independence** -- each 5-minute interval resolves independently; the doji's neutral price does not mechanically prevent the next interval from continuing the trend. ## Signal Logic 1. Discover crypto interval markets via keyword search (`Bitcoin Up or Down`, `Ethereum Up or Down`, `Solana Up or Down`, `XRP Up or Down`) with a `get_markets(limit=200)` fallback 2. Parse each question to extract (coin, date, start_time_minutes, end_time_minutes) using regex 3. Group intervals by (coin, date) and sort by time 4. Classify each interval: UP if p > 0.55, DOWN if p < 0.45, NEUTRAL if 0.45-0.55 5. Scan for doji intervals (p between 48-52%, tunable via `SIMMER_DOJI_RANGE`) 6. Check the `TREND_LENGTH` intervals before each doji -- if ALL show the same direction (all UP or all DOWN), the post-doji interval is a breakout target 7. Trade the post-doji interval: - Pre-doji trend = UP and post-doji priced < YES_THRESHOLD -> buy YES (breakout not priced in) - Pre-doji trend = DOWN and post-doji priced > NO_THRESHOLD -> buy NO (breakout not priced in) 8. Size by conviction (distance from threshold), not flat amount ### Remix Signal Ideas - **Volume-weighted doji detection** -- require the doji interval to have above-average trading volume for a stronger signal; low-volume dojis are less meaningful - **Multi-timeframe confirmation** -- check if the trend also holds on a 15-minute or 1-hour timeframe before trading the breakout - **Order book imbalance** -- use Polymarket order book depth to detect hidden buying/selling pressure during the doji interval - **Cross-coin doji sync** -- if BTC prints a doji while ETH/SOL are still trending, the breakout signal is stronger (BTC leads) - **Volatility filter** -- skip doji patterns during low-volatility regimes where breakouts are weaker and less profitable ## Safety & Execution Mode **The skill defaults to paper trading (`venue="sim"`). Real trades only with `--live` flag.** | Scenario | Mode | Financial risk | |---|---|---| | `python trader.py` | Paper (sim) | None | | Cron / automaton | Paper (sim) | None | | `python trader.py --live` | Live (polymarket) | Real USDC | `autostart: false` and `cron: null` mean nothing runs automatically until configured in Simmer UI. ## Required Credentials | Variable | Required | Notes | |---|---|---| | `SIMMER_API_KEY` | Yes | Trading authority. Treat as a high-value credential. | ## Tunables (Risk Parameters) All declared as `tunables` in `clawhub.json` and adjustable from the Simmer UI. | Variable | Default | Purpose | |---|---|---| | `SIMMER_MAX_POSITION` | `40` | Max USDC per trade at full conviction | | `SIMMER_MIN_TRADE` | `5` | Floor for any trade (min USDC regardless of conviction) | | `SIMMER_MIN_VOLUME` | `3000` | Min market volume filter (USD) | | `SIMMER_MAX_SPREAD` | `0.10` | Max bid-ask spread | | `SIMMER_MIN_DAYS` | `1` | Min days until resolution | | `SIMMER_MAX_POSITIONS` | `10` | Max concurrent open positions | | `SIMMER_YES_THRESHOLD` | `0.38` | Buy YES only if market probability <= this | | `SIMMER_NO_THRESHOLD` | `0.62` | Sell NO only if market probability >= this | | `SIMMER_DOJI_RANGE` | `0.02` | Half-width of doji zone around 50% (48-52% by default) | | `SIMMER_TREND_LENGTH` | `3` | Min consecutive same-direction intervals before doji to qualify as trend | ## Edge Thesis Crypto 5-minute interval markets on Polymarket exhibit a systematic mispricing after doji patterns. When a coin trends in one direction for 2-3 intervals then prints a doji (48-52% probability), the market prices the post-doji interval near 50% -- interpreting the doji as trend exhaustion. In reality, the doji represents a temporary pause in an ongoing trend; the next interval continues the pre-doji direction more often than the market implies. This is the classic doji-breakout pattern from candlestick analysis, adapted to Polymarket's 5-minute interval structure. The skill exploits this gap with conviction-based sizing that scales with the distance from the trading threshold. ## Dependency `simmer-sdk` by Simmer Markets (SpartanLabsXyz) - PyPI: https://pypi.org/project/simmer-sdk/ - GitHub: https://github.com/SpartanLabsXyz/simmer-sdk

path: clawhub.json
content: {
  "emoji": "\ud83d\udd6f\ufe0f\ud83d\udcab",
  "requires": {
    "env": [
      "SIMMER_API_KEY"
    ],
    "pip": [
      "simmer-sdk"
    ]
  },
  "cron": null,
  "autostart": false,
  "automaton": {
    "managed": true,
    "entrypoint": "trader.py"
  },
  "tunables": [
    {
      "env": "SIMMER_MAX_POSITION",
      "type": "number",
      "default": 40,
      "range": [
        1,
        200
      ],
      "step": 1,
      "label": "Max position size USD"
    },
    {
      "env": "SIMMER_MIN_TRADE",
      "type": "number",
      "default": 5,
      "range": [
        1,
        50
      ],
      "step": 1,
      "label": "Min trade size USD (floor)"
    },
    {
      "env": "SIMMER_MIN_VOLUME",
      "type": "number",
      "default": 3000,
      "range": [
        0,
        500000
      ],
      "step": 500,
      "label": "Min market volume USD"
    },
    {
      "env": "SIMMER_MAX_SPREAD",
      "type": "number",
      "default": 0.1,
      "range": [
        0.01,
        0.5
      ],
      "step": 0.01,
      "label": "Max bid-ask spread"
    },
    {
      "env": "SIMMER_MIN_DAYS",
      "type": "number",
      "default": 1,
      "range": [
        0,
        30
      ],
      "step": 1,
      "label": "Min days until resolution"
    },
    {
      "env": "SIMMER_MAX_POSITIONS",
      "type": "number",
      "default": 10,
      "range": [
        1,
        20
      ],
      "step": 1,
      "label": "Max open positions"
    },
    {
      "env": "SIMMER_YES_THRESHOLD",
      "type": "number",
      "default": 0.38,
      "range": [
        0.05,
        0.5
      ],
      "step": 0.01,
      "label": "YES threshold -- buy YES when p <= this"
    },
    {
      "env": "SIMMER_NO_THRESHOLD",
      "type": "number",
      "default": 0.62,
      "range": [
        0.5,
        0.95
      ],
      "step": 0.01,
      "label": "NO threshold -- sell NO when p >= this"
    },
    {
      "env": "SIMMER_DOJI_RANGE",
      "type": "number",
      "default": 0.02,
      "range": [
        0.01,
        0.05
      ],
      "step": 0.01,
      "label": "Half-width of doji zone around 50%"
    },
    {
      "env": "SIMMER_TREND_LENGTH",
      "type": "number",
      "default": 3,
      "range": [
        2,
        6
      ],
      "step": 1,
      "label": "Min consecutive same-direction intervals before doji"
    }
  ],
  "version": "0.0.2"
}

path: trader.py
content: """
polymarket-candle-doji-breakout-trader
Detects doji patterns (48-52% probability) in crypto 5-minute interval markets
on Polymarket and trades the post-doji breakout in the direction of the pre-doji
trend. In candlestick analysis, a doji after a directional trend signals
indecision before a breakout -- the next interval tends to continue the prior
trend direction.

SAFE BY DEFAULT:
- No --live flag = paper trading (venue="sim"), zero financial risk.
- Cron/automaton runs default to paper mode.
- Real trades only with explicit --live flag (venue="polymarket").
"""
import os
import re
import argparse
from datetime import datetime, timezone
from simmer_sdk import SimmerClient

TRADE_SOURCE = "sdk:polymarket-candle-doji-breakout-trader"
SKILL_SLUG   = "polymarket-candle-doji-breakout-trader"

KEYWORDS = [
    'Bitcoin Up or Down', 'Ethereum Up or Down',
    'Solana Up or Down', 'XRP Up or Down',
]

# Risk parameters -- declared as tunables in clawhub.json, adjustable from Simmer UI.
MAX_POSITION   = float(os.environ.get("SIMMER_MAX_POSITION",  "40"))
MIN_VOLUME     = float(os.environ.get("SIMMER_MIN_VOLUME",    "3000"))
MAX_SPREAD     = float(os.environ.get("SIMMER_MAX_SPREAD",    "0.10"))
MIN_DAYS       = int(os.environ.get(  "SIMMER_MIN_DAYS",      "1"))
MAX_POSITIONS  = int(os.environ.get(  "SIMMER_MAX_POSITIONS", "10"))
YES_THRESHOLD  = float(os.environ.get("SIMMER_YES_THRESHOLD", "0.38"))
NO_THRESHOLD   = float(os.environ.get("SIMMER_NO_THRESHOLD",  "0.62"))
MIN_TRADE      = float(os.environ.get("SIMMER_MIN_TRADE",     "5"))

# Doji-specific tunables
DOJI_RANGE     = float(os.environ.get("SIMMER_DOJI_RANGE",    "0.02"))
TREND_LENGTH   = int(os.environ.get(  "SIMMER_TREND_LENGTH",  "3"))

_client: SimmerClient | None = None


def safe_print(text):
    """Print with fallback for non-ASCII characters (Windows cp1252 terminals)."""
    try:
        print(text)
    except UnicodeEncodeError:
        print(text.encode('ascii', 'replace').decode())


def get_client(live: bool = False) -> SimmerClient:
    """
    live=False -> venue="sim"  (paper trades -- safe default).
    live=True  -> venue="polymarket" (real trades, only with --live flag).
    """
    global _client, MAX_POSITION, MIN_VOLUME, MAX_SPREAD, MIN_DAYS, MAX_POSITIONS
    global YES_THRESHOLD, NO_THRESHOLD, MIN_TRADE, DOJI_RANGE, TREND_LENGTH
    if _client is None:
        venue = "polymarket" if live else "sim"
        _client = SimmerClient(
            [REDACTED_SECRET]"SIMMER_API_KEY"],
            venue=venue,
        )
        if live:
            _client.live = True
        try:
            _client.apply_skill_config(SKILL_SLUG)
        except AttributeError:
            pass  # apply_skill_config only available in Simmer runtime
        # Re-read params in case apply_skill_config updated os.environ.
        MAX_POSITION   = float(os.environ.get("SIMMER_MAX_POSITION",  str(MAX_POSITION)))
        MIN_VOLUME     = float(os.environ.get("SIMMER_MIN_VOLUME",    str(MIN_VOLUME)))
        MAX_SPREAD     = float(os.environ.get("SIMMER_MAX_SPREAD",    str(MAX_SPREAD)))
        MIN_DAYS       = int(os.environ.get(  "SIMMER_MIN_DAYS",      str(MIN_DAYS)))
        MAX_POSITIONS  = int(os.environ.get(  "SIMMER_MAX_POSITIONS", str(MAX_POSITIONS)))
        YES_THRESHOLD  = float(os.environ.get("SIMMER_YES_THRESHOLD", str(YES_THRESHOLD)))
        NO_THRESHOLD   = float(os.environ.get("SIMMER_NO_THRESHOLD",  str(NO_THRESHOLD)))
        MIN_TRADE      = float(os.environ.get("SIMMER_MIN_TRADE",     str(MIN_TRADE)))
        DOJI_RANGE     = float(os.environ.get("SIMMER_DOJI_RANGE",    str(DOJI_RANGE)))
        TREND_LENGTH   = int(os.environ.get(  "SIMMER_TREND_LENGTH",  str(TREND_LENGTH)))
    return _client


# ---------------------------------------------------------------------------
# Parsing: "Bitcoin Up or Down - March 29, 10:50AM-10:55AM ET"
#          "Ethereum Up or Down - March 28, 11AM ET"
# ---------------------------------------------------------------------------
_INTERVAL_RE = re.compile(
    r"^(BTC|Bitcoin|ETH|Ethereum|SOL|Solana|XRP)\s+Up or Down\s*[-\u2013]\s*"
    r"(.+?),?\s*"
    r"(\d{1,2}(?::\d{2})?\s*[AP]M)"
    r"(?:\s*[-\u2013]\s*(\d{1,2}(?::\d{2})?\s*[AP]M))?"
    r"\s*ET$",
    re.IGNORECASE,
)

_COIN_NORM = {
    "btc": "BTC", "bitcoin": "BTC",
    "eth": "ETH", "ethereum": "ETH",
    "sol": "SOL", "solana": "SOL",
    "xrp": "XRP",
}


def _parse_time_minutes(t: str) -> int:
    """Convert '10:50AM' or '11AM' to minutes since midnight."""
    t = t.upper().replace(" ", "")
    try:
        fmt = "%I:%M%p" if ":" in t else "%I%p"
        dt = datetime.strptime(t, fmt)
        return dt.hour * 60 + dt.minute
    except Exception:
        return 0


def parse_interval(question: str):
    """
    Return (coin, date_str, start_minutes, end_minutes) or None.
    coin is normalised to BTC/ETH/SOL/XRP.
    """
    m = _INTERVAL_RE.match(question.strip())
    if not m:
        return None
    coin_raw, date_str, start_str, end_str = m.groups()
    coin = _COIN_NORM.get(coin_raw.lower(), coin_raw.upper())
    start_min = _parse_time_minutes(start_str)
    end_min = _parse_time_minutes(end_str) if end_str else start_min + 5
    return coin, date_str.strip(), start_min, end_min


# ---------------------------------------------------------------------------
# Doji detection and trend analysis
# ---------------------------------------------------------------------------

def classify_interval(p: float) -> str:
    """Classify an interval as UP, DOWN, or NEUTRAL."""
    if p > 0.55:
        return "UP"
    if p < 0.45:
        return "DOWN"
    return "NEUTRAL"


def is_doji(p: float) -> bool:
    """Check if an interval is in the doji zone (48-52% by default)."""
    return (0.50 - DOJI_RANGE) <= p <= (0.50 + DOJI_RANGE)


def detect_doji_breakouts(markets: list) -> list:
    """
    Group interval markets by (coin, date). Sort each group by time.
    Find doji intervals with a clear pre-doji trend. Return list of
    (market, trend_direction) for the NEXT interval after a doji.
    """
    groups: dict[tuple[str, str], list] = {}
    for m in markets:
        parsed = parse_interval(getattr(m, "question", ""))
        if not parsed:
            continue
        coin, date_str, start_min, end_min = parsed
        key = (coin, date_str)
        groups.setdefault(key, []).append((start_min, end_min, m))

    for key in groups:
        groups[key].sort(key=lambda x: x[0])

    breakout_targets = []

    for (coin, date_str), intervals in groups.items():
        n = len(intervals)
        # Need at least TREND_LENGTH intervals + doji + post-doji target
        if n < TREND_LENGTH + 2:
            continue

        for i in range(TREND_LENGTH, n - 1):
            _, _, doji_mkt = intervals[i]
            p_doji = doji_mkt.current_probability

            if not is_doji(p_doji):
                continue

            # Check pre-doji trend: TREND_LENGTH intervals before the doji
            directions = []
            for j in range(i - TREND_LENGTH, i):
                _, _, prev_mkt = intervals[j]
                directions.append(classify_interval(prev_mkt.current_probability))

            # All must be the same direction (UP or DOWN, not NEUTRAL)
            if len(set(directions)) == 1 and directions[0] != "NEUTRAL":
                trend_dir = directions[0]  # "UP" or "DOWN"
                _, _, post_doji_mkt = intervals[i + 1]
                breakout_targets.append((post_doji_mkt, trend_dir))

    return breakout_targets


# ---------------------------------------------------------------------------
# Signal
# ---------------------------------------------------------------------------

def compute_signal(market, trend_direction: str) -> tuple[str | None, float, str]:
    """
    Returns (side, size, reasoning) or (None, 0, skip_reason).

    Conviction-based sizing per CLAUDE.md. Trades the post-doji breakout:
    - Pre-doji trend = UP and post-doji priced < 0.50 -> buy YES (breakout not priced in)
    - Pre-doji trend = DOWN and post-doji priced > 0.50 -> buy NO (breakout not priced in)
    """
    p = market.current_probability
    q = getattr(market, "question", "")

    # Spread gate
    if market.spread_cents is not None and market.spread_cents / 100 > MAX_SPREAD:
        return None, 0, f"Spread {market.spread_cents/100:.1%} > {MAX_SPREAD:.1%}"

    # Days-to-resolution gate
    if market.resolves_at:
        try:
            resolves = datetime.fromisoformat(market.resolves_at.replace("Z", "+00:00"))
            days = (resolves - datetime.now(timezone.utc)).days
            if days < MIN_DAYS:
                return None, 0, f"Only {days} days to resolve"
        except Exception:
            pass

    if trend_direction == "UP":
        # Pre-doji trend was UP. Expect breakout UP. If post-doji is still
        # priced below 0.50, the market hasn't priced in the breakout -> buy YES.
        if p <= YES_THRESHOLD:
            conviction = (YES_THRESHOLD - p) / YES_THRESHOLD
            size = max(MIN_TRADE, round(conviction * MAX_POSITION, 2))
            edge = YES_THRESHOLD - p
            return "yes", size, (
                f"DOJI-BREAK-UP YES {p:.0%} edge={edge:.0%} "
                f"size=${size} -- {q[:60]}"
            )
        return None, 0, (
            f"Doji-break-up but post-doji already priced in ({p:.0%} > "
            f"YES_THRESHOLD={YES_THRESHOLD:.0%}) -- {q[:60]}"
        )

    if trend_direction == "DOWN":
        # Pre-doji trend was DOWN. Expect breakout DOWN. If post-doji is still
        # priced above 0.50, the market hasn't priced in the breakout -> buy NO.
        if p >= NO_THRESHOLD:
            conviction = (p - NO_THRESHOLD) / (1 - NO_THRESHOLD)
            size = max(MIN_TRADE, round(conviction * MAX_POSITION, 2))
            edge = p - NO_THRESHOLD
            return "no", size, (
                f"DOJI-BREAK-DN NO YES={p:.0%} edge={edge:.0%} "
                f"size=${size} -- {q[:60]}"
            )
        return None, 0, (
            f"Doji-break-down but post-doji already priced in ({p:.0%} < "
            f"NO_THRESHOLD={NO_THRESHOLD:.0%}) -- {q[:60]}"
        )

    return None, 0, f"Unknown trend direction: {trend_direction}"


# ---------------------------------------------------------------------------
# Market discovery
# ---------------------------------------------------------------------------

def find_markets(client: SimmerClient) -> list:
    """Find active crypto interval markets via keyword search + get_markets fallback."""
    seen, unique = set(), []

    # 1. Keyword search
    for kw in KEYWORDS:
        try:
            for m in client.find_markets(query=kw):
                if m.id not in seen:
                    seen.add(m.id)
                    unique.append(m)
        except Exception as e:
            safe_print(f"[search] {kw!r}: {e}")

    # 2. Fallback: scan broad market list for interval matches
    try:
        for m in client.get_markets(limit=200):
            mid = getattr(m, "id", None)
            q = getattr(m, "question", "")
            if mid and mid not in seen and _INTERVAL_RE.match(q.strip()):
                seen.add(mid)
                unique.append(m)
    except Exception as e:
        safe_print(f"[fallback] get_markets: {e}")

    return unique


def context_ok(client: SimmerClient, market_id: str) -> tuple[bool, str]:
    """Check flip-flop and slippage safeguards."""
    try:
        ctx = client.get_market_context(market_id)
        if not ctx:
            return True, "no context"
        if ctx.get("discipline", {}).get("is_flip_flop"):
            reason = ctx["discipline"].get("flip_flop_reason", "recent reversal")
            return False, f"Flip-flop: {reason}"
        slip = ctx.get("slippage", {})
        if isinstance(slip, dict) and slip.get("slippage_pct", 0) > 0.15:
            return False, f"Slippage {slip['slippage_pct']:.1%}"
        for w in ctx.get("warnings", []):
            safe_print(f"  [warn] {w}")
    except Exception as e:
        safe_print(f"  [ctx] {market_id}: {e}")
    return True, "ok"


# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------

def run(live: bool = False) -> None:
    mode = "LIVE" if live else "PAPER (sim)"
    safe_print(
        f"[doji-breakout] mode={mode} max_pos=${MAX_POSITION} "
        f"doji_range={DOJI_RANGE} trend_len={TREND_LENGTH} "
        f"yes_thresh={YES_THRESHOLD} no_thresh={NO_THRESHOLD}"
    )

    client  = get_client(live=live)
    markets = find_markets(client)
    safe_print(f"[doji-breakout] {len(markets)} candidate interval markets")

    breakout_targets = detect_doji_breakouts(markets)
    safe_print(f"[doji-breakout] {len(breakout_targets)} doji breakout opportunities detected")

    placed = 0
    for m, trend_dir in breakout_targets:
        if placed >= MAX_POSITIONS:
            break

        side, size, reasoning = compute_signal(m, trend_dir)
        if not side:
            safe_print(f"  [skip] {reasoning}")
            continue

        ok, why = context_ok(client, m.id)
        if not ok:
            safe_print(f"  [skip] {why}")
            continue

        try:
            r = client.trade(
                market_id=m.id,
                side=side,
                amount=size,
                source=TRADE_SOURCE,
                skill_slug=SKILL_SLUG,
                reasoning=reasoning,
            )
            tag    = "(sim)" if r.simulated else "(live)"
            status = "OK" if r.success else f"FAIL:{r.error}"
            safe_print(f"  [trade] {side.upper()} ${size} {tag} {status} -- {reasoning[:70]}")
            if r.success:
                placed += 1
        except Exception as e:
            safe_print(f"  [error] {m.id}: {e}")

    safe_print(f"[doji-breakout] done. {placed} orders placed.")


if __name__ == "__main__":
    ap = argparse.ArgumentParser(
        description="Trades post-doji breakouts in crypto 5-min interval markets on Polymarket."
    )
    ap.add_argument("--live", action="store_true",
                    help="Real trades on Polymarket. Default is paper (sim) mode.")
    run(live=ap.parse_args().live)


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