Skill: diagnostikon/polymarket-candle-timeframe-mismatch-trader (version 0.0.3)

--- name: polymarket-candle-timeframe-mismatch-trader description: Multi-timeframe analysis for crypto Up or Down markets on Polymarket. Detects when 5-minute candle consensus diverges from the hourly market and trades convergence. When 4+ of 6 sub-intervals show a clear directional pattern but the hourly market remains neutral, the 5-min intervals are leading indicators and the longer timeframe will converge. metadata: author: Diagnostikon owner: Diagnostikon version: "1.0.0" displayName: Candle Timeframe Mismatch Trader difficulty: advanced --- # Candle Timeframe Mismatch Trader > **This is a template.** > The default signal detects timeframe mismatches between 5-min candle consensus and hourly markets -- remix it with real-time price feeds, volume data, or multi-coin correlation analysis. > The skill handles all the plumbing (market discovery, interval parsing, consensus detection, trade execution, safeguards). Your agent provides the alpha. ## Strategy Overview Polymarket lists both hourly and 5-minute interval "Up or Down" markets for BTC, ETH, and SOL. An hourly market like "Bitcoin Up or Down - March 29, 11AM ET" covers the same period as twelve 5-minute sub-intervals (11:00AM-11:05AM through 11:55AM-12:00PM). When 4+ of the 6 sub-intervals within an hour show a clear directional consensus (all biased UP or DOWN), but the hourly market is still priced in the neutral zone (0.45-0.55), there is a timeframe mismatch. The 5-minute markets are reacting faster to real-time price action while the hourly market lags. This skill trades the convergence -- the hourly market will catch up. ## Edge Multi-timeframe divergence is a well-known signal in traditional markets: 1. **Information cascade** -- 5-min intervals react to real-time BTC price movements within minutes; hourly markets aggregate slower and price in less frequently 2. **Liquidity gap** -- Hourly markets have more liquidity but update slower; 5-min markets are thinner but more responsive to current price action 3. **Convergence guarantee** -- Both timeframes cover the same underlying asset movement; if BTC is trending up in 5-min increments, the hourly must eventually reflect that 4. **Statistical edge** -- When 4+ of 6 sub-intervals agree on direction, the probability of the hourly resolving in that direction exceeds 70% ## Signal Logic 1. Discover active crypto "Up or Down" markets (BTC, ETH, SOL) via keyword search + `get_markets(limit=200)` fallback 2. Separate markets into hourly (no time range in name) and 5-min intervals (time range like 10:50AM-10:55AM) 3. Map each 5-min interval to its containing hour 4. For each hour: classify each sub-interval as UP (p > 0.55), DOWN (p < 0.45), or NEUTRAL 5. Count directional consensus: if `MIN_CONSENSUS`+ sub-intervals agree on UP or DOWN, consensus is established 6. Check the hourly market: if it is NEUTRAL (0.45-0.55), a mismatch exists 7. Trade the hourly market in the consensus direction: - 5-min consensus UP + hourly neutral: buy YES on hourly - 5-min consensus DOWN + hourly neutral: buy NO on hourly 8. Conviction scales with consensus strength (4/6 = base, 6/6 = full conviction) 9. Size = `max(MIN_TRADE, conviction * MAX_POSITION)` ### Remix Signal Ideas - **Real-time BTC price overlay**: Compare actual Binance price trend over the last 30 minutes against the 5-min consensus -- if price confirms the direction, increase conviction - **Volume-weighted consensus**: Weight each sub-interval's vote by its market volume -- a high-volume sub-interval at 60% UP counts more than a thin one at 56% - **Cross-coin confirmation**: If BTC, ETH, and SOL hourly markets all show the same mismatch, conviction increases (correlated crypto moves) - **Momentum decay filter**: Check if the 5-min consensus is strengthening or weakening over time -- if the most recent intervals are reverting to neutral, skip the trade - **Spread-adjusted entry**: Only trade when the hourly market spread narrows below 5 cents, indicating sufficient liquidity for the convergence trade ## 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 | | `SIMMER_MIN_VOLUME` | `3000` | Min market volume filter (USD) | | `SIMMER_MAX_SPREAD` | `0.08` | Max bid-ask spread | | `SIMMER_MIN_DAYS` | `0` | Min days until resolution (0 = allow same-session) | | `SIMMER_MAX_POSITIONS` | `8` | 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_MIN_CONSENSUS` | `4` | Min same-direction 5-min sub-intervals for consensus (out of 6) | ## Edge Thesis Hourly crypto markets on Polymarket aggregate the same price action that 5-minute intervals react to in real time. When the majority of sub-intervals within an hour converge on a direction, the information is already priced into the granular markets but not yet into the hourly market. This is a structural lag -- hourly markets have more liquidity and update less frequently. The convergence is not a question of if, but when. By detecting the mismatch early and trading the hourly market before it catches up, this skill captures the informational edge embedded in the faster-reacting 5-minute markets. ## 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\udd0d",
  "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,
        100
      ],
      "step": 1,
      "label": "Min trade size (USD)"
    },
    {
      "env": "SIMMER_MIN_VOLUME",
      "type": "number",
      "default": 3000,
      "range": [
        0,
        500000
      ],
      "step": 1000,
      "label": "Min market volume (USD)"
    },
    {
      "env": "SIMMER_MAX_SPREAD",
      "type": "number",
      "default": 0.08,
      "range": [
        0.01,
        0.5
      ],
      "step": 0.01,
      "label": "Max bid-ask spread"
    },
    {
      "env": "SIMMER_MIN_DAYS",
      "type": "number",
      "default": 0,
      "range": [
        0,
        180
      ],
      "step": 1,
      "label": "Min days until resolution"
    },
    {
      "env": "SIMMER_MAX_POSITIONS",
      "type": "number",
      "default": 8,
      "range": [
        1,
        20
      ],
      "step": 1,
      "label": "Max open positions"
    },
    {
      "env": "SIMMER_YES_THRESHOLD",
      "type": "number",
      "default": 0.38,
      "range": [
        0.01,
        0.49
      ],
      "step": 0.01,
      "label": "YES threshold"
    },
    {
      "env": "SIMMER_NO_THRESHOLD",
      "type": "number",
      "default": 0.62,
      "range": [
        0.51,
        0.99
      ],
      "step": 0.01,
      "label": "NO threshold"
    },
    {
      "env": "SIMMER_MIN_CONSENSUS",
      "type": "number",
      "default": 4,
      "range": [
        2,
        6
      ],
      "step": 1,
      "label": "Min same-direction sub-intervals for consensus"
    }
  ],
  "version": "0.0.2"
}

path: trader.py
content: """
polymarket-candle-timeframe-mismatch-trader
Multi-timeframe analysis for crypto "Up or Down" markets on Polymarket.
Detects when 5-minute candle consensus diverges from the hourly/30-min market
and trades the convergence.

Core edge: Polymarket lists both hourly and 5-minute interval markets for BTC,
ETH, and SOL. When 4+ of 6 five-minute sub-intervals within an hour show a
clear directional consensus (UP or DOWN), but the hourly market is still
NEUTRAL (0.45-0.55), the 5-min intervals are leading indicators. The hourly
market will converge to match the sub-interval consensus.

SAFE BY DEFAULT:
- No --live flag = paper trading (venue="sim"), zero financial risk.
- live=True -> venue="polymarket" (real trades, only with --live flag).
- Cron/automaton runs default to paper mode.
- Real trades only with explicit --live flag.
"""
import os
import re
import argparse
from datetime import datetime, timezone
from simmer_sdk import SimmerClient

TRADE_SOURCE = "sdk:polymarket-candle-timeframe-mismatch-trader"
SKILL_SLUG   = "polymarket-candle-timeframe-mismatch-trader"

KEYWORDS = ['Bitcoin Up or Down', 'Ethereum Up or Down', 'Solana 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.08"))
MIN_DAYS       = int(os.environ.get(  "SIMMER_MIN_DAYS",      "0"))
MAX_POSITIONS  = int(os.environ.get(  "SIMMER_MAX_POSITIONS", "8"))
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"))
# Skill-specific tunables
MIN_CONSENSUS  = int(os.environ.get(  "SIMMER_MIN_CONSENSUS", "4"))

# Bias thresholds for classifying interval direction
UP_BIAS_THRESHOLD   = 0.55   # p > 0.55 = Up bias
DOWN_BIAS_THRESHOLD = 0.45   # p < 0.45 = Down bias

_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, MIN_CONSENSUS
    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
        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)))
        MIN_CONSENSUS  = int(os.environ.get(  "SIMMER_MIN_CONSENSUS", str(MIN_CONSENSUS)))
    return _client


# ---------------------------------------------------------------------------
# Market parsing -- extract coin, date, time from crypto Up/Down questions
# ---------------------------------------------------------------------------

COIN_PATTERNS = {
    "BTC": re.compile(r"(?:Bitcoin|BTC)", re.I),
    "ETH": re.compile(r"(?:Ethereum|ETH)", re.I),
    "SOL": re.compile(r"(?:Solana|SOL)", re.I),
}

# Matches 5-min interval: "Bitcoin Up or Down - March 29, 10:50AM-10:55AM ET"
_INTERVAL_RE = re.compile(
    r"(Bitcoin|BTC|Ethereum|ETH|Solana|SOL)\s+(?:Up\s+or\s+Down)"
    r"\s*[-\u2013]\s*"
    r"(\w+\s+\d{1,2}),?\s*"
    r"(\d{1,2}:\d{2}\s*[AP]M)"
    r"\s*[-\u2013]\s*"
    r"(\d{1,2}:\d{2}\s*[AP]M)",
    re.I,
)

# Matches hourly/30-min market: "Bitcoin Up or Down - March 29, 11AM ET" (no sub-interval)
_HOURLY_RE = re.compile(
    r"(Bitcoin|BTC|Ethereum|ETH|Solana|SOL)\s+(?:Up\s+or\s+Down)"
    r"\s*[-\u2013]\s*"
    r"(\w+\s+\d{1,2}),?\s*"
    r"(\d{1,2}\s*[AP]M)\s*ET",
    re.I,
)


def normalize_coin(raw: str) -> str:
    """Normalize coin name to ticker."""
    r = raw.upper().strip()
    if r in ("BITCOIN", "BTC"):
        return "BTC"
    if r in ("ETHEREUM", "ETH"):
        return "ETH"
    if r in ("SOLANA", "SOL"):
        return "SOL"
    return r


def time_to_minutes(time_str: str) -> int:
    """Convert a time like '11:00AM' or '11AM' to minutes since midnight."""
    try:
        t = time_str.upper().replace(" ", "")
        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_5min_interval(question: str):
    """
    Parse a 5-min interval question.
    Returns (coin, date_str, start_minutes) or None.
    """
    m = _INTERVAL_RE.search(question)
    if m:
        coin = normalize_coin(m.group(1))
        date_str = m.group(2).strip()
        start_minutes = time_to_minutes(m.group(3).strip())
        return coin, date_str, start_minutes
    return None


def parse_hourly_market(question: str):
    """
    Parse an hourly/30-min market question (no sub-interval range).
    Returns (coin, date_str, start_minutes) or None.
    """
    # Exclude questions that have a time range (those are 5-min intervals)
    if re.search(r"\d{1,2}:\d{2}\s*[AP]M\s*[-\u2013]\s*\d{1,2}:\d{2}\s*[AP]M", question, re.I):
        return None
    m = _HOURLY_RE.search(question)
    if m:
        coin = normalize_coin(m.group(1))
        date_str = m.group(2).strip()
        start_minutes = time_to_minutes(m.group(3).strip())
        return coin, date_str, start_minutes
    return None


def is_crypto_updown_market(question: str) -> bool:
    """Return True if the question looks like a crypto Up or Down market."""
    q = question.lower()
    has_coin = any(w in q for w in ("bitcoin", "btc", "ethereum", "eth", "solana", "sol"))
    has_updown = "up or down" in q
    has_time = any(w in q for w in ("am", "pm"))
    return has_coin and has_updown and has_time


# ---------------------------------------------------------------------------
# Signal logic
# ---------------------------------------------------------------------------

def compute_signal(market) -> tuple[str | None, float, str]:
    """
    Returns (side, size, reasoning) or (None, 0, skip_reason).
    Standard conviction-based sizing per CLAUDE.md.
    """
    p = market.current_probability
    q = 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}d to resolve"
        except Exception:
            pass

    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"YES {p:.0%} edge={edge:.0%} size=${size} -- {q[:70]}"

    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"NO YES={p:.0%} edge={edge:.0%} size=${size} -- {q[:70]}"

    return None, 0, f"Neutral at {p:.1%} (outside {YES_THRESHOLD:.0%}/{NO_THRESHOLD:.0%} bands)"


def compute_mismatch_signal(hourly_market, consensus_dir: str,
                            consensus_count: int) -> tuple[str | None, float, str]:
    """
    Timeframe mismatch signal: hourly market is NEUTRAL but 5-min consensus
    is clearly directional. Trade the hourly to converge.

    consensus_dir: "up" or "down"
    consensus_count: how many of the sub-intervals agree
    """
    m = hourly_market
    p = float(m.current_probability)
    q = m.question

    # Spread gate
    if m.spread_cents is not None and m.spread_cents / 100 > MAX_SPREAD:
        return None, 0, f"Spread {m.spread_cents/100:.1%} > {MAX_SPREAD:.1%}"

    # Only trade if hourly is in the NEUTRAL zone (0.45-0.55)
    if p < DOWN_BIAS_THRESHOLD or p > UP_BIAS_THRESHOLD:
        return None, 0, f"Hourly not neutral ({p:.1%}), no mismatch"

    # Conviction scales with how many sub-intervals agree (4/6 = 0.33, 5/6 = 0.67, 6/6 = 1.0)
    base_conviction = (consensus_count - MIN_CONSENSUS) / (6 - MIN_CONSENSUS) if 6 > MIN_CONSENSUS else 0.5
    conviction = min(1.0, 0.33 + base_conviction * 0.67)

    size = max(MIN_TRADE, round(conviction * MAX_POSITION, 2))

    if consensus_dir == "up":
        # 5-min says UP, hourly is neutral -> buy YES on hourly
        return "yes", size, (
            f"TF-MISMATCH {consensus_count}/6 5min=UP hourly={p:.0%} "
            f"size=${size} -- {q[:55]}"
        )
    elif consensus_dir == "down":
        # 5-min says DOWN, hourly is neutral -> buy NO on hourly
        return "no", size, (
            f"TF-MISMATCH {consensus_count}/6 5min=DOWN hourly={p:.0%} "
            f"size=${size} -- {q[:55]}"
        )

    return None, 0, f"No mismatch signal -- {q[:60]}"


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"


def find_markets(client: SimmerClient) -> list:
    """
    Find active crypto Up/Down markets via keyword search + bulk fetch fallback,
    deduplicated.
    """
    seen: set[str] = set()
    unique: list = []

    # Keyword search
    for kw in KEYWORDS:
        try:
            for m in client.find_markets(query=kw):
                market_id = getattr(m, "id", None)
                if market_id and market_id not in seen:
                    q = getattr(m, "question", "")
                    if is_crypto_updown_market(q):
                        seen.add(market_id)
                        unique.append(m)
        except Exception as e:
            safe_print(f"[search] {kw!r}: {e}")

    # Bulk fallback
    try:
        for m in client.get_markets(limit=200):
            market_id = getattr(m, "id", None)
            if market_id and market_id not in seen:
                q = getattr(m, "question", "")
                if is_crypto_updown_market(q):
                    seen.add(market_id)
                    unique.append(m)
    except Exception as e:
        safe_print(f"[bulk-fetch] {e}")

    return unique


def run(live: bool = False) -> None:
    mode = "LIVE" if live else "PAPER (sim)"
    safe_print(
        f"[candle-timeframe-mismatch] mode={mode} max_pos=${MAX_POSITION} "
        f"min_consensus={MIN_CONSENSUS}"
    )

    client = get_client(live=live)
    markets = find_markets(client)
    safe_print(f"[candle-timeframe-mismatch] {len(markets)} candidate markets")

    # Separate hourly markets from 5-min interval markets
    # Key: (coin, date_str, hour_start_minutes) -> list of 5-min markets
    fivemin_by_hour: dict[tuple[str, str, int], list] = {}
    hourly_markets: dict[tuple[str, str, int], object] = {}

    for m in markets:
        q = getattr(m, "question", "")

        # Try hourly first (must exclude 5-min ranges)
        hourly = parse_hourly_market(q)
        if hourly:
            coin, date_str, start_min = hourly
            key = (coin, date_str, start_min)
            hourly_markets[key] = m
            continue

        # Try 5-min interval
        interval = parse_5min_interval(q)
        if interval:
            coin, date_str, start_min = interval
            # Map to the containing hour (floor to nearest 60 min)
            hour_start = (start_min // 60) * 60
            key = (coin, date_str, hour_start)
            fivemin_by_hour.setdefault(key, []).append((start_min, m))

    safe_print(
        f"[candle-timeframe-mismatch] {len(hourly_markets)} hourly markets, "
        f"{len(fivemin_by_hour)} hour-groups of 5-min intervals"
    )

    # Find mismatches: hourly market is neutral, but 5-min sub-intervals have consensus
    placed = 0
    for key, hourly_m in hourly_markets.items():
        if placed >= MAX_POSITIONS:
            break

        coin, date_str, hour_start = key
        sub_intervals = fivemin_by_hour.get(key, [])

        if len(sub_intervals) < MIN_CONSENSUS:
            safe_print(
                f"  [{coin} {date_str} {hour_start//60}h] "
                f"only {len(sub_intervals)} sub-intervals, need {MIN_CONSENSUS}"
            )
            continue

        # Sort by time and classify direction
        sorted_subs = sorted(sub_intervals, key=lambda x: x[0])
        up_count = 0
        down_count = 0
        for _, sub_m in sorted_subs:
            p = float(sub_m.current_probability)
            if p > UP_BIAS_THRESHOLD:
                up_count += 1
            elif p < DOWN_BIAS_THRESHOLD:
                down_count += 1

        # Determine consensus
        consensus_dir = None
        consensus_count = 0
        if up_count >= MIN_CONSENSUS:
            consensus_dir = "up"
            consensus_count = up_count
        elif down_count >= MIN_CONSENSUS:
            consensus_dir = "down"
            consensus_count = down_count

        if not consensus_dir:
            safe_print(
                f"  [{coin} {date_str} {hour_start//60}h] "
                f"no consensus (up={up_count}, down={down_count}, need {MIN_CONSENSUS})"
            )
            continue

        # Check if hourly market is neutral (mismatch exists)
        hourly_p = float(hourly_m.current_probability)
        safe_print(
            f"  [{coin} {date_str} {hour_start//60}h] "
            f"5min consensus={consensus_dir}({consensus_count}/{len(sorted_subs)}) "
            f"hourly={hourly_p:.0%}"
        )

        side, size, reasoning = compute_mismatch_signal(
            hourly_m, consensus_dir, consensus_count
        )
        if not side:
            safe_print(f"  [skip] {reasoning}")
            continue

        ok, why = context_ok(client, hourly_m.id)
        if not ok:
            safe_print(f"  [skip] {why}")
            continue

        try:
            r = client.trade(
                market_id=hourly_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[:100]}")
            if r.success:
                placed += 1
        except Exception as e:
            safe_print(f"  [error] {hourly_m.id}: {e}")

    safe_print(f"[candle-timeframe-mismatch] done. {placed} orders placed.")


if __name__ == "__main__":
    ap = argparse.ArgumentParser(
        description="Trades crypto timeframe mismatch 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: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)